返回 DeepSeek-Reasonix
executor.go
根目录 / internal / browser / cdp / executor.go
1 package cdp
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9 "path/filepath"
10 "slices"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/browser"
17 )
18
19 const (
20 defaultNavigateTimeout = 30 * time.Second
21 healthTTL = 30 * time.Second
22 // maxOperations bounds the single-use ledger. The ceiling exists so a
23 // runaway loop cannot grow it without limit; no real session approaches it.
24 maxOperations = 50000
25 )
26
27 // Options configures one CDP-backed executor.
28 type Options struct {
29 // Endpoint is an existing DevTools endpoint such as http://127.0.0.1:9222
30 // or a ws:// socket URL. Empty launches a browser this executor owns.
31 Endpoint string
32 // AllowRemoteEndpoint permits a non-loopback Endpoint. A DevTools endpoint
33 // grants full control of the browser, so this stays off by default.
34 AllowRemoteEndpoint bool
35 ChromePath string
36 ChromeArgs []string
37 // UserDataDir is the launched browser's profile. Empty uses a temporary
38 // directory that is removed on Shutdown.
39 UserDataDir string
40 Headless bool
41 LaunchTimeout time.Duration
42 NavigateTimeout time.Duration
43 // ArtifactDir receives screenshots and downloads. Empty uses a temporary
44 // directory that is removed on Shutdown.
45 ArtifactDir string
46 // UploadRoots are the directories browser_upload may read from, beside the
47 // artifact directory. Empty leaves only the artifact directory, because a
48 // file input on an untrusted page must never reach the whole filesystem.
49 UploadRoots []string
50 HTTPClient *http.Client
51 }
52
53 // Executor drives an external Chrome and satisfies browser.Executor. It owns
54 // the operation ledger and document tokens that a raw browser has no notion of.
55 type Executor struct {
56 opts Options
57 conn *conn
58 proc *launched
59 artifacts string
60 ownedDir bool
61 navWait time.Duration
62 uploads uploadRoots
63
64 mu sync.Mutex
65 pages map[string]*page
66 ops map[string]string
67 contexts map[string]int
68 downloads map[string]*downloadRecord
69 nextTab int
70 closed bool
71 healthyAt time.Time
72 now func() time.Time
73 }
74
75 var _ browser.Executor = (*Executor)(nil)
76
77 // New attaches to the configured DevTools endpoint, or launches a browser when
78 // none is configured, and returns the executor that drives it.
79 func New(ctx context.Context, opts Options) (*Executor, error) {
80 artifacts, ownedDir, err := artifactDir(opts.ArtifactDir)
81 if err != nil {
82 return nil, err
83 }
84 e := &Executor{
85 opts: opts, artifacts: artifacts, ownedDir: ownedDir,
86 navWait: cmpDuration(opts.NavigateTimeout, defaultNavigateTimeout),
87 uploads: newUploadRoots(opts.UploadRoots, artifacts),
88 pages: map[string]*page{},
89 ops: map[string]string{},
90 contexts: map[string]int{},
91 downloads: map[string]*downloadRecord{},
92 now: time.Now,
93 }
94 wsURL, err := e.endpoint(ctx)
95 if err != nil {
96 e.cleanup()
97 return nil, err
98 }
99 if e.conn, err = dialConn(ctx, wsURL); err != nil {
100 e.cleanup()
101 return nil, err
102 }
103 e.watch()
104 if err := e.setDownloadBehavior(ctx, ""); err != nil {
105 e.Shutdown()
106 return nil, err
107 }
108 return e, nil
109 }
110
111 // endpoint resolves the socket to drive, launching a browser when the caller
112 // configured no endpoint of their own.
113 func (e *Executor) endpoint(ctx context.Context) (string, error) {
114 if e.opts.Endpoint == "" {
115 proc, wsURL, err := launchChrome(ctx, e.opts)
116 if err != nil {
117 return "", err
118 }
119 e.proc = proc
120 return wsURL, nil
121 }
122 launchCtx, cancel := context.WithTimeout(ctx, cmpDuration(e.opts.LaunchTimeout, 10*time.Second))
123 defer cancel()
124 return waitForEndpoint(launchCtx, e.opts.Endpoint, e.opts.HTTPClient, e.opts.AllowRemoteEndpoint)
125 }
126
127 // watch binds browser-level events: a detached target is a tab that is gone,
128 // and download events feed the per-tab download list.
129 func (e *Executor) watch() {
130 e.conn.on("", "Target.detachedFromTarget", func(params json.RawMessage) {
131 var ev struct {
132 SessionID string `json:"sessionId"`
133 }
134 if err := json.Unmarshal(params, &ev); err != nil {
135 return
136 }
137 if p := e.pageBySession(ev.SessionID); p != nil {
138 p.markDead()
139 }
140 })
141 e.conn.on("", "Browser.downloadWillBegin", e.downloadWillBegin)
142 e.conn.on("", "Browser.downloadProgress", e.downloadProgress)
143 }
144
145 // Shutdown releases the browser: a launched one is killed, an attached one
146 // keeps running with the tabs this executor opened closed behind it.
147 func (e *Executor) Shutdown() {
148 e.mu.Lock()
149 if e.closed {
150 e.mu.Unlock()
151 return
152 }
153 e.closed = true
154 pages := make([]*page, 0, len(e.pages))
155 for _, p := range e.pages {
156 pages = append(pages, p)
157 }
158 e.pages = map[string]*page{}
159 e.mu.Unlock()
160
161 if e.conn != nil {
162 shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
163 for _, p := range pages {
164 p.unsubscribe()
165 _ = e.conn.call(shutdown, "", "Target.closeTarget", map[string]any{"targetId": p.target}, nil)
166 }
167 cancel()
168 e.conn.close()
169 }
170 e.cleanup()
171 }
172
173 func (e *Executor) cleanup() {
174 if e.proc != nil {
175 e.proc.stop()
176 }
177 if e.ownedDir && e.artifacts != "" {
178 _ = os.RemoveAll(e.artifacts)
179 }
180 }
181
182 // Available reports whether the browser still answers. The probe is cached so
183 // a tool surface that asks per turn does not round-trip every time.
184 func (e *Executor) Available(ctx context.Context) bool {
185 e.mu.Lock()
186 closed, fresh := e.closed, !e.healthyAt.IsZero() && e.now().Sub(e.healthyAt) < healthTTL
187 e.mu.Unlock()
188 if closed || e.conn == nil || e.conn.closed() {
189 return false
190 }
191 if fresh {
192 return true
193 }
194 probe, cancel := context.WithTimeout(ctx, 5*time.Second)
195 defer cancel()
196 if err := e.conn.call(probe, "", "Browser.getVersion", nil, nil); err != nil {
197 return false
198 }
199 e.mu.Lock()
200 e.healthyAt = e.now()
201 e.mu.Unlock()
202 return true
203 }
204
205 // reserve records a model-minted operationId. Reuse is refused forever: a
206 // replayed id means the model is retrying a write it was told not to retry, and
207 // the earlier attempt's effect — including an unknown one — already stands.
208 func (e *Executor) reserve(id, what string) error {
209 e.mu.Lock()
210 defer e.mu.Unlock()
211 if e.closed {
212 return fmt.Errorf("the browser is closed")
213 }
214 if prev, ok := e.ops[id]; ok {
215 return fmt.Errorf("operationId %q was already used for %s; that attempt's outcome stands. Take a browser_snapshot to see the page before deciding what to do next", id, prev)
216 }
217 if len(e.ops) >= maxOperations {
218 return fmt.Errorf("this session has run %d browser writes, the ceiling for one browser", maxOperations)
219 }
220 e.ops[id] = what
221 return nil
222 }
223
224 // lookup resolves a tab the calling session owns.
225 func (e *Executor) lookup(ctx context.Context, tabID string) (*page, error) {
226 e.mu.Lock()
227 p, ok := e.pages[tabID]
228 closed := e.closed
229 e.mu.Unlock()
230 switch {
231 case closed:
232 return nil, browser.ErrNoGrant
233 case !ok:
234 return nil, fmt.Errorf("no tab %q is open for this task; call browser_tabs to list them", tabID)
235 case p.owner != browser.SessionFromContext(ctx):
236 return nil, browser.ErrNoGrant
237 case p.isDead():
238 return nil, fmt.Errorf("tab %s is gone: the page crashed or the browser closed it", tabID)
239 }
240 return p, nil
241 }
242
243 func (e *Executor) pageBySession(sessionID string) *page {
244 e.mu.Lock()
245 defer e.mu.Unlock()
246 for _, p := range e.pages {
247 if p.session == sessionID {
248 return p
249 }
250 }
251 return nil
252 }
253
254 // Tabs lists the calling session's tabs, refreshed from the browser so a page
255 // that navigated itself reports its current address.
256 func (e *Executor) Tabs(ctx context.Context) ([]browser.Tab, error) {
257 owner := browser.SessionFromContext(ctx)
258 e.mu.Lock()
259 owned := make([]*page, 0, len(e.pages))
260 for _, p := range e.pages {
261 if p.owner == owner && !p.isDead() {
262 owned = append(owned, p)
263 }
264 }
265 e.mu.Unlock()
266 tabs := make([]browser.Tab, 0, len(owned))
267 for _, p := range owned {
268 e.refreshTarget(ctx, p)
269 tabs = append(tabs, p.tab())
270 }
271 sortTabs(tabs)
272 return tabs, nil
273 }
274
275 // refreshTarget reads URL and title from the browser rather than the page, so
276 // listing tabs never runs script in a document the agent has not snapshotted.
277 func (e *Executor) refreshTarget(ctx context.Context, p *page) {
278 var info struct {
279 TargetInfo struct {
280 URL string `json:"url"`
281 Title string `json:"title"`
282 } `json:"targetInfo"`
283 }
284 if err := e.conn.call(ctx, "", "Target.getTargetInfo", map[string]any{"targetId": p.target}, &info); err != nil {
285 return
286 }
287 p.mu.Lock()
288 p.url, p.title = info.TargetInfo.URL, info.TargetInfo.Title
289 p.mu.Unlock()
290 }
291
292 // Open creates a tab for the calling session. A temporary tab gets its own
293 // browser context, which shares no cookies and is discarded with the tab.
294 func (e *Executor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) {
295 if err := e.reserve(req.OperationID, "browser_open "+req.URL); err != nil {
296 return browser.Tab{}, err
297 }
298 contextID, err := e.openContext(ctx, req.Temporary)
299 if err != nil {
300 return browser.Tab{}, err
301 }
302 params := map[string]any{"url": "about:blank"}
303 if contextID != "" {
304 params["browserContextId"] = contextID
305 }
306 var created struct {
307 TargetID string `json:"targetId"`
308 }
309 if err := e.conn.call(ctx, "", "Target.createTarget", params, &created); err != nil {
310 e.releaseContext(ctx, contextID)
311 return browser.Tab{}, fmt.Errorf("open tab: %w", err)
312 }
313 p, err := e.register(ctx, created.TargetID, contextID, req.Temporary)
314 if err != nil {
315 _ = e.conn.call(ctx, "", "Target.closeTarget", map[string]any{"targetId": created.TargetID}, nil)
316 e.releaseContext(ctx, contextID)
317 return browser.Tab{}, err
318 }
319 if err := e.navigate(ctx, p, req.URL); err != nil {
320 return p.tab(), err
321 }
322 return p.tab(), nil
323 }
324
325 // register attaches to a freshly created target and gives it a tab ID.
326 func (e *Executor) register(ctx context.Context, target, contextID string, temporary bool) (*page, error) {
327 e.mu.Lock()
328 e.nextTab++
329 id := "tab-" + strconv.Itoa(e.nextTab)
330 owner := browser.SessionFromContext(ctx)
331 e.mu.Unlock()
332
333 p, err := e.attach(ctx, id, target, contextID, owner, temporary)
334 if err != nil {
335 return nil, err
336 }
337 e.mu.Lock()
338 e.pages[id] = p
339 if contextID != "" {
340 e.contexts[contextID]++
341 }
342 e.mu.Unlock()
343 return p, nil
344 }
345
346 func (e *Executor) openContext(ctx context.Context, temporary bool) (string, error) {
347 if !temporary {
348 return "", nil
349 }
350 var out struct {
351 BrowserContextID string `json:"browserContextId"`
352 }
353 if err := e.conn.call(ctx, "", "Target.createBrowserContext", map[string]any{"disposeOnDetach": true}, &out); err != nil {
354 return "", fmt.Errorf("create temporary partition: %w", err)
355 }
356 if err := e.setDownloadBehavior(ctx, out.BrowserContextID); err != nil {
357 return "", err
358 }
359 return out.BrowserContextID, nil
360 }
361
362 // releaseContext disposes a temporary partition once its last tab is gone.
363 func (e *Executor) releaseContext(ctx context.Context, contextID string) {
364 if contextID == "" {
365 return
366 }
367 e.mu.Lock()
368 e.contexts[contextID]--
369 remaining := e.contexts[contextID]
370 if remaining <= 0 {
371 delete(e.contexts, contextID)
372 }
373 e.mu.Unlock()
374 if remaining > 0 {
375 return
376 }
377 _ = e.conn.call(ctx, "", "Target.disposeBrowserContext", map[string]any{"browserContextId": contextID}, nil)
378 }
379
380 // Navigate moves a tab and retires every ref and token bound to the document
381 // it is leaving.
382 func (e *Executor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) {
383 p, err := e.lookup(ctx, req.TabID)
384 if err != nil {
385 return browser.Tab{}, err
386 }
387 if err := e.reserve(req.OperationID, "browser_navigate "+req.Action+" on "+req.TabID); err != nil {
388 return browser.Tab{}, err
389 }
390 p.retire("")
391 switch req.Action {
392 case browser.NavigateURL:
393 err = e.navigate(ctx, p, req.URL)
394 case browser.NavigateReload:
395 err = e.withLoad(ctx, p, func(loadCtx context.Context) error {
396 return e.conn.call(loadCtx, p.session, "Page.reload", map[string]any{}, nil)
397 })
398 case browser.NavigateBack, browser.NavigateForward:
399 err = e.history(ctx, p, req.Action)
400 default:
401 return browser.Tab{}, fmt.Errorf("unknown navigate action %q", req.Action)
402 }
403 if err != nil {
404 return p.tab(), err
405 }
406 e.refreshTarget(ctx, p)
407 return p.tab(), nil
408 }
409
410 func (e *Executor) navigate(ctx context.Context, p *page, url string) error {
411 err := e.withLoad(ctx, p, func(loadCtx context.Context) error {
412 var out struct {
413 ErrorText string `json:"errorText"`
414 }
415 if err := e.conn.call(loadCtx, p.session, "Page.navigate", map[string]any{"url": url}, &out); err != nil {
416 return err
417 }
418 if out.ErrorText != "" {
419 return fmt.Errorf("navigate to %s: %s", url, out.ErrorText)
420 }
421 return nil
422 })
423 if err != nil {
424 return err
425 }
426 e.refreshTarget(ctx, p)
427 return nil
428 }
429
430 // history walks the tab's navigation history one entry in either direction.
431 func (e *Executor) history(ctx context.Context, p *page, action string) error {
432 var hist struct {
433 CurrentIndex int `json:"currentIndex"`
434 Entries []struct {
435 ID int `json:"id"`
436 } `json:"entries"`
437 }
438 if err := e.conn.call(ctx, p.session, "Page.getNavigationHistory", nil, &hist); err != nil {
439 return err
440 }
441 index := hist.CurrentIndex - 1
442 if action == browser.NavigateForward {
443 index = hist.CurrentIndex + 1
444 }
445 if index < 0 || index >= len(hist.Entries) {
446 return fmt.Errorf("the tab has no %s entry in its history", action)
447 }
448 entry := hist.Entries[index].ID
449 return e.withLoad(ctx, p, func(loadCtx context.Context) error {
450 return e.conn.call(loadCtx, p.session, "Page.navigateToHistoryEntry", map[string]any{"entryId": entry}, nil)
451 })
452 }
453
454 // withLoad runs a navigation command and waits for the page to settle. A
455 // timeout is not an error: the tools let the model snapshot a slow page.
456 func (e *Executor) withLoad(ctx context.Context, p *page, run func(context.Context) error) error {
457 loaded, cancel := e.conn.once(p.session, "Page.frameStoppedLoading")
458 defer cancel()
459 p.setLoading(true)
460 if err := run(ctx); err != nil {
461 p.setLoading(false)
462 return err
463 }
464 wait, stop := context.WithTimeout(ctx, e.navWait)
465 defer stop()
466 select {
467 case <-loaded:
468 p.setLoading(false)
469 case <-wait.Done():
470 }
471 return nil
472 }
473
474 // Close closes one tab and disposes a temporary partition with its last tab.
475 func (e *Executor) Close(ctx context.Context, req browser.CloseRequest) error {
476 p, err := e.lookup(ctx, req.TabID)
477 if err != nil {
478 return err
479 }
480 if err := e.reserve(req.OperationID, "browser_close "+req.TabID); err != nil {
481 return err
482 }
483 e.mu.Lock()
484 delete(e.pages, req.TabID)
485 e.mu.Unlock()
486 p.unsubscribe()
487 if err := e.conn.call(ctx, "", "Target.closeTarget", map[string]any{"targetId": p.target}, nil); err != nil {
488 return fmt.Errorf("close tab %s: %w", req.TabID, err)
489 }
490 e.releaseContext(ctx, p.context)
491 return nil
492 }
493
494 func artifactDir(configured string) (string, bool, error) {
495 if dir := configured; dir != "" {
496 if err := os.MkdirAll(dir, 0o700); err != nil {
497 return "", false, fmt.Errorf("cdp: artifact dir %s: %w", dir, err)
498 }
499 return dir, false, nil
500 }
501 dir, err := os.MkdirTemp("", "reasonix-browser-")
502 if err != nil {
503 return "", false, fmt.Errorf("cdp: artifact dir: %w", err)
504 }
505 return dir, true, nil
506 }
507
508 // artifactPath names a file inside the executor's own directory. A name is a
509 // leaf, never a path: nothing this package writes may be steered out of the
510 // directory the session cleans up.
511 func (e *Executor) artifactPath(kind, name string) (string, error) {
512 if name != "" && (name != filepath.Base(name) || strings.ContainsAny(name, `/\`)) {
513 return "", fmt.Errorf("%s name %q is not a plain file name", kind, name)
514 }
515 dir := filepath.Join(e.artifacts, kind)
516 if err := os.MkdirAll(dir, 0o700); err != nil {
517 return "", fmt.Errorf("prepare %s directory: %w", kind, err)
518 }
519 return filepath.Join(dir, name), nil
520 }
521
522 func cmpDuration(value, fallback time.Duration) time.Duration {
523 if value > 0 {
524 return value
525 }
526 return fallback
527 }
528
529 // sortTabs restores creation order, which the page map does not preserve.
530 func sortTabs(tabs []browser.Tab) {
531 slices.SortFunc(tabs, func(a, b browser.Tab) int { return tabIndex(a) - tabIndex(b) })
532 }
533
534 func tabIndex(t browser.Tab) int {
535 n, err := strconv.Atoi(strings.TrimPrefix(t.ID, "tab-"))
536 if err != nil {
537 return 0
538 }
539 return n
540 }
541
541 lines GO