| 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 |