diff --git a/.gitignore b/.gitignore index b7ded39c0..32d218606 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ standalone/dist/ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.cjs standalone/sidecar/recovery.cjs +standalone/sidecar/browser-host.cjs standalone/sidecar/agent-browser-host.cjs standalone/sidecar/playwright-host.cjs standalone/sidecar/burrow.cjs diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index ef0733b96..d9465c1f9 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -31,6 +31,26 @@ Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, (`BODY_COMPONENTS`), `lib/src/components/Wall.tsx` (`surfaceRenderModeFromParams`, `createContentSurface`). +## Providers + +An automated renderer belongs to one **provider**, the CLI that drives its +browser. **Must read every per-provider fact from the one registry** — render +modes, CLI, binary for `dor`, the hosts and the webview; label, device presets +and viewport hint for the GUI — never a ternary on the provider or a mode +prefix. **Never change a persisted mode string**: they are the public +`render_mode` and `dormouse.yml` `render` values. `parseRenderMode` decodes one +to its provider and presentation (`screencast` / `popout`), reading anything +else as `iframe`. + +| Provider | CLI | Render modes | Binary override / name | Install | +| --- | --- | --- | --- | --- | +| agent-browser | `dor ab` | `ab-screencast`, `ab-popout` | `DORMOUSE_AGENT_BROWSER_BIN` / `agent-browser` | `npm i -g agent-browser` | +| Playwright | `dor pw` | `pw-screencast`, `pw-popout` | `DORMOUSE_PLAYWRIGHT_BIN` / `playwright-cli` | `npm i -g @playwright/cli` | + +Source of truth: `BROWSER_PROVIDERS` and `parseRenderMode` in +`dor-lib-common/src/browser-providers.ts`; `BROWSER_PROVIDER_GUI` in +`lib/src/components/wall/browser-automation.ts`. + ## Canonical Params Invariants on the flat persisted `BrowserPanelParams`: @@ -43,9 +63,8 @@ Invariants on the flat persisted `BrowserPanelParams`: relaunches at nothing else; iframe persists only navigations initiated by Dormouse chrome. - **Must keep automation state flat** (`session`, `launchSession`, - `launchFallback`, `binaryPath`, `syncEngaged`, `key`, plus Playwright - `cwd`/`nativeIdentity`), never nested but for a `launchFallback` restore's - params. Pop-out is not a param — it derives from `renderMode` + `launchFallback`, `binaryPath`, `cwd`, `nativeIdentity`, `syncEngaged`, + `key`), never nested but for a `launchFallback` restore's params. Pop-out is not a param — it derives from `renderMode` once, at controller construction. - **Never carry a stream port in params**: the port `dor ab` reads, or the one the Playwright host's `attach` answers for `dor pw`, goes straight to the @@ -80,12 +99,13 @@ the pane the user is currently selected on moves selection to the replacement Surface lifetime owns backing resources: - **Must retain the mounted DOM when minimizing.** Agent-browser connection - parking follows [Agent-Browser Connection](#agent-browser-connection). + parking follows [Browser Connection](#browser-connection). **Must unpark a doored pane before killing it**, so its DOM dies with the Surface. - **Killing an automated pane — or swapping away from that renderer — must go through `closeBrowserSurface`**: it closes the session through the controller (or from params when none holds it) and releases every client resource. - **Work that lands after the close closes what it brought up.** + **The Surface's work still in flight never outlives the close** + ([Browser Host](#browser-host)). - **A Workspace transfer releases its browser controllers without closing their sessions**; the destination attaches to them, or opens the same named session a launch was opening. **An abandoned launch closes only a session the host @@ -109,11 +129,11 @@ Header contract: - **Must open the Display modal from this capability-first identity** (rationale): - | Display | Icon cluster | + | Display, labelled ` ` | Icon cluster | | --- | --- | - | agent-browser resizes with pane (`syncEngaged`) | wide robot + frame corners | - | agent-browser fixed size | wide robot + picture-in-picture | - | agent-browser popout | wide robot + arrow-square-out | + | resizes with pane (`syncEngaged`) | wide robot + frame corners | + | fixed size | wide robot + picture-in-picture | + | popout | wide robot + arrow-square-out | | iframe embed | frame corners only | - **Must reuse this mapping in browser Doors** (`docs/specs/layout.md` → @@ -161,11 +181,11 @@ Source of truth: `lib/src/components/wall/use-dev-server-ports.ts`, **Must scan once per context opening**, using the shared per-port URL selection in `docs/specs/dor-cli.md` → Browser Open Target Resolution. Zero/one port uses an inline row; multiple ports use a selector. Failed scans are distinct from no listeners. -**Must offer System browser, Iframe, and each automation provider’s screencast and popout for the selected port**, disabling unavailable host capabilities with a reason. Opening a browser from context always preserves the source terminal, including an untouched one. +**Must offer System browser, Iframe, and each automation provider’s screencast and popout for the selected port**, each target named by its provider's label (`agent-browser`, `agent-browser popout`, `Playwright`, `Playwright popout`) and disabled with a reason when the host does not offer it (`agent-browser unavailable on this host`). Opening a browser from context always preserves the source terminal, including an untouched one. **Must reuse targets per source, port, and provider**: each provider’s screencast and popout share a browser session and switch display modes. **A reuse is one intent, `setRenderMode(mode, { url })`, reaching the Surface's controller by id** (`requestBrowserRenderMode`), so a mode switch relaunches at the port's page rather than racing a navigation into it, even in an unmounted Door. Reattach minimized targets and recreate closed ones. System browser follows the OS opener's behavior. -**Must create automated browser Surfaces at once with the URL and no session**, their controller launching ([Agent-Browser Connection](#agent-browser-connection)); a failure is reported in context and closes the pane (`launchFallback: 'close'`). Concurrent requests for the same target are serialized. +**Must create automated browser Surfaces at once with the URL and no session**, their controller launching ([Browser Connection](#browser-connection)); a failure is reported in context and closes the pane (`launchFallback: 'close'`). Concurrent requests for the same target are serialized. Source of truth: `openContextPort` in `lib/src/components/Wall.tsx`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. @@ -177,7 +197,7 @@ Chrome icon pair across its nesting: the robot rides each provider’s screencast parent, each nested resolution row carrying only its presentation glyph. -**Must offer only the render modes the Surface's screen controller declares** (`renderModes`), never the host's global capabilities: a provider its host can launch, or the running one, which relaunches; that provider's popout where the host can also pop out; always `iframe`; for a Tool, only its declarable renders (`docs/specs/dor-tool.md` → Declaring tools). **`setRenderMode` refuses any other mode.** +**Must offer only the render modes the Surface's screen controller declares** (`renderModes`), never the host's global capabilities: both presentations of a provider its host drives (`browserProviders`), the running one's screencast even where it does not; always `iframe`; for a Tool, only its declarable renders (`docs/specs/dor-tool.md` → Declaring tools). **`setRenderMode` refuses any other mode.** The iframe option lists that the embed keeps no logins or cookies (for `https://`, see [Iframe Renderer](#iframe-renderer)). @@ -204,29 +224,26 @@ size landed**, so a resize transient is not read as an external override. Source of truth: `lib/src/components/wall/AgentBrowserScreenModal.tsx`, `offeredRenderModes` in `lib/src/components/wall/browser-automation.ts`, `lib/src/components/wall/agent-browser-surface-controller.ts` (`screenActions`, sync effects, -pop-out/pop-in, `closeLanded`), `lib/src/components/Wall.tsx` (`onSwapRenderMode`), Storybook +pop-out/pop-in), `lib/src/components/Wall.tsx` (`onSwapRenderMode`), Storybook `lib/src/stories/AgentBrowserScreenModal.stories.tsx`. Pinned by `restores a failed provider swap minimized meanwhile in place` in `lib/src/components/Wall.test.tsx`. -## Agent-Browser Renderer - -**Dormouse is a viewer/client for the user's installed `agent-browser`** — it -neither bundles nor forks Chromium behavior. `dor ab` intercepts only the three -mutually exclusive identity flags `--key`, `--session`, `--surface` and forwards -everything else verbatim to -`agent-browser --session `. -The only rewrite is inside `open` / `goto` / `navigate`, where a -Dormouse target (`surface:N`, `:port`, `host:port`) resolves to a URL first -(`docs/specs/dor-cli.md` → Browser Open Target Resolution). Flags Dormouse does -not model still pass through: `--headed` is a no-op against a *live* daemon, and -only pop-out's kill-then-relaunch changes the mode ([Pop-Out](#pop-out)). - -The binary comes from `DORMOUSE_AGENT_BROWSER_BIN` or `PATH`; `dor ab` resolves -an absolute `binaryPath` for the host, which may not share the terminal's shell -PATH; **a GUI launch passes the one a `dor ab` Surface last resolved, and -remembers the one it ran**. **Both `dor ab` and the host must spawn `agent-browser` through -`spawnAndCapture`** (`dor-lib-common`), never raw `child_process` — the Windows -`.cmd`-shim recipe applies even to that absolute path (`docs/specs/dor-cli.md` → -Spawning External Binaries). +## Automated Browser + +**Dormouse is a viewer/client for the user's installed provider CLI** — it +neither bundles nor forks a browser ([Providers](#providers)). `dor ab` / `dor +pw` intercept only the identity flags and forward everything else verbatim to +the provider's CLI against the resolved session; the only rewrite is a +navigation verb's Dormouse target (`surface:N`, `:port`, `host:port`), resolved +to a URL first (`docs/specs/dor-cli.md` → Browser Surface Addressing). Flags +Dormouse does not model still pass through. + +The binary comes from the provider's override variable or `PATH`; `dor` +resolves an absolute `binaryPath` for the host, which may not share the +terminal's shell PATH; **a GUI launch passes the one that provider's `dor` +command last resolved, and remembers the one it ran**. **Both `dor` and the host +must spawn a provider CLI through `spawnAndCapture`** (`dor-lib-common`), never +raw `child_process` — the Windows `.cmd`-shim recipe applies even to that +absolute path (`docs/specs/dor-cli.md` → Spawning External Binaries). ### Managed identity @@ -234,41 +251,52 @@ Spawning External Binaries). because it becomes part of a session name that becomes a filesystem path. **`--key`, raw `--session`, and `--surface` are mutually exclusive** — naming a browser twice is a mistake, never a precedence question. -- **A key is namespaced by the Workspace that holds the browser** — - `dormouse..`, the Workspace's *stable* id so a strip reorder - renames nothing — and `dormouse.1.` for a bare Wall, which has no - Workspace id (VS Code, the website, Pocket). The same key in two Workspaces is - therefore two browsers, which is what keeps one Surface per session (below) - once several Workspaces each run `dor ab --key default`. **Only the answering Workspace can name it**, - so `dor ab` asks the host (`surface.resolveAgentBrowser` with `key`) before it - forwards anything, and namespaces the key itself only when there is no control - endpoint at all — outside Dormouse, where `dor ab` is a pure passthrough. - **Every managed `dor ab` invocation depends on the host answering** — a - passthrough verb included — with no CLI-side fallback: a refusal (a Wall still - mounting, a webview mid-reload, the VS Code guard) fails the command with the - host's message before the binary runs, and the router answers the no-Wall - case after its bounded retry rather than leaving `dor ab` to its deadline - (`docs/specs/dor-cli.md` → "Handle Model"). A CLI-namespaced fallback would - name the wrong Workspace's browser. -- GUI-spawned sessions use `dormouse.1.gui-`, minted host-wide (the Window's - one agent-browser host, not a Workspace), which no `--key` names; they - are reachable by `dor ab --surface ` (`docs/specs/dor-cli.md` → - Agent-Browser Surface Addressing). **The host answers only for an - agent-browser-rendered Surface** — an `iframe`-rendered Surface has a browser - but no session to drive. -- **One agent-browser session maps to one Dormouse surface.** Re-running `dor ab` - for an existing session hands its port over, refreshes `binaryPath` and reuses the pane, as - does a `--surface`-addressed run — not an invariant, though: a surface killed - or render-swapped mid-command leaves the trailing request to mint a fresh pane - (rationale). - -Source of truth: `sessionForKey` in `dor-lib-common/src/agent-browser.ts`, -`resolveSession` in `dor/src/commands/agent-browser.ts`, `dor/src/commands/types.ts` -(`AgentBrowserSurfaceRequest`, `ResolveAgentBrowserSessionRequest`), `lib/src/components/Wall.tsx` / -`lib/src/components/wall/use-dor-control.ts` (`findAgentBrowserSurface`, `surface.agentBrowser`, -`surface.resolveAgentBrowser`). - -### Agent-Browser Connection +- **A key names the Surface of that provider holding it in the answering Wall**, + whose stored binding — session, cwd, executable — the command runs with; so a + Surface keeps its session however keys were named when it was made. +- **A key no Surface holds is minted `dormouse..`**, scoped by the + Workspace that will hold the browser — its *stable* id, so a strip reorder + renames nothing. **A bare Wall, which has no Workspace id (a VS Code webview, + the website, Pocket), mints a scope of its own for its life**, so two + webviews' `--key default` are two browsers. Its first commands that may bind + reserve the caller's cwd and executable for two minutes, shared by concurrent + first commands; one that succeeds without a viewer (a non-Chromium Playwright) + keeps it until a Surface binds, and binding removes it. **A key never mints a + session a Surface anywhere in the Window holds, or the provider's reservation + of another key**: it takes the first free `.2`, `.3`, … suffix (rationale). +- **Only the answering Workspace can name a key**, so `dor` asks the host + (`surface.resolveBrowser`) before it forwards anything, and names the key + itself (`dormouse.1.`) only when there is no control endpoint at all — + outside Dormouse, where `dor` is a pure passthrough. **Every managed + invocation depends on the host answering** — a passthrough verb included — + with no CLI-side fallback: a refusal (a Wall still mounting, a webview + mid-reload, the VS Code guard) fails the command with the host's message + before the binary runs, and the router answers the no-Wall case after its + bounded retry rather than leaving `dor` to its deadline (`docs/specs/dor-cli.md` + → "Handle Model"). A CLI-namespaced fallback would name the wrong Workspace's + browser. +- GUI-spawned sessions use `dormouse.1.gui-`, minted host-wide, which no + `--key` names; they are reachable by `--surface ` + (`docs/specs/dor-cli.md` → Browser Surface Addressing). **The host answers only + for a Surface its provider renders** — an `iframe`-rendered Surface has a + browser but no session to drive. +- **One browser maps to one Dormouse surface**, found by its host-reported + native identity (agent-browser: the session; Playwright: installation, + project scope and session, which a raw `--session` shares across one + project's subdirectories). A command for a browser that has a Surface hands + its port over, refreshes `binaryPath` and reuses the pane — not an invariant, + though: a surface killed or render-swapped mid-command leaves the trailing + request to mint a fresh pane (rationale). + +Source of truth: `sessionForKey` in `dor-lib-common/src/browser-providers.ts`, +`runBrowserCli` in `dor/src/commands/browser-cli.ts`, `BrowserBindingReservations` +in `lib/src/components/wall/browser-binding-reservations.ts`, +`lib/src/components/wall/use-dor-control.ts` (`findBrowserSurface`, +`ensureBrowserSurface`, `browserKeyScope`). Pinned by `resolves a browser surface +handle to its agent-browser session, and gates the rest` in +`lib/src/components/Wall.test.tsx`. + +### Browser Connection A surface-id-keyed controller registry (mirroring `terminal-lifecycle.ts`) owns one `AgentBrowserConnection` plus its screenshot loop. **The controller is @@ -289,12 +317,12 @@ place of `live`; a `dor` handover moves any phase but `launching` and | --- | --- | --- | | `idle` | No view has started it | `launching` without a session; `live` at a handed-over port; else `attaching` with its page | | `launching` | Opening `url`, in `launchSession` when set, then binding the session answered | `live` at the answered port, else `attaching`; `ended` | -| `attaching` | Asking `agentBrowserAttach` where the session streams | `live`; `ended` | +| `attaching` | Asking the host (`attach`) where the session streams | `live`; `ended` | | `live` | Streaming from its port | `parked` (hidden ≥1s, headless); `relaunching`; `ended` when a headless stream drops (rationale); `attaching` with no page when an unpark's port fails | | `parked` | Stream released; daemon up at its port | `live` at that port on unpark (rationale); `relaunching` | | `relaunching` | Headed↔headless relaunch | `live` at the host's port; else `attaching` with its page, headless | | `ended` | No browser; `error` says why | `relaunching`; a navigation rebinds, with its page | -| `disposed` | Released; `closed` if its session was | — | +| `disposed` | Released | — | - **Every daemon command must pass one gate (`driver`), open only in `live`, and after an unpark only once its stream opens** — chrome and Display modal @@ -311,9 +339,11 @@ place of `live`; a `dor` handover moves any phase but `launching` and - **A failed first launch is reported once to the Wall, which applies the Surface's `launchFallback`**: `close` the pane, `embed` (a Tool's iframe), or `{ restore }` the params a swap replaced. A param cleared on success, it - survives a restore mid-launch (rationale). **A launch into a - named session waits out a close of that session still in flight**, held open - by any work of the closed Surface still landing (rationale). + survives a restore mid-launch (rationale). **A launch into a named session is + sent only once every close of that session this webview sent has been + answered**, whatever the transport's order; **a Surface's close is sent at + once**, for its bound session or the one its launch names + ([Browser Host](#browser-host)). - **Params predating the controller's own `session` or `renderMode` write are ignored until they show it back.** - **One relaunch at a time, only of a bound browser** (`live`, `parked`, @@ -345,7 +375,7 @@ until `tabs` refreshes, even at the same URL. **provisional frame** — the first image, 250ms after any input (pointer, keys, pasted text, editing chords; continuous input extends the window), and while a capture is **overdue** — then a crisp device-resolution -`agentBrowserScreenshot` replaces it (rationale): +host `screenshot` replaces it (rationale): - **Both paths are latest-only.** - **No capture may start inside the provisional window** (rationale). @@ -356,7 +386,7 @@ capture is **overdue** — then a crisp device-resolution (rationale). Pinned by `agent-browser-screenshot-loop.test.ts`. - **Any canvas writer but the crisp loop must bump the draw generation** in its key, or the byte-identical-frame dedup drops its paint (rationale). -- **A host without `agentBrowserScreenshot` paints every changed provisional +- **A host that cannot drive the provider paints every changed provisional frame as its final image** rather than showing only the placeholder. High-rate `[ab-panel]`/`[agent-browser]` console diagnostics sit behind the @@ -372,14 +402,15 @@ Input rules: - **`windowsVirtualKeyCode` comes from a real key map, never `key.charCodeAt(0)`** (`.` is char 46 = VK_DELETE, so periods would otherwise become Delete presses). -- Local paste is replayed as per-character key input. -- **Select-all/copy/cut go through the host `agentBrowserEdit` channel on every - platform**, since those chords do not survive CDP input; every shipped host - implements it. Undo/redo is not emulated. +- Local paste is replayed as per-character key input — as `input_text` on + [Playwright](#playwright). +- **Select-all/copy/cut go through the host `edit` operation on every + platform**, since those chords do not survive CDP input. Undo/redo is not + emulated. -Tabs live in the agent-browser surface: **the in-body strip renders only at two +Tabs live in the automated browser surface: **the in-body strip renders only at two or more**, one tab getting the ordinary URL header and nothing tab-shaped. -Select/close go through `agentBrowserCommand`. The daemon gate is pinned by +Select/close go through the host `tab` operation. The daemon gate is pinned by `reaches no daemon from the header, Display modal, tabs, sync or edit chords mid-relaunch` in `lib/src/components/wall/agent-browser-surface-controller.test.ts`. @@ -393,105 +424,181 @@ shared by the stream and `tab list --json`). ### Pop-Out -`ab-popout` relaunches the same session headed, because Chrome fixes -headed/headless at daemon launch. The pane becomes a stub with Pop back in; +A popout (`ab-popout`, `pw-popout`) relaunches the same session headed, because +Chrome fixes headed/headless at launch. The pane becomes a stub with Pop back in; while the window is still opening (a launch, attach or relaunch in flight) the stub offers nothing. **State carried in v1 is only the last http(s) active URL**: other tabs, DOM state, scroll, form inputs, session storage, cookies/logins do not survive. -Host sequence: run `close`, **then terminate the daemon by its pid file and wait -for it to exit** (rationale), then reopen. **Never wait for the page to load** -(rationale): every launch — pop-out, pop-in, `agentBrowserOpen` — resolves once -the *relaunched* daemon is up, asking `stream status` only after `open` returns. -**A non-zero `open` exit with the daemon up is a page still loading, not a -failed launch**; only a launch without a published port fails, including after a -zero exit; `agentBrowserOpen` then closes its spawn. **A headed session is -tracked for shutdown before its launch**, so a window whose page never loads is -still closed. **Never query the daemon during the close/reopen gap** -(rationale) — host-side, and in the controller through its daemon gate — so -**Dormouse supplies the active-tab URL and the host trusts it**. Once `open` returns, only -a still-current relaunch best-effort closes stray `about:blank` tabs, **and only -while a real page is open**, so it never closes the sole tab (rationale). +A pop-out or pop-in is a `launch` of the bound session, under the host's +lifecycle ([Browser Host](#browser-host)). +agent-browser's relaunch runs `close`, **then terminates the daemon its state +files prove live ([agent-browser](#agent-browser)) and waits for it to exit** +(rationale), then reopens. **Never wait for the +page to load** (rationale): its launch resolves once the *relaunched* daemon is +up, asking `stream status` only after `open` returns. **A non-zero `open` exit +with the daemon up is a page still loading, not a failed launch**; only a launch +without a published port fails, including after a zero exit. **Never query the +daemon during the close/reopen gap** (rationale) — host-side, and in the +controller through its daemon gate — so **Dormouse supplies the active-tab URL +and the host trusts it**. While popped out, Dormouse keeps a stream/CDP observer for same-tab URL/header -updates and headed-window close auto-revert. **Hosts must cancel pending -relaunch sweeps, then close tracked popped-out sessions on shutdown** so -quitting orphans no headed window. +updates and headed-window close auto-revert. Source of truth: `lib/src/components/wall/agent-browser-surface-controller.ts` (pop-out state, CDP -observer, auto-revert), `lib/src/host/agent-browser-host.ts` (`popOut`, `popIn`, -`killDaemon`, `closePoppedOut`), VS Code/standalone shutdown wiring. +observer, auto-revert), `lib/src/host/agent-browser-host.ts` (`killDaemon`), +VS Code/standalone shutdown wiring. -### Agent-Browser Host Capabilities +### Browser Host -These `PlatformAdapter` methods are optional: VS Code imports the shared -implementation directly, standalone runs the bundled copy through the -sidecar/Rust adapter. +**Every browser operation rides one `PlatformAdapter.browser(request)`**: a +provider-tagged `BrowserRequest` — `{ provider, binding: { session?, cwd?, +binaryPath? } }` plus one operation — answered by a `BrowserResult`. A host lists +the providers it drives in `browserProviders`; one without them (the web demo) +offers no automated renderer. VS Code runs the shared host in the extension +host; standalone runs the bundled copy in the sidecar behind one Rust command, +plus `browser_screenshot` for raw bytes. -| Method | Contract | +| Operation | Contract | | --- | --- | -| `agentBrowserCommand` | Navigation, tab, viewport/device, `get cdp-url` and `close` commands, one shape per verb. | -| `agentBrowserScreenshot` | One device-resolution JPEG/PNG frame. VS Code structured-clones the bytes; standalone passes Rust the capture's temp-file **path** over the sidecar stdio, for Rust to read (rationale). **One capture per session and format in flight**: a request made meanwhile joins it — never one from before the session's close or relaunch — and the capture's spawn is killed past 30s. | -| `agentBrowserAttach` | The live stream port from `.pid` / `.stream` and a port probe, never spawning. Only a gone daemon, for a caller naming a page, is relaunched there (headed on request), answering `relaunched`; one up but not streaming is left alone. Concurrent attaches join. | -| `agentBrowserEdit` | select-all/copy/cut via fixed host-owned JS plus an OS clipboard write. | -| `getAgentBrowserStreamUrl` | Direct stream URL, or the VS Code relay URL. | -| `agentBrowserOpen` | Open a URL in a new GUI session, or a caller-named one (a live daemon navigates); resolves when the daemon is up, not when the page loads ([Pop-Out](#pop-out)). | -| `agentBrowserPopOut` / `agentBrowserPopIn` | Headed/headless relaunch. | - -**Every adapter must wait past agent-browser's 25s action timeout for a -command, edit or capture reply** (30s on each host), so the webview never -re-asks while the host still works. - -**Host-side validation is the security boundary: both provider hosts run only -what the shared `parseWebviewCommand` accepts, rebuilt from its parsed value, -and refuse an option- or path-shaped session name and a non-http(s) launch URL -on every entry point** (rationale). Pinned by +| `launch` | Without a session, open an http(s) `url` in a new GUI session; with one, navigate it when it is up in the mode asked for, else relaunch it headed or headless at `url`, blank when that page is not http(s). Resolves when the browser is up, not when the page loads ([Pop-Out](#pop-out)). | +| `attach` | The live stream port, found without starting a browser ([agent-browser](#agent-browser), [Playwright](#playwright)), with headedness where the provider can tell. Only a gone browser, for a caller naming a page, is relaunched there (headed on request), answering `relaunched`; one it cannot view is left alone. | +| `screenshot` | One device-resolution JPEG/PNG frame. VS Code structured-clones the bytes; standalone passes Rust a temp-file **path** over the sidecar stdio, for Rust to read and delete (rationale). **One capture per session and format in flight**: a request made meanwhile joins it — never one from before the session's close or relaunch — and the capture's spawn is killed past 30s. | +| `edit` | select-all/copy/cut via fixed host-owned JS plus an OS clipboard write. | +| `streamUrl` | The URL the webview connects to for a stream port. | +| `navigate`, `history`, `tab`, `viewport`, `device`, `close` | One fixed argv (agent-browser) or client call (Playwright) each. | +| `cdpUrl` | agent-browser only: the browser's CDP endpoint, for the popped-out URL observer. | + +**Every transport waits `BROWSER_REQUEST_TIMEOUT_MS` (40s) for any reply**, past +agent-browser's 25s action timeout, so the webview never re-asks while the host +still works. + +**The host runs one lifecycle for both providers**; a provider implements only +the primitives that differ (`BrowserProvider`: find, stop, open, probe, close, +list tabs, act, evaluate, screenshot, stream URL): + +- **Must serialize a browser's launches, relaunching attaches and closes per + native identity**, in arrival order, so two panes restoring one session + relaunch it once. **A close runs after the launch or attach already running, + closing what it brings up, and supersedes one sent before it that has not + begun**, which answers that the browser was closed (rationale). **A close + also cancels, by webview-minted `requestId`, the closing Surface's own + launches, relaunches and page-naming attaches still unanswered + (`cancels`); one arriving after it answers the same and opens nothing.** + The host keeps a cancelled id five minutes, at most 256. +- **A launch naming a session whose browser is up in the mode it asks for + must navigate it, never stop it** (a Tool re-announced); only one gone or in + the other mode is relaunched (rationale). agent-browser cannot report its + mode: one this host did not launch headed counts as headless. +- **Must answer a launch inside `BROWSER_REQUEST_TIMEOUT_MS`**: startup, + queueing included, gets 30 s from the request's arrival. A relaunch stops + what runs the session first, then resolves once the provider reports the + browser up, **never waiting for the page**. +- **A launch that gives up must let its `open` land, for up to 4 s, before + closing the session**, and close again when a later `open` lands unless a + newer launch owns the session (rationale). +- **Once `open` returns, only a still-current launch closes stray + `about:blank` tabs, last first, and only while a real page is open**, so it + never closes the sole tab (rationale). One policy for every launch of either + provider, fresh or relaunched. +- **Every provider call a browser's queue waits on must be bounded** — a + launch's by its deadline, a close by 10 s — so one hung CLI holds that + browser's later requests only that long; **shutdown waits on the queue at + most 10 s** (rationale). +- **A headed launch is tracked for shutdown before it starts**, so a window + whose page never loads is still closed; a headless relaunch or a close drops + it. **Shutdown supersedes pending launches and sweeps, then closes every + tracked headed browser**, so quitting orphans no window. + +Pinned by `lib/src/host/browser-host.test.ts`, and against the controller by +`a closed Surface and the next launch into its session` in +`lib/src/components/wall/agent-browser-surface-controller.test.ts`. + +**Host-side validation is the security boundary: `parseBrowserRequest` rebuilds +every request field by field before a provider sees it** — a known provider and +operation, an http(s) navigation or new-session URL, bounded dimensions, a tab +id and device name that cannot read as an option, a session name neither +CLI reads as an option or a path, and request ids of at most 64 `[A-Za-z0-9-]`, +32 per close (rationale). Pinned by `lib/src/host/agent-browser-host.test.ts`. **`binaryPath` crosses from the webview realm, so it is checked at the spawn** -(rationale) — the gate is `runWithBinaryFallback`, the one call every entry point -shares. Accepted: the agent-browser executable by file name — absolute, or bare -and resolved on `PATH` — plus the host's own `DORMOUSE_AGENT_BROWSER_BIN` by -exact match. **A refused path is dropped, never fatal**, so the host's own -candidates run. The webview applies the same predicate before storing or sending -one. +(rationale). Accepted: the provider's executable by file name — absolute, or +bare and resolved on `PATH` — plus the host's own override variable by exact +match. **A refused path is dropped, never fatal**, so the host's own candidates +run. The webview applies the same predicate before sending one +(`browserHandle`) or storing one. **Screenshots are captured into a private per-process directory, and it is removed** (rationale). **A tmpdir that cannot be created is answered -`{ ok: false }` and retried on the next capture, never memoized.** - -**VS Code must reach the stream through a loopback relay** — the agent-browser -stream server rejects `vscode-webview://` origins. The relay grants one -single-use, short-TTL token bound to one stream port and strips the Origin -header; standalone connects directly. - -Source of truth: `lib/src/host/agent-browser-host.ts` (`runWithBinaryFallback`), -`lib/src/host/browser-host-shared.ts` (`parseWebviewCommand`, -`isAgentBrowserSession`, `isPlaywrightSession`), -`dor-lib-common/src/agent-browser.ts` (`isAllowedAgentBrowserBinary`), -`lib/src/host/private-capture-dir.ts`, `lib/src/host/browser-stream-guard.ts`, -`vscode-ext/src/agent-browser-host.ts`, `vscode-ext/src/webview-html.ts`, -`standalone/src/tauri-adapter.ts`, `standalone/src-tauri/src/lib.rs`, +`{ ok: false }` and retried on the next capture, never memoized.** **Every +capture writes fresh files, one per caller** (rationale); a reader deletes its +own, and the browser's close or relaunch, or a capture past +`BROWSER_REQUEST_TIMEOUT_MS`, deletes one never read. + +Source of truth: `lib/src/host/browser-host.ts` (`parseBrowserRequest`, +`createBrowserHost`, `BrowserProvider`), `BROWSER_PROVIDERS` (`isSessionName`, +`isAllowedBinary`) in `dor-lib-common/src/browser-providers.ts`, `BrowserRequest` in +`lib/src/lib/platform/browser-automation.ts`, `browserHandle` in +`lib/src/components/wall/browser-automation.ts`, +`lib/src/host/private-capture-dir.ts`, `vscode-ext/src/agent-browser-host.ts`, +`vscode-ext/src/webview-html.ts`, `standalone/src/tauri-adapter.ts`, +`standalone/src-tauri/src/lib.rs` (`browser_request`, `browser_screenshot`), `standalone/sidecar/main.js`. -## Playwright Renderer - -**Must share browser chrome, Display controls, input, screenshot scheduling, parking, and pop-out behavior with agent-browser.** `pw-screencast` and `pw-popout` select providers within the existing Surface kind. Cross-provider swaps preserve only the active URL, warn when other tabs will be lost, and close the old provider. Failed launches restore the previous renderer for visible and minimized Surfaces. +### agent-browser + +What is agent-browser's alone: its per-session daemon, the state files beside +its socket, the pid kill a relaunch needs ([Pop-Out](#pop-out)), and one fixed +argv per operation. `--headed` is a no-op against a *live* daemon; only a +relaunch changes the mode. + +- **`attach` reads the live port from `.pid` / `.stream` and a + port probe, never spawning** — any CLI verb starts a daemon to answer. A + daemon up but not streaming is left alone; its native identity is its session. + **A state file written before this boot reads as absent.** +- **Never signal a pid its state files do not prove to be the session's live + daemon**: named by a pid file from this boot, alive, beside a stream port that + accepts, checked before `close` (rationale). +- **A launch runs `open` in the binding's project directory while it exists**, + so a relaunch reads the same `./agent-browser.json` the `dor ab` there did; + every other call runs in the host's. +- **Every spawn passes the `binaryPath` gate in `runWithBinaryFallback`**, the + host's `DORMOUSE_AGENT_BROWSER_BIN` being the exact-match override. +- **`dor ab` must read the stream port itself after a command that may bind** + (`stream status --json`, safe once the command made the daemon) and hand it + over; the Surface streams from it without asking the host (rationale). + **Never carry a socket directory to the host** — it kills the pid it reads + there. **Host-side operations for a session in a socket directory the host + does not share are unsupported**: they run the CLI under the host's + environment. +- **VS Code must reach the stream through a loopback relay** — the agent-browser + stream server rejects `vscode-webview://` origins. The relay grants one + single-use, short-TTL token bound to one stream port and strips the Origin + header; standalone connects directly. + +Source of truth: `createAgentBrowserProvider` and `runWithBinaryFallback` in +`lib/src/host/agent-browser-host.ts`, `isAllowedAgentBrowserBinary` and +`streamStatusArgs` in `dor-lib-common/src/browser-providers.ts`, +`streamStatus` in `dor/src/commands/agent-browser.ts`, +`vscode-ext/src/agent-browser-host.ts`. Pinned by +`lib/src/host/agent-browser-host.test.ts`. -**Must use the user's installed `@playwright/cli`, resolved from `DORMOUSE_PLAYWRIGHT_BIN` or `PATH`.** GUI launches use Chromium. Native commands retain Playwright semantics: `open` restarts, `goto` navigates; commands for unsupported engines still run, with a viewer warning. The viewer requires CLI 0.1.19’s local browser-binding endpoint; installation errors name this requirement. +### Playwright -**Must scope managed keys by provider and Dormouse workspace.** Managed bindings retain unique native session names. The first command reserves its cwd and executable for two minutes while binding the Surface; concurrent first commands share the reservation, and one that succeeds without a viewer (a non-Chromium browser) keeps it until a Surface binds. Successful bindings remove reservations; Surfaces retain cwd/executable for later commands, including relative paths. `--session` bypasses managed-key addressing and uses the caller's native project scope. `--surface` requires a Playwright renderer. GUI Connect inherits the source terminal's cwd; a swap without one uses the host cwd. +**Must use the user's installed `@playwright/cli`, resolved from `DORMOUSE_PLAYWRIGHT_BIN` or `PATH`.** GUI launches use Chromium. Native commands retain Playwright semantics: `open` restarts, `goto` navigates; commands for unsupported engines still run, with a viewer warning. The viewer requires CLI 0.1.19’s local browser-binding endpoint; installation errors name this requirement. A Playwright session lives in its CLI project scope, so a binding's cwd and executable pin every later command, relative paths included; `--session` uses the caller's own scope. GUI Connect inherits the source terminal's cwd; a swap without one uses the host cwd. -**Must discover the native session in its CLI project scope and connect using that installation's matching Playwright client.** Accept only a unique registry entry matching session, workspace and library, with a local pipe endpoint and Chromium engine. Never load modules from the registry's library path. The host derives the client from the validated CLI installation. Raw sessions reuse Surfaces by that native identity, including callers in different subdirectories of one project. **`attach` relaunches at the page only when the registry lists no browser for the session**, answering `relaunched`, never one it cannot view; `dor pw`'s binding attaches with no page. Native CLI tabs and the pane share the selected tab; the host polls tab selection and metadata every 750ms while viewed, broadcasting only changes, and the current state to each connecting viewer. Screenshots reuse tab state for up to 750ms; explicit host controls refresh immediately. A native launch updates headed shutdown ownership and the pane's display mode. **Must apply that host-reported mode in the controller before the new viewer port**, so sync never sizes a headed window, except mid-relaunch or as an echo ([Agent-Browser Connection](#agent-browser-connection)). +**Must discover the native session in its CLI project scope and connect using that installation's matching Playwright client.** Accept only a unique registry entry matching session, workspace and library, with a local pipe endpoint and Chromium engine. Never load modules from the registry's library path. The host derives the client from the validated CLI installation. **`attach` relaunches at the page only when the registry lists no browser for the session**, never one it cannot view; `dor pw`'s binding attaches with no page, and reports the browser's headedness. Native CLI tabs and the pane share the selected tab; the host polls tab selection and metadata every 750ms while viewed, broadcasting only changes, and the current state to each connecting viewer. Screenshots reuse tab state for up to 750ms; explicit host controls refresh immediately. **The controller must apply a host-reported mode before the new viewer port**, so sync never sizes a headed window, except mid-relaunch or as an echo ([Browser Connection](#browser-connection)). -**Must expose only fixed host operations.** Navigation, tabs, viewport/device, screenshots, editing and close are validated host-side (commands by the shared parser above); arbitrary CLI arguments, JavaScript and CDP methods are unavailable through the webview channel. The trusted `dor pw` process retains native passthrough. Executable hints use the same filename/exact-host-override boundary as agent-browser, with `playwright-cli` as the accepted name; `dor pw` applies it to the executable a binding returns (`docs/specs/dor-cli.md` → Playwright Surface Addressing). +Arbitrary CLI arguments, JavaScript and CDP methods are unavailable through the webview channel; the trusted `dor pw` process retains native passthrough. Executable hints use the same filename/exact-host-override boundary as agent-browser, with `playwright-cli` as the accepted name; `dor pw` applies it to the executable a binding returns (`docs/specs/dor-cli.md` → Browser Surface Addressing). -**Must serialize GUI relaunches and closes per native session.** Close the previous CLI session before polling for its replacement; return when the browser endpoint is ready, without waiting for page load. **A pop-out or pop-in whose page is not http(s) must reopen blank**, never fail; only a GUI open's URL must pass the http(s) check. **Must answer a GUI launch inside the transports' `PLAYWRIGHT_REQUEST_TIMEOUT_MS`**: startup, queueing included, gets 30 s from the request's arrival, and every CLI call it waits on is killed at that deadline. **Must bound every other CLI call to 10 s, except `open`**, which lasts as long as the page load and whose end could take its browser down; nothing waits on it past a launch's own bounds. **A launch that gives up must let its `open` land, for up to 4 s, before closing the session**, and close again when a later `open` lands unless a newer launch owns the session (rationale). Only a completed, still-current GUI launch may close startup blank tabs, and only while a real page exists. Shutdown cancels pending launches, disconnects viewers and closes tracked headed sessions. Viewer disconnect alone leaves the CLI browser alive. Concurrent input/captures share CDP attachments; disposal releases late attachments. **A screencast that fails to start must forget its page**, so the next poll releases the attachment and retries. Temporary screenshots follow the agent-browser private-directory contract. +A relaunch closes the previous CLI session, and a launch completes when the browser endpoint is ready. **Every CLI call a launch waits on is killed at its deadline; every other one at 10 s, except `open`**, which lasts as long as the page load and whose end could take its browser down; nothing waits on it past a launch's own bounds. Shutdown also disconnects viewers. Viewer disconnect alone leaves the CLI browser alive. Concurrent input/captures share CDP attachments; disposal releases late attachments. **A screencast that fails to start must forget its page**, so the next poll releases the attachment and retries. **Must authorize every stream upgrade with an own-loopback Host and a single-use, 60-second token bound to that viewer port.** Grants are capped at 1024; normal HTTP requests are refused. Input is limited to 64 KiB per message and 256 queued messages; frame backpressure drops frames above 2 MB queued. **Must send a paste as `input_text` messages of at most 8192 characters**, which the host inserts with CDP `Input.insertText`, never as a key pair per character (rationale). The same guarded stream serves all three hosts. -Source of truth: `browserPlatform` in `lib/src/components/wall/browser-automation.ts`; `followParamsHeadedness` in `lib/src/components/wall/agent-browser-surface-controller.ts`; `playwrightTextInputs` in `lib/src/lib/platform/browser-automation.ts`; `BrowserBindingReservations` in `lib/src/components/wall/browser-binding-reservations.ts`; `createPlaywrightHost` in `lib/src/host/playwright-host.ts`; `resolvePlaywrightInstall` in `lib/src/host/playwright-install.ts`; `BrowserStreamGrants` in `lib/src/host/browser-stream-guard.ts`. Pinned by `lib/src/host/playwright-host.test.ts` (opt-in real CLI via `DORMOUSE_PLAYWRIGHT_TEST_BIN`), `lib/src/host/playwright-host.lifecycle.test.ts`, `lib/src/host/browser-stream-guard.test.ts`, `lib/src/components/wall/browser-binding-reservations.test.ts`, and `AgentBrowserPanel Playwright params` in `lib/src/components/wall/AgentBrowserPanel.test.tsx`. +Source of truth: `followParamsHeadedness` in `lib/src/components/wall/agent-browser-surface-controller.ts`; `playwrightTextInputs` in `lib/src/lib/platform/browser-automation.ts`; `createPlaywrightProvider` in `lib/src/host/playwright-host.ts`; `resolvePlaywrightInstall` in `lib/src/host/playwright-install.ts`; `BrowserStreamGrants` in `lib/src/host/browser-stream-guard.ts`. Pinned by `lib/src/host/playwright-host.test.ts` (opt-in real CLI via `DORMOUSE_PLAYWRIGHT_TEST_BIN`), `lib/src/host/playwright-host.lifecycle.test.ts`, `lib/src/host/browser-stream-guard.test.ts`, and `AgentBrowserPanel Playwright params` in `lib/src/components/wall/AgentBrowserPanel.test.tsx`. ## Iframe Renderer diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 5da80696b..b7162222c 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -36,11 +36,17 @@ views without weakening that first signal. **Why a failed swap's restore reopens the previous session.** A fresh `gui-` session kept a `key` badge that `dor ab --key` no longer resolved to, and the next command opened a second pane. -## Agent-Browser Renderer +## Automated Browser -**Why one-session-one-surface is not an invariant.** `dor ab` forwards the user's command and then runs `stream status` before it asks the host for a surface, so a surface killed or render-swapped inside that window is gone by the time the trailing request arrives — and the session behind it is still live and needs somewhere to render. +**Why one-session-one-surface is not an invariant.** `dor` forwards the user's command before it asks the host for a surface, so a surface killed or render-swapped inside that window is gone by the time the trailing request arrives — and the session behind it is still live and needs somewhere to render. -## Agent-Browser Connection +**Why a bare Wall mints its own key scope.** Every VS Code webview is a bare Wall, and each named `--key default` `dormouse.1.default`: two webviews' default browsers were one browser behind two Surfaces, and killing either closed it under the other. Playwright had avoided it with random session names and a reservation; one deterministic scheme with a scope unique per bare Wall covers both, and needs no migration because a key finds its Surface's stored session first (review of the browser stack, 2026-09). + +## Managed identity + +**Why a key numbers past sessions held elsewhere.** A key's session is `dormouse..`, and the key lookup searches only the answering Wall. A pane bound to that session which left for another Workspace keeps it, so the same command here bound a second pane to the same browser, and closing either closed the other's (review of #777, 2026-09). Playwright keys had minted a fresh UUID each, so this was new there; agent-browser keys always worked this way. + +## Browser Connection **What parking is worth.** Lath leaves stay mounted, so a background window would otherwise retain every pane's ~20Hz decode and screenshot round trips. The ~1s debounce rides through transient visibility flips and StrictMode remounts without rebuilding the connection. @@ -68,8 +74,6 @@ views without weakening that first signal. **Why the launch-failure policy is a param.** Its four creators each awaited an in-memory waiter with its own liveness check, so a pane persisted mid-launch — or whose webview reloaded — then failed, showed "ended" instead of its creator's fallback (review, 2026-09). -**Why a launch waits out its session's close.** A failed swap's restore reopens the previous provider's session, whose `close` was issued at swap time; a fast failure (no Playwright installed) lands before that close does, so a reopen racing it was closed under the restored pane. A Tool re-run relaunching the `tool.` session its last run closed meets the same race. So does a Surface closed while its own launch, relaunch or relaunching attach into the session was in flight: that work closed the session when it landed, after the next launch into the name had opened it, and a close issued before the launch had bound the session registered nothing to wait on. The Tool serving loop that awaited each open never overlapped two launches; its replacement does (review of #775, 2026-09). - **Why a new `url` mid-launch is a navigation.** Tool serving stopped awaiting the launch, so an announcement landing mid-launch wrote a new `url` with the session still unbound; nothing rebound, the launch went live on the old page, and serving had already recorded the announcement, so the pane was never re-framed (review of #775, 2026-09). **Why an unpark drives nothing until its stream opens.** The unpark entered `live` at once, so sync-to-pane and a pending navigation ran before the parked port was proven. With the daemon gone while hidden (`dor ab close`, a crash) and the pane resized meanwhile, as restoring a Door does, `set viewport` started a fresh `about:blank` daemon through the CLI, and the failed stream's attach then streamed it instead of ending (review of #775, 2026-09). @@ -90,21 +94,37 @@ views without weakening that first signal. A post-open blank-tab sweep can become such a query when a later relaunch, explicit Surface close, or host shutdown starts before the earlier page finishes loading, so the host invalidates the sweep before any close can release that pending launch. -**Why the stray-`about:blank` sweep is guarded.** The close/reopen pair can leave an extra blank tab beside the navigated one. Sweeping blanks unconditionally is the obvious fix and is wrong: a session whose only tab is legitimately blank would lose it, leaving the pane with nothing to show. - -## Agent-Browser Host Capabilities +## Browser Host **Why standalone passes a screenshot path, not bytes.** The sidecar stdio is a JSON-lines pipe shared with PTY traffic; a base64 frame on it would bloat every capture and interleave with terminal output. **Why the verb alone is no boundary.** agent-browser honors launch options after the verb: `agent-browser --session x open about:blank --executable-path /nonexistent` fails with `Failed to launch Chrome at "/nonexistent"` (checked against 0.31.1, 2026-09-23). A verb-only allowlist therefore let an allowed `open`, `back` or `tab` carry `--executable-path`, `--args`, `--extension`, `--init-script`, `--profile`, `--state` or `--proxy` past the `binaryPath` gate; it also passed `close --all` (every session), `tab new `, and `screenshot `, which writes an image over any file the user can write. A session name becomes `/.pid`, whose pid a relaunch SIGTERMs, so a `/` in it reaches outside that directory. The two hosts first parsed the same argv separately and drifted within a day: agent-browser took any URL scheme and any DPR, Playwright http(s) and DPR ≤ 10 — so one parser serves both. -**Why `binaryPath` needs a gate of its own.** The argv check covers arguments, not the executable: `streamStatus`, `open` and `popOut` supply their own args and each take a `binaryPath`, so a check on `command`'s argv never sees one. And the value is persisted into the pane's params, so an unchecked one is not a one-shot — it is arbitrary local execution in the extension host or the Tauri sidecar on every subsequent launch. Dropping rather than failing degrades a stale or hostile value to "resolve it yourself". +**Why `binaryPath` needs a gate of its own.** The request validation covers arguments, not the executable: every operation takes a `binaryPath`, and the argv checks never saw one. And the value is persisted into the pane's params, so an unchecked one is not a one-shot — it is arbitrary local execution in the extension host or the Tauri sidecar on every subsequent launch. Dropping rather than failing degrades a stale or hostile value to "resolve it yourself". + +**Why the stray-`about:blank` sweep is guarded.** The close/reopen pair can leave an extra blank tab beside the navigated one, and Playwright's startup leaves one beside a fresh page. Sweeping blanks unconditionally is the obvious fix and is wrong: a session whose only tab is legitimately blank would lose it, leaving the pane with nothing to show. The sweep closes the last tab first because Playwright names tabs by index. + +**Why a failed launch waits for its `open`.** `open` runs unawaited while the host polls for the browser. A `close` issued before the CLI has registered the session closes nothing, and the `open` then brings up a Chromium window nothing tracks. Before the launch had a deadline, its worst case (close, 30 s of polling, an 8 s connect, close) ran past the webview's 40 s wait, so a slow pop-out could finish after the webview had restored the previous renderer (review of #773, 2026-09). + +**Why a close runs after the launch in flight and supersedes one queued.** A failed swap's restore reopens the previous provider's session, whose `close` was issued at swap time; a fast failure (no Playwright installed) lands before that close does, so a reopen racing it was closed under the restored pane. A Tool re-run relaunching the `tool.` session its last run closed meets the same race. So does a Surface closed while its own launch, relaunch or relaunching attach into the session was in flight: that work closed the session when it landed, after the next launch into the name had opened it, and a close issued before the launch had bound the session registered nothing to wait on. The Tool serving loop that awaited each open never overlapped two launches; its replacement does (review of #775, 2026-09). The webview first also recorded each Surface's work in flight, to close the session again when it landed; the host, which already serialized a browser's launches and closes, now runs a close after the work already running, so a close is sent at once and never repeated. A launch still queued when a close of its browser arrives was sent by a Surface closed meanwhile (a Tool swapped away and back twice): run after that close it would reopen the session nobody shows. The host sees arrival order, which VS Code's message channel keeps but standalone's Tauri commands, run on a worker pool, can swap for two requests sent within the same instant (static reading, 2026-09). So the ordering takes both layers: a named launch waits in the webview for the answer to every close of its session it sent, which no transport can reorder, and the host orders a close after the work in flight and supersedes what is queued. The reverse swap — a Surface's own launch or relaunch reaching the host after its close — would find the close already done and bring a browser up for nobody, a headless one outliving even shutdown; so the close names those requests and the host refuses them when they arrive. Closing only after the work answered, as the webview once did, covers it too, but holds every such close up to 40 s and loses it to a webview reload. + +**Why one lifecycle for both providers.** The two hosts carried the same policies twice — headed tracking, relaunch generations, the blank-tab sweep, capture joins, the editing scripts — and the copies drifted: an empty copy clobbered the clipboard in one, the capture directory lacked its `chmod` in the other, and only Playwright serialized its closes with its relaunches, so the webview kept its own record of closes in flight for agent-browser (review of the browser stack, 2026-09). **Why the screenshot path is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask, so a derivable name in the shared temp directory is readable by anything else on the machine for as long as it exists. Precedent: `standalone/sidecar/clipboard-ops.js` applies the same discipline, cleanup included, to clipboard images. -## Playwright Renderer +**Why a named launch into a live browser navigates.** A Tool re-announcing — its dev server moved — sends a named launch into the session it already has. Relaunching it stopped the daemon (`close`, then SIGTERM and SIGKILL), so an agent driving that Tool lost its tabs, page state and CDP clients on every move, and a `dor ab` command in flight failed or started a daemon mid-relaunch (review of #777, 2026-09). Only a change of mode needs a new browser. + +**Why every capture writes fresh files.** A capture file reused per browser was answered before its reader read it, so a second capture of that browser in the gap — a second pane, or the Display modal beside the loop — rewrote it under the read: a torn or empty frame. A name rotated on close without deleting its file left the user's last page on disk until shutdown (review of #777, 2026-09). A joined capture's callers each get a copy because each reader deletes what it read. + +**Why every call in a browser's queue is bounded.** A close ran inside the browser's lifecycle queue with no time limit, and agent-browser's `close` queues behind an `open` stalled on a slow page: a hung daemon held the close forever, every later attach, relaunch and pop of that session waited behind it, each webview request gave up at 40 s, and shutdown never settled (review of #777, 2026-09). A launch already bounded its stop by its deadline. + +## agent-browser + +**Why a pid needs proof before a signal.** A relaunch reads the daemon's pid from `.pid`, which nothing removes: after a reboot, or once that daemon died, the number can belong to any process, and SIGTERM then SIGKILL would reach it (review of #777, 2026-09). A file older than the boot is stale for certain; a live pid beside a stream port that accepts is the daemon as far as its state files can tell. + +**Why `dor ab` reads the stream port itself.** The host's `attach` reads only the state files, which an older agent-browser does not write and a caller's own `AGENT_BROWSER_SOCKET_DIR` keeps where the host does not look; with either, `dor ab open` opened no pane (review of #777, 2026-09). The command has just made the daemon, so asking it starts none. -**Why a failed launch waits for its `open`.** `open` runs unawaited while the host polls for the endpoint. A `close` issued before the CLI has registered the session closes nothing, and the `open` then brings up a Chromium window nothing tracks. Before the launch had a deadline, its worst case (close, 30 s of polling, an 8 s connect, close) ran past the webview's 40 s wait, so a slow pop-out could finish after the webview had restored the previous renderer (review of #773, 2026-09). +## Playwright **Why a paste is text, not keys.** agent-browser's stream takes only key and mouse events, so its paste replays a key down and up per character. Sent to the Playwright host, whose input queue closes the viewer (1008) at 256 queued messages, any paste over about 128 characters arriving as one burst truncated and dropped the pane into a 2 s reconnect (static reading, 2026-09). The 8192-character chunk keeps a message under the 64 KiB socket cap even when every character JSON-escapes to six bytes. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index c5c0cdd57..10f3f0c21 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -399,9 +399,9 @@ mode under `dor ab` belongs to the delegated `agent-browser`. positional handle** (`read` / `send` / `await` / `kill`); **a command that creates or places a Surface keeps `--surface` as an optional *reference* Surface** (`split`, `ensure`, `iframe`, browser creation). So `--surface` means -"place near this" everywhere except [`dor -ab`](#agent-browser-surface-addressing), whose whole positional space belongs to -`agent-browser`, leaving `--surface` its only room for a real target. +"place near this" everywhere except [`dor ab` and `dor +pw`](#browser-surface-addressing), whose whole positional space belongs to the +provider's CLI, leaving `--surface` its only room for a real target. The generated help snapshots own command names, syntax, flags, and defaults. Where `stricli` cannot express a shape, a command may declare narrow, @@ -422,7 +422,7 @@ The spec keeps the behavior help cannot express: | `read` | Clean, ANSI-free rendered lines; line limits count rendered lines. | | `await` | **Must name `--until quiet\|exit`; never infer it.** Timeout 1–86400 whole seconds, default 600; `alert.md` owns wake semantics. | | `kill` | **Must select exactly one confirmation mode.** Conditional text needs four non-whitespace characters and must match `read`; browser Surfaces are killable. | -| `iframe`, `agent-browser` / `ab`, `playwright` / `pw` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. | +| `iframe`, `agent-browser` / `ab`, `playwright` / `pw` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#browser-surface-addressing). The passthrough is intercepted before stricli parses it. | | `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--all --json` adds `caller_workspace_ref` / `focused_workspace_ref`** beside the `_surface_ref` pair, which under `--all` names a `surface:N` every Workspace has; the `_surface_id` halves stay unique. **`--workspaces` takes `--json` and `--window` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | | `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `app` | Standalone only ([dor app](#dor-app)). | @@ -441,7 +441,7 @@ Activity/state filters are staged (see [Future](#future)). **Every command that acts on a Surface accepts `--workspace `** — `split`, `ensure`, `read`, `send`, `await`, `kill`, `iframe`, `tool`, `open`, and the -`dor ab` passthrough, which intercepts it beside its identity flags — naming the +`dor ab` and `dor pw` passthroughs, which intercept it beside their identity flags — naming the Workspace its targets resolve in and, for a creating verb, the Workspace the new Surface joins. @@ -561,63 +561,77 @@ in `dor/src/protocol.ts`, the `surface.resolveOpen` handler in `lib/src/components/wall/use-dor-control.ts`, `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`. -## Playwright Surface Addressing - -**Must intercept `dor playwright` / `dor pw` before stricli parses native arguments.** Identity flags are the same mutually exclusive `--key`, `--session`, `--surface` set as agent-browser, plus the same `--workspace`, with native `-s` accepted for `--session`. `open` and `goto` share Browser Open Target Resolution. All other native arguments, stdout, stderr and exit status pass through; a failed viewer attachment adds a stderr warning without changing command success. Help, install, listing, and close commands never create a Surface. - -**Must resolve a managed key or Surface before invoking the CLI**, so commands run in the bound native project cwd and executable. The `surface.resolveBrowser` request and subsequent `surface.browser` binding carry an explicit provider; legacy agent-browser methods retain their contract. Identity scope and lifetime belong to `docs/specs/dor-browser.md` → Playwright Renderer. - -**Must pass the bound cwd through `spawnAndCapture`'s optional cwd argument.** Omission preserves inherited cwd for existing callers. - -**Never spawn a bound executable the host's Playwright allowlist refuses**, since the binding comes back off persisted params; run the caller's own resolution instead. The caller's `DORMOUSE_PLAYWRIGHT_BIN` is the exact-match override. **Must run the caller's own executable, with a stderr warning, when the bound one is gone**, and **must fail naming a bound cwd that no longer exists** rather than report playwright-cli missing. - -Source of truth: `runPlaywrightCli` and `resolveBinding` in `dor/src/commands/playwright.ts`; `BrowserBinding` in `dor/src/commands/types.ts`; `isAllowedPlaywrightBinary` in `dor-lib-common/src/agent-browser.ts`; `spawnAndCapture` in `dor-lib-common/src/spawn.ts`. Pinned by `dor/test/playwright.test.mjs`. - -## Agent-Browser Surface Addressing - -`dor ab --surface ` drives the browser Surface a handle names -rather than a session the caller must already know — the same handle addressing -terminal verbs use (`dor read surface:3`). - -`--surface` is the third of the mutually exclusive identity flags -(`docs/specs/dor-browser.md` → Managed identity); any two of the three fail -(`--key and --surface are mutually exclusive`). It changes *addressing* only: -every other argument is still forwarded verbatim, and the host's webview argv -shapes are untouched (`docs/specs/dor-browser.md` → Agent-Browser Host -Capabilities). - -**Resolution is host-side**, mirroring `surface.resolveOpen`: the CLI sends the -handle to `surface.resolveAgentBrowser` and forwards the session it gets back. -A managed `--key` takes the same method (with `key` in place of `surface`), -because the Workspace that will hold the browser is what namespaces a key -(`docs/specs/dor-browser.md` → Managed identity). -The handle resolves against **listed** Surfaces ([Handle Model](#handle-model)), -and the host applies two gates in order: +## Browser Surface Addressing + +**Must intercept `dor agent-browser` / `dor ab` and `dor playwright` / `dor pw` +before stricli parses provider arguments.** One runner drives both; what differs +is each provider's descriptor: + +| | `dor ab` | `dor pw` | +| --- | --- | --- | +| Session flag forwarded | `--session ` | `--session=`; native `-s` read as `--session` | +| Targets resolved in | `open`, `goto`, `navigate` | `open`, `goto` | +| Never binds a Surface | `close` | `close`, `detach`, `close-all`, `kill-all`, `delete-data`, `list`, `show`, `install`, `install-browser` | +| Informational flags | `--help`, `-h` | `--help`, `-h`, `--version`, `-v` | +| Runs in / with | the caller's cwd and executable | the binding's project cwd and pinned executable | + +- **Exactly one identity flag**: `--key` (default `default`), `--session`, or + `--surface`, plus `--workspace`; any two fail (`--key and --surface are + mutually exclusive`). Everything else is forwarded verbatim; stdout, stderr and + exit status pass through. Target resolution: [Browser Open Target + Resolution](#browser-open-target-resolution). +- **Resolution is host-side**, mirroring `surface.resolveOpen`: a `--key` or + `--surface` takes one `surface.resolveBrowser { provider, key | surface, + proposed? }` round trip before the binary runs, answered with a binding + `{ session, cwd?, binaryPath? }` (`docs/specs/dor-browser.md` → Managed + identity). `proposed` — the caller's cwd and executable — rides only a command + that may bind. `--session` and an informational command ask nothing. Outside + Dormouse a key names its unscoped session itself; a `--surface` fails. +- **After a command that may bind succeeds, `surface.browser { provider, key?, + session, cwd, binaryPath, wsPort? }` opens or reuses its Surface**: `dor ab` + first reads the stream port itself (`docs/specs/dor-browser.md` → + agent-browser), and the host reports Playwright's. **The call must wait past + `BROWSER_REQUEST_TIMEOUT_MS`**, since the host's answer can queue behind a + launch or close of the browser (rationale). A failure there adds a stderr + warning without changing the command's success. +- **The host must keep `surface.resolveAgentBrowser` and `surface.agentBrowser` + as agent-browser aliases of the pair for one release**, in their old answer + shapes, for a terminal still running the `dor` it was staged with before an + update. + +A `--surface` handle resolves against **listed** Surfaces ([Handle +Model](#handle-model)), and the host applies two gates in order: - **Browser-gated** (`docs/specs/glossary.md` → Panes and Surfaces). A target with no browser fails with the shared capability wording under [`dor list`](#current-implemented-commands). -- **Render-mode-gated.** Past that gate, a browser Surface on the `iframe` - renderer is a browser with nothing to drive, and one the other provider - renders is driven by the other CLI. **The refusal must name the command that - works**: `surface 'surface:2' is not agent-browser rendered (render_mode: - pw-screencast) — drive it with dor pw --surface surface:2`, or for an iframe - `… open its page with dor ab open `. - -Neither gate covers an agent-browser Surface whose launch has not yet named its -session ([dor-browser.md](dor-browser.md) → Agent-Browser Connection): -capability and renderer but no session, failing with +- **Render-mode-gated.** A browser Surface on the `iframe` renderer has nothing + to drive, and one the other provider renders is driven by the other CLI. **The + refusal must name the command that works**: `surface 'surface:2' is not + agent-browser rendered (render_mode: pw-screencast) — drive it with dor pw + --surface surface:2`, or for an iframe `… open its page with dor ab open `. + +Neither gate covers a Surface whose launch has not yet named its session +([dor-browser.md](dor-browser.md) → Browser Connection): it fails with `surface 'surface:2' has no agent-browser session yet`. -Like every handle target, `--surface` requires a live control endpoint. - -Source of truth: `extractSessionFlags` in `dor/src/commands/browser-cli.ts`, -`resolveSession` in `dor/src/commands/agent-browser.ts`, `resolveAgentBrowser` in -`dor/src/protocol.ts`, `ResolveAgentBrowserSessionRequest` / `Response` in -`dor/src/commands/types.ts`, `requireBrowserSurface`, -`requireAutomationSession` and the `surface.resolveAgentBrowser` handler in -`lib/src/components/wall/use-dor-control.ts`, and -`agentBrowserSessionFromParams` in `lib/src/components/wall/browser-surface.ts`. +For a project-scoped provider: **must pass the bound cwd through +`spawnAndCapture`'s optional cwd argument**; **never spawn a bound executable +the provider's allowlist refuses**, since the binding comes back off persisted +params — run the caller's own resolution instead, whose `DORMOUSE_PLAYWRIGHT_BIN` +is the exact-match override; **must run the caller's own executable, with a +stderr warning, when the bound one is gone**, and **must fail naming a bound cwd +that no longer exists** rather than report the CLI missing. + +Source of truth: `runBrowserCli`, `resolveBinding` and `extractSessionFlags` in +`dor/src/commands/browser-cli.ts`; `runAgentBrowserCli` in +`dor/src/commands/agent-browser.ts`; `runPlaywrightCli` in +`dor/src/commands/playwright.ts`; `SURFACE_CONTROL_METHODS` in +`dor/src/protocol.ts`; `ResolveBrowserRequest`, `BrowserSurfaceRequest` and +`BrowserBinding` in `dor/src/commands/types.ts`; `requireBrowserSurface` and +`requireAutomationSession` in `lib/src/components/wall/use-dor-control.ts`. +Pinned by `dor/test/cli-output.test.mjs` and `dor/test/playwright.test.mjs`. ## Agent Workflows diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index 0b4de382f..3c45f4bf4 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -105,6 +105,10 @@ releases its request; it does not prove itself before receiving the client proof **Why `dor await` prints no terminal text.** Mirroring `dor read` would drag its whole output-flag surface (`--lines`, mode selection) onto `await` and spend the one thing `await` has that composes cleanly: a stdout that is nothing but the cause, so `CAUSE=$(dor await …)` needs no parsing. `dor await … && dor read …` gets the screen back for one extra command. +## Browser Surface Addressing + +**Why the bind waits past the host's browser request timeout.** `surface.browser` answers only once the host has asked the browser where it streams, and that `attach` queues behind any launch, relaunch or close of the same browser, which can take the host's whole budget. At the client's default 5 s deadline, a command run just after a pop-out printed "could not open the Dormouse browser surface … timed out" and the pane bound anyway; agents read stderr, so the warning misled them (review of #777, 2026-09). + ## Browser Open Target Resolution **Why the port, and not the hostname, picks `http`.** A public HTTPS site lives on 443 and is written without a port, whereas a bare `host:port` is overwhelmingly a dev or infra server — loopback, a LAN container, a Tailnet peer — and those speak `http`. The hostname carries no usable signal: `box.ts.net` is a private Tailnet peer and looks like any other domain, so the CLI does not try to classify it. diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 191606ad7..410be2b6f 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -109,7 +109,7 @@ Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrust - **Must poll unbound Tools every 1.5 seconds while their command runs.** Reset settle memory and retire browser resources when the observed command-run id changes, even when the command text is unchanged; an initial observation preserves an imported live binding. (rationale) - **Must let a changed announced port or path override a committed conflict or browser**, but only after a matching scan. An unchanged announcement never undoes URL-bar navigation. (rationale) - **Must stop ordinary port scans once a browser or conflict is committed.** An unannounced additional port appearing after settle is not detected. -- **Must display the browser destination at once and leave the launch to the Surface's controller** (`docs/specs/dor-browser.md` → Agent-Browser Connection), in the session the Tool has or else its own `tool.`, falling back to the embed. Block Workspace transfer until the session binds. +- **Must display the browser destination at once and leave the launch to the Surface's controller** (`docs/specs/dor-browser.md` → Browser Connection), in the session the Tool has or else its own `tool.`, falling back to the embed. Block Workspace transfer until the session binds. - **Must retain a runtime re-key within the Tool's namespace**, following [Identity and dedupe](#identity-and-dedupe). Reserved: **Must derive a Tool's URL again on cold restore**, compatible with future `prespawn_port` and `DORMOUSE_TOOL_PORT` in scope **dor-tools**; [Persistence and hosts](#persistence-and-hosts) owns the saved projection. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 8b3427889..1bff9b7e9 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -259,7 +259,7 @@ Use glossary names instead. A left-column term retains meaning only where noted. | **reconnect** | Retired: live-PTY case → **resume**; cold start → **restore**. | | **restore** | Keeps its cold-start rehydrate meaning. Never for Door→Pane (**reattach**) or alert-manager seeding (**seed**). | | **attach** | Retired at the DOM layer (`attachTerminal`) → **mount**; user-level **reattach** (Door→Pane) keeps the `re-` prefix. | -| **session** | The durable identity of a **terminal Surface**. Never for the Activity projection (`ActivityState`, not `SessionUiState`), nor for the agent-browser daemon's lowercase `session` string (`dormouse..`) — not a Dormouse durable unit. | +| **session** | The durable identity of a **terminal Surface**. Never for the Activity projection (`ActivityState`, not `SessionUiState`), nor for a browser provider's lowercase `session` string (`dormouse..`) — not a Dormouse durable unit. | | **terminal** | Keeps its meaning for the `xterm.Terminal` instance; prose meaning "the whole thing" is **Session**. | | **surface** | Not retired. **Session** names only the terminal kind; **Surface** covers both. | | **panel / pane / leaf** | Prefer **pane** for the layout slot; **leaf** is Lath's tree node for it (1:1). "panel" survives only in React component names (`TerminalPanel`, `BrowserPanel`, `IframePanel`, `AgentBrowserPanel`). | diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 4fcdd5ece..6f00a8172 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -73,7 +73,7 @@ Every key not claimed above forwards to the embedded page while a screencast pan | Key | Action | Description | |-----|--------|-------------| | `⌘V` / `Ctrl+V` | Paste into page | Replays the *local* clipboard as per-character key events — the embedded browser's own clipboard is empty. | -| `⌘`/`Ctrl` + `a` / `c` / `x` | Select all / copy / cut | Routed through the host's `agentBrowserEdit` channel. | +| `⌘`/`Ctrl` + `a` / `c` / `x` | Select all / copy / cut | Routed through the host's `edit` operation. | | `c` / `Esc` (render-swap warning) | Continue / cancel | Confirm dropping the non-active tabs when swapping a multi-tab screencast surface to the `iframe` renderer. | ## Dialogs, menus & prompts diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 1e24915ed..13511a414 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -89,7 +89,7 @@ are *not* forwarded: |---|---|---| | `load_session` / `save_session` | Rust | the per-window session file is Rust's store (§Persistence) | | the `clipboard` readers (Windows only) | Rust (`clipboard_win.rs`) | native Win32 reads (`docs/specs/mouse-and-clipboard.md` §8.6) | -| `agent_browser_screenshot` | Rust reads the bytes from a sidecar-supplied temp-file *path* | images must never ride the JSON-lines pipe shared with PTY traffic (`docs/specs/dor-browser.md`) | +| `browser_screenshot` | Rust reads, then deletes, a sidecar-supplied temp-file *path* | images must never ride the JSON-lines pipe shared with PTY traffic (`docs/specs/dor-browser.md`) | Request/response commands block on the sidecar's reply under a timeout. `OPEN_PORT_TIMEOUT_MS` and `OPEN_PORT_TIMEOUT_PER_ID_MS` in `lib.rs` mirror the @@ -311,10 +311,10 @@ Source of truth: `standalone/sidecar/main.js`. Browser cleanup is pinned by `sta Shutdown (`sidecar:shutdown` message, stdin EOF, or SIGTERM) is **idempotent and ordered**: -1. **Must await both browser providers’ cleanup under one 1.5s deadline** - (`agentBrowser.closePoppedOut()`, and `playwright.close()` once its - lazily required host exists); `docs/specs/dor-browser.md` owns their - teardown contracts. +1. **Must await the browser host's cleanup under one 1.5s deadline** + (`browserHost.close()`, which closes Playwright's only once its lazily + required host exists); `docs/specs/dor-browser.md` owns the teardown + contract. 2. Close the dor control socket. 3. `host.dispose()`: the alerts (§Alerts), then the Burrow service, dropping the relay socket and settling every outstanding ask so nothing waits on a @@ -1351,4 +1351,4 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, Playwright, and the sidecar's alerts (`alert_command` in, stamped with the one window label the harness simulates, `main`; their events back; §Alerts) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; **must let retryable connection failures reconnect within the open timeout**; after a reconnect the adapter sends `sync`, since whatever the sidecar sent while the stream was down is gone (`resolves on the stream's open event, not on construction` in `standalone/src/browser-sidecar-host.test.ts`; `asks the sidecar to sync when the event stream reconnects` in `standalone/src/browser-sidecar-adapter.test.ts`). Fatal startup handling follows §Boot sequence. It **must mirror** standalone's Session-persistence answer (`docs/specs/transport.md` → "The governing rule"): one `PersistedWindow` per window, in `localStorage` rather than the Rust file store, and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (§Agent recovery). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. -Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. +Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/browser-providers.ts`. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 07d381ab9..d196415db 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -119,9 +119,9 @@ A `WebviewPanelSerializer` registered under the `dormouse` view type restores ed **On deactivate**, in this order (`extension.ts:deactivate()`): -1. Kick off `closePoppedOutSessions()` — started here, joined after step 2, so its - external-process time overlaps the capture. **Both browser providers' cleanup - shares one 1.5 s deadline**, as in the sidecar's shutdown, so a hung launch +1. Kick off `closeBrowserSessions()` — started here, joined after step 2, so its + external-process time overlaps the capture. **The browser host's cleanup + gets one 1.5 s deadline**, as in the sidecar's shutdown, so a hung launch cannot hold the join (`vscode-ext/test/agent-browser-host.test.ts`). **Its rejections are absorbed:** a throw out of the join would skip the flush, the refresh, and both kills. 2. `captureAgentRecoveryCommands(context, 1200)`. diff --git a/dor-lib-common/package.json b/dor-lib-common/package.json index 2515c1c22..f5098a08a 100644 --- a/dor-lib-common/package.json +++ b/dor-lib-common/package.json @@ -9,9 +9,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./agent-browser": { - "types": "./dist/agent-browser.d.ts", - "default": "./dist/agent-browser.js" + "./browser-providers": { + "types": "./dist/browser-providers.d.ts", + "default": "./dist/browser-providers.js" } }, "scripts": { diff --git a/dor-lib-common/src/agent-browser.ts b/dor-lib-common/src/agent-browser.ts deleted file mode 100644 index 41079358b..000000000 --- a/dor-lib-common/src/agent-browser.ts +++ /dev/null @@ -1,127 +0,0 @@ -// The scope a Window with one implicit Workspace answers with: a bare Wall (VS -// Code, the website, Pocket) has no Workspace id of its own, so its keys keep -// the names they have always had. Private: callers build session names through -// sessionForKey, never by hand. -const BARE_WALL_SCOPE = '1'; - -// A session name becomes a filesystem path (the daemon's socket dir), so both -// halves are held to the charset `dor ab --key` enforces CLI-side: the key -// arrives over the control socket too, from clients that are not `dor`. -const UNSAFE_SESSION_CHARS = /[^A-Za-z0-9._-]/g; - -/** Env var that overrides which agent-browser binary to run; shared so `dor ab` - * and the host key off the same name. */ -export const AGENT_BROWSER_BIN_ENV = 'DORMOUSE_AGENT_BROWSER_BIN'; - -/** Default binary name, resolved on PATH when no override/explicit path is given. */ -export const DEFAULT_AGENT_BROWSER_BIN = 'agent-browser'; - -/** The Playwright provider's counterparts: the `@playwright/cli` override env - * var and the binary name resolved on PATH. */ -export const PLAYWRIGHT_BIN_ENV = 'DORMOUSE_PLAYWRIGHT_BIN'; -export const DEFAULT_PLAYWRIGHT_BIN = 'playwright-cli'; - -/* - * What may be spawned as a browser provider's CLI (docs/specs/dor-browser.md → - * "Agent-Browser Host Capabilities"). - * - * `binaryPath` exists because the GUI host's `PATH` is often the login `PATH` - * with no nvm/volta shims, so `dor ab` / `dor pw` resolve an absolute path in - * the user's terminal and hand it along. That makes it an **exec channel** - * rather than a hint: it crosses the webview boundary, it is persisted into a - * pane's Lath params, and the host hands it back to `dor pw`, so a compromised - * webview realm and a hand-edited session file could otherwise choose what the - * extension host, the Tauri sidecar, or `dor` spawns. - * - * The rule is therefore not "trust the caller" but "the caller may only pick - * the provider's CLI": an absolute path whose file name is that CLI, the - * operator's own override variable, or the bare name resolved on `PATH`. - * Everything else is refused and the spawner falls through to its own - * candidates. - * - * Free of Node dependencies (no `node:path`) so the same predicate runs in the - * webview — which validates persisted params before they are ever sent — in - * the Node hosts, which validate again at the spawn, and in `dor`. - */ - -// The Windows PATH shims npm/vfox install alongside the POSIX executable. -// `spawnAndCapture` routes `.cmd`/`.bat` through cmd.exe (docs/specs/dor-cli.md -// → "Spawning External Binaries"), so those spellings are legitimate targets. -const PLAYWRIGHT_FILENAME_RE = /^playwright-cli(?:\.(?:cmd|bat|exe|com|ps1))?$/i; -const AGENT_BROWSER_FILENAME_RE = /^agent-browser(?:\.(?:cmd|bat|exe|com|ps1))?$/i; - -// POSIX absolute, Windows drive-absolute, or a UNC share. A relative path is -// refused outright: it would resolve against the spawner's cwd, which the -// caller does not know and must not be able to aim at. -const ABSOLUTE_RE = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/; - -/** - * True when `candidate` may be spawned as agent-browser. - * - * `configuredPath` is the spawner's own `DORMOUSE_AGENT_BROWSER_BIN`, accepted - * by exact match because the operator chose it deliberately; pass `undefined` - * in the webview, which cannot read the host's environment. - */ -export function isAllowedAgentBrowserBinary( - candidate: unknown, - configuredPath?: string, -): candidate is string { - return isAllowedBrowserBinary(candidate, configuredPath, DEFAULT_AGENT_BROWSER_BIN, AGENT_BROWSER_FILENAME_RE); -} - -/** The same executable gate for the parallel Playwright provider, whose - * override is `DORMOUSE_PLAYWRIGHT_BIN`. */ -export function isAllowedPlaywrightBinary(candidate: unknown, configuredPath?: string): candidate is string { - return isAllowedBrowserBinary(candidate, configuredPath, DEFAULT_PLAYWRIGHT_BIN, PLAYWRIGHT_FILENAME_RE); -} - -function isAllowedBrowserBinary(candidate: unknown, configuredPath: string | undefined, name: string, filename: RegExp): candidate is string { - if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > 4096) return false; - // Control characters have no place in a path and are how one argument - // becomes two on the platforms that take a command string. - if (/[\u0000-\u001f\u007f]/.test(candidate)) return false; - if (configuredPath && candidate === configuredPath) return true; - if (candidate === name) return true; - if (!ABSOLUTE_RE.test(candidate)) return false; - const segments = candidate.split(/[\\/]/); - if (segments.includes('..')) return false; - return filename.test(segments[segments.length - 1] ?? ''); -} - -/** argv for `agent-browser stream status --json` against a session — the command - * whose output {@link parseStreamPort} reads. */ -export function streamStatusArgs(session: string): string[] { - return ['--session', session, 'stream', 'status', '--json']; -} - -/** - * Managed, workspace-scoped agent-browser session name: - * `dormouse..`, and `dormouse.1.` for a Window whose one - * Wall has no Workspace id (`workspaceId` omitted). The scope is what keeps one - * `--key default` per Workspace from being one shared browser - * (`docs/specs/dor-browser.md` → Managed identity). - * - * agent-browser session names become filesystem paths (the socket dir), so `/` - * can't separate the namespace — the daemon fails to start; dots keep it - * readable. Shared by `dor ab` (--key resolution) and the lib host (GUI sessions). - */ -export function sessionForKey(key: string, workspaceId?: string): string { - const scope = workspaceId ? workspaceId.replace(UNSAFE_SESSION_CHARS, '-') : BARE_WALL_SCOPE; - return `dormouse.${scope}.${key.replace(UNSAFE_SESSION_CHARS, '-')}`; -} - -/** - * Parse the stream WebSocket port from `agent-browser stream status --json`. - * The CLI wraps payloads as either `{ port }` or `{ data: { port } }`; tolerate - * both, and return undefined for anything malformed or non-finite. Shared by - * `dor ab` (surface binding) and the lib host (panel stream recovery). - */ -export function parseStreamPort(stdout: string): number | undefined { - try { - const parsed = JSON.parse(stdout) as { port?: unknown; data?: { port?: unknown } }; - const port = parsed.data?.port ?? parsed.port; - return typeof port === 'number' && Number.isFinite(port) ? port : undefined; - } catch { - return undefined; - } -} diff --git a/dor-lib-common/src/browser-providers.ts b/dor-lib-common/src/browser-providers.ts new file mode 100644 index 000000000..cef521e73 --- /dev/null +++ b/dor-lib-common/src/browser-providers.ts @@ -0,0 +1,264 @@ +/** + * The browser-automation providers, as `dor`, the Node hosts and the webview + * all need them: each one's persisted render modes, its CLI, and what may be + * spawned as that CLI (docs/specs/dor-browser.md → "Providers"). The GUI half + * of the registry — labels, device lists — lives in lib + * (`lib/src/components/wall/browser-automation.ts`). + * + * Free of Node dependencies (no `node:path`), so the same module runs in the + * webview, the Node hosts, and `dor`. + */ + +/** A browser-automation provider: whose CLI drives a browser Surface. */ +export type BrowserAutomationProvider = 'agent-browser' | 'playwright'; + +/** Where an automated browser is shown: streamed into the pane, or as its own + * headed OS window. */ +export type BrowserPresentation = 'screencast' | 'popout'; + +// The scope a caller with no scope of its own names: `dor` outside Dormouse, +// and the host's GUI sessions. Private: callers build session names through +// sessionForKey, never by hand. +const BARE_WALL_SCOPE = '1'; + +// A session name becomes a filesystem path (the daemon's socket dir), so both +// halves are held to the charset `dor ab --key` enforces CLI-side: the key +// arrives over the control socket too, from clients that are not `dor`. +const UNSAFE_SESSION_CHARS = /[^A-Za-z0-9._-]/g; + +/** Env var that overrides which agent-browser binary to run; shared so `dor ab` + * and the host key off the same name. */ +export const AGENT_BROWSER_BIN_ENV = 'DORMOUSE_AGENT_BROWSER_BIN'; + +/** Default binary name, resolved on PATH when no override/explicit path is given. */ +export const DEFAULT_AGENT_BROWSER_BIN = 'agent-browser'; + +/** The Playwright provider's counterparts: the `@playwright/cli` override env + * var and the binary name resolved on PATH. */ +export const PLAYWRIGHT_BIN_ENV = 'DORMOUSE_PLAYWRIGHT_BIN'; +export const DEFAULT_PLAYWRIGHT_BIN = 'playwright-cli'; + +/* + * What may be spawned as a browser provider's CLI (docs/specs/dor-browser.md → + * "Browser Host"). + * + * `binaryPath` exists because the GUI host's `PATH` is often the login `PATH` + * with no nvm/volta shims, so `dor ab` / `dor pw` resolve an absolute path in + * the user's terminal and hand it along. That makes it an **exec channel** + * rather than a hint: it crosses the webview boundary, it is persisted into a + * pane's Lath params, and the host hands it back to `dor pw`, so a compromised + * webview realm and a hand-edited session file could otherwise choose what the + * extension host, the Tauri sidecar, or `dor` spawns. + * + * The rule is therefore not "trust the caller" but "the caller may only pick + * the provider's CLI": an absolute path whose file name is that CLI, the + * operator's own override variable, or the bare name resolved on `PATH`. + * Everything else is refused and the spawner falls through to its own + * candidates. + * + * The same predicate runs in the webview — which validates persisted params + * before they are ever sent — in the Node hosts, which validate again at the + * spawn, and in `dor`. + */ + +// The Windows PATH shims npm/vfox install alongside the POSIX executable. +// `spawnAndCapture` routes `.cmd`/`.bat` through cmd.exe (docs/specs/dor-cli.md +// → "Spawning External Binaries"), so those spellings are legitimate targets. +const PLAYWRIGHT_FILENAME_RE = /^playwright-cli(?:\.(?:cmd|bat|exe|com|ps1))?$/i; +const AGENT_BROWSER_FILENAME_RE = /^agent-browser(?:\.(?:cmd|bat|exe|com|ps1))?$/i; + +// POSIX absolute, Windows drive-absolute, or a UNC share. A relative path is +// refused outright: it would resolve against the spawner's cwd, which the +// caller does not know and must not be able to aim at. +const ABSOLUTE_RE = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/; + +/** + * True when `candidate` may be spawned as agent-browser. + * + * `configuredPath` is the spawner's own `DORMOUSE_AGENT_BROWSER_BIN`, accepted + * by exact match because the operator chose it deliberately; pass `undefined` + * in the webview, which cannot read the host's environment. + */ +export function isAllowedAgentBrowserBinary( + candidate: unknown, + configuredPath?: string, +): candidate is string { + return isAllowedBrowserBinary(candidate, configuredPath, DEFAULT_AGENT_BROWSER_BIN, AGENT_BROWSER_FILENAME_RE); +} + +/** The same executable gate for the parallel Playwright provider, whose + * override is `DORMOUSE_PLAYWRIGHT_BIN`. */ +export function isAllowedPlaywrightBinary(candidate: unknown, configuredPath?: string): candidate is string { + return isAllowedBrowserBinary(candidate, configuredPath, DEFAULT_PLAYWRIGHT_BIN, PLAYWRIGHT_FILENAME_RE); +} + +function isAllowedBrowserBinary(candidate: unknown, configuredPath: string | undefined, name: string, filename: RegExp): candidate is string { + if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > 4096) return false; + // Control characters have no place in a path and are how one argument + // becomes two on the platforms that take a command string. + if (/[\u0000-\u001f\u007f]/.test(candidate)) return false; + if (configuredPath && candidate === configuredPath) return true; + if (candidate === name) return true; + if (!ABSOLUTE_RE.test(candidate)) return false; + const segments = candidate.split(/[\\/]/); + if (segments.includes('..')) return false; + return filename.test(segments[segments.length - 1] ?? ''); +} + +/** What a provider's CLI command runs with: its native session, the project + * directory it runs in, and the executable. */ +export interface BrowserBinding { + session: string; + cwd?: string; + binaryPath?: string; +} + +/** An agent-browser session name. `dor ab --session` passes a user's raw name + * through, so anything goes but what agent-browser would read as an option or + * its socket directory as a path: the name lands after `--session` and in + * `/.pid`, whose pid a relaunch signals. */ +function isAgentBrowserSession(value: unknown): value is string { + return typeof value === 'string' && /^(?!-)[^/\\\x00-\x1f\x7f]{1,200}$/.test(value); +} + +/** A Playwright session name: Dormouse mints these, and the CLI takes them + * as `--session=`, so a strict charset costs nothing. */ +function isPlaywrightSession(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9._-]{1,200}$/.test(value); +} + +/** One provider's row. The provider's id is also its long `dor` command; + * `alias` is its short one and the prefix of its render modes; `modes` are the + * persisted `renderMode` strings, which are also the public `render_mode` and + * `dormouse.yml` `render` values. */ +interface BrowserProviderSpec { + /** The provider's name in user-facing text. */ + label: string; + alias: string; + modes: Readonly>; + /** The CLI's session flag, as argv. */ + sessionArgs(session: string): string[]; + /** A session name the CLI reads as neither an option nor a path. */ + isSessionName(value: unknown): value is string; + binEnv: string; + defaultBin: string; + installHint: string; + isAllowedBinary(candidate: unknown, configuredPath?: string): candidate is string; +} + +export const BROWSER_PROVIDERS = { + 'agent-browser': { + label: 'agent-browser', + alias: 'ab', + modes: { screencast: 'ab-screencast', popout: 'ab-popout' }, + sessionArgs: (session: string) => ['--session', session], + isSessionName: isAgentBrowserSession, + binEnv: AGENT_BROWSER_BIN_ENV, + defaultBin: DEFAULT_AGENT_BROWSER_BIN, + installHint: 'npm i -g agent-browser', + isAllowedBinary: isAllowedAgentBrowserBinary, + }, + playwright: { + label: 'Playwright', + alias: 'pw', + modes: { screencast: 'pw-screencast', popout: 'pw-popout' }, + sessionArgs: (session: string) => [`--session=${session}`], + isSessionName: isPlaywrightSession, + binEnv: PLAYWRIGHT_BIN_ENV, + defaultBin: DEFAULT_PLAYWRIGHT_BIN, + installHint: 'npm i -g @playwright/cli', + isAllowedBinary: isAllowedPlaywrightBinary, + }, +} as const satisfies Record; + +/** Every provider, in the order the GUI lists them. */ +export const BROWSER_PROVIDER_IDS = Object.keys(BROWSER_PROVIDERS) as BrowserAutomationProvider[]; + +/** An automated render mode: a provider's screencast or popout. */ +export type AutomatedRenderMode = (typeof BROWSER_PROVIDERS)[BrowserAutomationProvider]['modes'][BrowserPresentation]; + +/** Every render mode a browser Surface can take; `iframe` is the embed. */ +export type SurfaceRenderMode = 'iframe' | AutomatedRenderMode; + +/** A render mode decoded: its provider and presentation, or the embed. */ +export type ParsedRenderMode = + | { provider: BrowserAutomationProvider; presentation: BrowserPresentation; mode: AutomatedRenderMode } + | { provider: null; presentation: 'iframe'; mode: 'iframe' }; + +const PARSED_MODES = new Map(BROWSER_PROVIDER_IDS.flatMap((provider) => + (Object.keys(BROWSER_PROVIDERS[provider].modes) as BrowserPresentation[]).map((presentation) => { + const mode = BROWSER_PROVIDERS[provider].modes[presentation]; + return [mode, { provider, presentation, mode }] as const; + }))); +const EMBED: ParsedRenderMode = { provider: null, presentation: 'iframe', mode: 'iframe' }; + +/** Decode a render mode. Anything but an automated mode — `iframe`, an absent + * one, or an unknown persisted string — is the embed. */ +export function parseRenderMode(mode: unknown): ParsedRenderMode { + return (typeof mode === 'string' && PARSED_MODES.get(mode)) || EMBED; +} + +/** The render mode showing `provider`'s browser as `presentation`. */ +export function renderModeFor(provider: BrowserAutomationProvider, presentation: BrowserPresentation): AutomatedRenderMode { + return BROWSER_PROVIDERS[provider].modes[presentation]; +} + +/** How long the webview waits for any browser request before its transport + * gives up. The host bounds a launch to answer inside it, every transport + * waits exactly this long (VS Code's `requestResponse`, the Tauri + * `browser_request` command, the browser-dev harness), and `dor`'s bind waits + * past it, since the host's answer can wait on one. */ +export const BROWSER_REQUEST_TIMEOUT_MS = 40_000; + +/** Env var that moves agent-browser's socket directory, where its daemon's + * `.pid` and `.stream` live; the host reads its own. */ +export const AGENT_BROWSER_SOCKET_DIR_ENV = 'AGENT_BROWSER_SOCKET_DIR'; + +/** agent-browser's argv for `stream status --json`, whose output + * {@link parseStreamPort} reads. */ +export function streamStatusArgs(session: string): string[] { + return [...BROWSER_PROVIDERS['agent-browser'].sessionArgs(session), 'stream', 'status', '--json']; +} + +/** + * The stream WebSocket port `stream status --json` printed. The CLI wraps + * payloads as either `{ port }` or `{ data: { port } }`; tolerate both, and + * return undefined for anything malformed or not a TCP port. + */ +export function parseStreamPort(stdout: string): number | undefined { + try { + const parsed = JSON.parse(stdout) as { port?: unknown; data?: { port?: unknown } }; + const port = parsed.data?.port ?? parsed.port; + return isTcpPort(port) ? port : undefined; + } catch { + return undefined; + } +} + +/** Whether `value` is a TCP port number. */ +export function isTcpPort(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535; +} + +/** Whether `value` names a provider. */ +export function isBrowserProvider(value: unknown): value is BrowserAutomationProvider { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(BROWSER_PROVIDERS, value); +} + +/** + * Managed, scoped browser session name, `dormouse..`, for either + * provider: the host passes the Workspace's id, or the scope a bare Wall mints + * for itself; `dormouse.1.` is what a caller with no host — `dor` outside + * Dormouse — names. The scope is what keeps one `--key default` per Workspace + * from being one shared browser (`docs/specs/dor-browser.md` → Managed + * identity). + * + * agent-browser session names become filesystem paths (the socket dir), so `/` + * can't separate the namespace — the daemon fails to start; dots keep it + * readable. Shared by `dor` (--key outside Dormouse) and the lib host (key + * and GUI sessions). + */ +export function sessionForKey(key: string, workspaceId?: string): string { + const scope = workspaceId ? workspaceId.replace(UNSAFE_SESSION_CHARS, '-') : BARE_WALL_SCOPE; + return `dormouse.${scope}.${key.replace(UNSAFE_SESSION_CHARS, '-')}`; +} diff --git a/dor-lib-common/src/index.ts b/dor-lib-common/src/index.ts index dac70e510..509b0111a 100644 --- a/dor-lib-common/src/index.ts +++ b/dor-lib-common/src/index.ts @@ -3,17 +3,24 @@ export type { SpawnCaptureResult } from './spawn.js'; export { binaryCandidateNames, browserBinaryIsMissing, + isDirectory, isExecutableFile, resolveBinaryPath, } from './resolve-binary.js'; export { + BROWSER_PROVIDER_IDS, + BROWSER_PROVIDERS, isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary, + isBrowserProvider, parseStreamPort, sessionForKey, streamStatusArgs, AGENT_BROWSER_BIN_ENV, + BROWSER_REQUEST_TIMEOUT_MS, + AGENT_BROWSER_SOCKET_DIR_ENV, DEFAULT_AGENT_BROWSER_BIN, PLAYWRIGHT_BIN_ENV, DEFAULT_PLAYWRIGHT_BIN, -} from './agent-browser.js'; +} from './browser-providers.js'; +export type { BrowserAutomationProvider, BrowserBinding } from './browser-providers.js'; diff --git a/dor-lib-common/src/resolve-binary.ts b/dor-lib-common/src/resolve-binary.ts index 037c01bd4..12229450c 100644 --- a/dor-lib-common/src/resolve-binary.ts +++ b/dor-lib-common/src/resolve-binary.ts @@ -39,6 +39,15 @@ export function isExecutableFile(candidate: string, isWindows: boolean): boolean } } +/** Whether `candidate` is a directory that exists. */ +export function isDirectory(candidate: string): boolean { + try { + return statSync(candidate).isDirectory(); + } catch { + return false; + } +} + /** * The filenames to try for a bare `binary`, in order — `which`'s extension logic, * which the walk has to reproduce because its answer is what gets spawned. Takes diff --git a/dor-lib-common/test/agent-browser.test.mjs b/dor-lib-common/test/agent-browser.test.mjs deleted file mode 100644 index 2a71c15e5..000000000 --- a/dor-lib-common/test/agent-browser.test.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { parseStreamPort, sessionForKey } from '../dist/index.js'; - -test('sessionForKey namespaces a key under the workspace', () => { - assert.equal(sessionForKey('default'), 'dormouse.1.default'); - assert.equal(sessionForKey('gui-abc'), 'dormouse.1.gui-abc'); - assert.equal(sessionForKey('default', 'workspace-2b1c'), 'dormouse.workspace-2b1c.default'); -}); - -test('sessionForKey scrubs the key like the scope: a session name is a socket path', () => { - // The key crosses the control socket from any client, not only `dor` (which - // rejects this shape itself), so it cannot be allowed to escape the socket dir. - assert.equal(sessionForKey('../../../tmp/x', 'ws/1'), 'dormouse.ws-1...-..-..-tmp-x'); - // A valid key is unchanged. - assert.equal(sessionForKey('a.b_c-D9', 'ws'), 'dormouse.ws.a.b_c-D9'); -}); - -test('parseStreamPort reads a top-level port', () => { - assert.equal(parseStreamPort(JSON.stringify({ port: 61218 })), 61218); -}); - -test('parseStreamPort reads a nested data.port', () => { - assert.equal(parseStreamPort(JSON.stringify({ data: { port: 5173 } })), 5173); -}); - -test('parseStreamPort returns undefined for malformed or portless output', () => { - assert.equal(parseStreamPort('not json'), undefined); - assert.equal(parseStreamPort(JSON.stringify({ data: {} })), undefined); - assert.equal(parseStreamPort(JSON.stringify({ port: 'nope' })), undefined); -}); diff --git a/dor-lib-common/test/browser-providers.test.mjs b/dor-lib-common/test/browser-providers.test.mjs new file mode 100644 index 000000000..4e4b0aa6d --- /dev/null +++ b/dor-lib-common/test/browser-providers.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BROWSER_PROVIDER_IDS, BROWSER_PROVIDERS, parseRenderMode, parseStreamPort, renderModeFor, sessionForKey } from '../dist/browser-providers.js'; + +test('sessionForKey namespaces a key under the workspace', () => { + assert.equal(sessionForKey('default'), 'dormouse.1.default'); + assert.equal(sessionForKey('gui-abc'), 'dormouse.1.gui-abc'); + assert.equal(sessionForKey('default', 'workspace-2b1c'), 'dormouse.workspace-2b1c.default'); +}); + +test('sessionForKey scrubs the key like the scope: a session name is a socket path', () => { + // The key crosses the control socket from any client, not only `dor` (which + // rejects this shape itself), so it cannot be allowed to escape the socket dir. + assert.equal(sessionForKey('../../../tmp/x', 'ws/1'), 'dormouse.ws-1...-..-..-tmp-x'); + // A valid key is unchanged. + assert.equal(sessionForKey('a.b_c-D9', 'ws'), 'dormouse.ws.a.b_c-D9'); +}); + +test('parseRenderMode decodes every automated mode and renderModeFor inverts it', () => { + for (const provider of BROWSER_PROVIDER_IDS) { + for (const presentation of ['screencast', 'popout']) { + const mode = renderModeFor(provider, presentation); + assert.deepEqual(parseRenderMode(mode), { provider, presentation, mode }); + } + } + assert.equal(renderModeFor('agent-browser', 'screencast'), 'ab-screencast'); + assert.equal(renderModeFor('playwright', 'popout'), 'pw-popout'); +}); + +test('parseRenderMode reads anything else as the embed, inherited names included', () => { + for (const mode of ['iframe', undefined, null, 'constructor', 'toString', 'ab-', 7]) { + assert.deepEqual(parseRenderMode(mode), { provider: null, presentation: 'iframe', mode: 'iframe' }); + } +}); + +test('each provider refuses a session name its CLI would read as an option or a path', () => { + for (const provider of BROWSER_PROVIDER_IDS) { + const { isSessionName } = BROWSER_PROVIDERS[provider]; + assert.equal(isSessionName('dormouse.1.default'), true, provider); + for (const bad of ['../../tmp/evil', 'a/b', 'a\\b', 'a\nb', '', 7]) { + assert.equal(isSessionName(bad), false, `${provider} ${JSON.stringify(bad)}`); + } + } + // agent-browser takes its session as its own argument, so a leading dash + // would read as an option; Playwright's rides inside `--session=`. + for (const option of ['--executable-path', '-x']) assert.equal(BROWSER_PROVIDERS['agent-browser'].isSessionName(option), false); + // agent-browser takes a user's raw name; Playwright only the names Dormouse mints. + assert.equal(BROWSER_PROVIDERS['agent-browser'].isSessionName('my session'), true); + assert.equal(BROWSER_PROVIDERS.playwright.isSessionName('my session'), false); +}); + +test('parseStreamPort reads a top-level or nested port, and nothing from malformed, portless or out-of-range output', () => { + assert.equal(parseStreamPort(JSON.stringify({ port: 61218 })), 61218); + assert.equal(parseStreamPort(JSON.stringify({ data: { port: 5173 } })), 5173); + for (const stdout of ['not json', JSON.stringify({ data: {} }), JSON.stringify({ port: 'nope' }), JSON.stringify({ port: 0 }), JSON.stringify({ port: 70000 }), JSON.stringify({ port: 80.5 })]) { + assert.equal(parseStreamPort(stdout), undefined, stdout); + } +}); diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 9c955d457..cbba9290d 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -7,6 +7,7 @@ import { type ApplicationText, type StricliProcess, } from '@stricli/core'; +import { BROWSER_PROVIDER_IDS, BROWSER_PROVIDERS, isBrowserProvider, type BrowserAutomationProvider } from 'dor-lib-common'; import { agentBrowserCommand, runAgentBrowserCli } from './commands/agent-browser.js'; import { appCommand } from './commands/app.js'; import { awaitCommand } from './commands/await.js'; @@ -36,16 +37,16 @@ import type { } from './commands/types.js'; export type { - AgentBrowserExec, - AgentBrowserExecResult, - AgentBrowserSurfaceRequest, - AgentBrowserSurfaceResponse, AppRestartResponse, AwaitCause, AwaitSurfaceOutcome, AwaitSurfaceRequest, AwaitSurfaceResponse, AwaitUntil, + BrowserExec, + BrowserExecResult, + BrowserSurfaceRequest, + BrowserSurfaceResponse, CliEnv, CliOptions, CliResult, @@ -201,11 +202,8 @@ export async function runCli(rawArgv: string[], options: CliOptions = {}): Promi // provider's CLI, so they must never reach stricli's flag parser. Only a bare // `--help`/`-h` (or `dor help agent-browser`, normalized above) falls through // to stricli. - if (argv[0] === 'agent-browser' && !isPassthroughHelpInvocation(argv)) { - return runAgentBrowserCli(argv.slice(1), options); - } - if (argv[0] === 'playwright' && !isPassthroughHelpInvocation(argv)) { - return runPlaywrightCli(argv.slice(1), options); + if (isBrowserProvider(argv[0]) && !isPassthroughHelpInvocation(argv)) { + return BROWSER_CLIS[argv[0]](argv.slice(1), options); } // `dor __view-file ` is the built-in viewer's private entry // (docs/specs/dor-tool.md -> Opening local files). Its server outlives this @@ -255,11 +253,14 @@ function normalizeVersionAlias(argv: string[]): string[] { return argv; } +/** Each browser provider's passthrough, run under its id as the command. */ +const BROWSER_CLIS: Record Promise> = { + 'agent-browser': runAgentBrowserCli, + playwright: runPlaywrightCli, +}; + /** The documented short aliases of the browser passthroughs. */ -const PASSTHROUGH_ALIASES = new Map([ - ['ab', 'agent-browser'], - ['pw', 'playwright'], -]); +const PASSTHROUGH_ALIASES = new Map(BROWSER_PROVIDER_IDS.map((provider) => [BROWSER_PROVIDERS[provider].alias, provider])); /** Expand a passthrough's short alias, in any help form. */ function normalizePassthroughAlias(argv: string[]): string[] { diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index c7e52935e..084e1b0fc 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -3,36 +3,18 @@ * stricli so forwarded arguments are never parsed as dor flags. */ import { buildCommand } from '@stricli/core'; -// All external spawns go through dor-lib-common's spawnAndCapture, which owns the -// Windows recipe (cross-spawn for PATHEXT/.cmd, windowsHide, exit-vs-close). -// See docs/specs/dor-cli.md → "Spawning External Binaries". import { - browserBinaryIsMissing, - parseStreamPort, - resolveBinaryPath, - sessionForKey, - streamStatusArgs, AGENT_BROWSER_BIN_ENV, + BROWSER_PROVIDERS, DEFAULT_AGENT_BROWSER_BIN, + streamStatusArgs, + type BrowserAutomationProvider, } from 'dor-lib-common'; -import { - execBrowserProcess, - extractSessionFlags, - isMissingBinaryError, - resolveOpenTargetArgs, - type ResolvedSessionFlags, -} from './browser-cli.js'; -import type { - AgentBrowserExecResult, - CliOptions, - CliResult, - Command, - DorCommandContext, - ParseResult, -} from './types.js'; -import { errorMessage, fail, requireControlClient, stringParser, workspaceFlag, workspaceParam } from './shared.js'; +import { runBrowserCli, type BrowserCliDescriptor } from './browser-cli.js'; +import type { CliOptions, CliResult, Command, DorCommandContext } from './types.js'; +import { stringParser, workspaceFlag } from './shared.js'; -const INSTALL_HINT = 'npm i -g agent-browser'; +const INSTALL_HINT = BROWSER_PROVIDERS['agent-browser'].installHint; const INSTALL_DOCS = 'https://agent-browser.dev'; /** @@ -57,7 +39,7 @@ function missingBinaryMessage(binary: string): string { } export const agentBrowserCommand: Command = { - name: 'agent-browser', + name: 'agent-browser' satisfies BrowserAutomationProvider, helpPatches: [ { scope: 'root', @@ -133,149 +115,26 @@ Examples: }), }; -export async function runAgentBrowserCli(args: string[], options: CliOptions): Promise { - const flags = extractSessionFlags(args); - if (!flags.ok) return fail(flags.message); - const { key } = flags.value; - - // `--surface ` names the browser Surface rather than the session, so - // the session comes from the host's session↔surface registry before anything - // is forwarded. This is the only way to drive a GUI-spawned session, whose - // `gui-` name no `--key` can produce. - const resolvedSession = await resolveSession(flags.value, options); - if (!resolvedSession.ok) return fail(resolvedSession.message); - const session = resolvedSession.value; - - // `dor ab open ` accepts a Surface handle / bare :port wherever it - // takes a URL; resolve it to a URL before forwarding, because agent-browser - // only understands URLs. Every other command's args pass through untouched. - const resolvedRest = await resolveOpenTargetArgs(flags.value.rest, options, flags.value.workspace); - if (!resolvedRest.ok) return fail(resolvedRest.message); - const rest = resolvedRest.value; - - const env = options.env ?? {}; - const binary = env[AGENT_BROWSER_BIN_ENV] || DEFAULT_AGENT_BROWSER_BIN; - const exec = options.execAgentBrowser ?? execBrowserProcess; - - // Resolve the binary to an absolute path once: it proves the install present - // (below), is what we spawn (see `execTarget`), and travels to the host as - // `binaryPath` (a GUI host may not share this terminal's PATH). undefined - // means "not found on PATH" — or, for an explicit path, simply "returned - // verbatim", which browserBinaryIsMissing re-checks on disk. - const binaryPath = resolveBinaryPath(binary, env); - - // Spawn the resolved path, never the bare name: cross-spawn resolves a bare - // name through `which`, which checks `process.cwd()` *before* PATH on Windows - // (and re-emits the bare name into cmd.exe for a `.cmd` shim, which does the - // same). Since `dor` inherits the pane's cwd, a bare-name spawn would let an - // `agent-browser.cmd` sitting in a cloned repository win the race against the - // real install — repo content executing with no gate, which - // docs/specs/dor-tool.md -> Trust treats as a boundary. - // - // The `?? binary` branch is unreachable on the real path and is a type-level - // belt only: browserBinaryIsMissing already ends the call whenever binaryPath is - // undefined, and an explicit path comes back from resolveBinaryPath verbatim. - // Only a stub exec (tests), which skips that check, reaches it. - // See docs/specs/dor-cli.md -> "Spawning External Binaries". - const execTarget = binaryPath ?? binary; - - // Detect a missing install deterministically, before spawning. A failed spawn - // on Windows emits BOTH 'error' (ENOENT) and 'close' (a libuv error code); if - // 'close' wins that race the process resolves with a bogus exit code and no - // output, so `dor ab` would print nothing at all. Checking the filesystem - // ourselves sidesteps that ordering. Skipped when a stub exec is injected - // (tests), which supplies its own ENOENT behavior via the catch below. - if (options.execAgentBrowser === undefined && browserBinaryIsMissing(binary, env, binaryPath)) { - return fail(missingBinaryMessage(binary)); - } - - let result: AgentBrowserExecResult; - try { - result = await exec(execTarget, ['--session', session, ...rest]); - } catch (error) { - if (isMissingBinaryError(error)) { - return fail(missingBinaryMessage(binary)); - } - return fail(errorMessage(error)); - } - - let stderrSuffix = ''; - if (shouldManageSurface(result.exitCode, rest)) { - const client = requireControlClient(options); - // Outside a Dormouse terminal there is no control endpoint; stay a pure - // passthrough rather than nagging about the missing surface. - if (!(client instanceof Error)) { - try { - const status = await exec(execTarget, streamStatusArgs(session)); - const wsPort = parseStreamPort(status.stdout); - // Pass the absolute path resolved above so the host (which may not share - // this terminal's PATH) can run host-side tab/close commands. - await client.agentBrowserSurface({ - key, - session, - wsPort, - ...(binaryPath ? { binaryPath } : {}), - ...workspaceParam(flags.value.workspace), - }); - } catch (error) { - stderrSuffix = `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; - } - } - } - - return { - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr + stderrSuffix, - }; -} - -/** - * The agent-browser session to forward — one `surface.resolveAgentBrowser` round - * trip for the two forms only the host can name: - * - * - `--session ` is already the session; nothing is asked. - * - `--key ` is namespaced under the Workspace that will hold the browser, - * which only that Workspace knows (`docs/specs/dor-browser.md` → "Managed identity"). - * **Outside Dormouse the CLI namespaces it itself**, so `dor ab` stays a - * passthrough with no control endpoint. - * - `--surface ` is the session the host says that Surface is bound to. - * The host owns the gating: the target must have a browser, and that browser - * must be agent-browser-rendered with a session (an `iframe` renderer has no - * session to drive). - * - * The host's messages are printed verbatim; dor does not re-interpret them. - * **A host that refuses fails the command** before the binary runs — there is - * no fallback to a CLI-namespaced key, which would name the wrong Workspace's - * browser (`docs/specs/dor-browser.md` → "Managed identity"). - */ -async function resolveSession( - flags: ResolvedSessionFlags, - options: CliOptions, -): Promise> { - if (flags.session !== undefined) return { ok: true, value: flags.session }; - const client = requireControlClient(options); - if (client instanceof Error) { - return flags.key === undefined - ? { ok: false, message: client.message } - : { ok: true, value: sessionForKey(flags.key) }; - } - try { - const { session } = await client.resolveAgentBrowserSession({ - ...(flags.key === undefined ? { surface: flags.surface } : { key: flags.key }), - ...workspaceParam(flags.workspace), - }); - return { ok: true, value: session }; - } catch (error) { - return { ok: false, message: errorMessage(error) }; - } -} - -function shouldManageSurface(exitCode: number, rest: string[]): boolean { - if (exitCode !== 0 || rest.length === 0) return false; - if (rest.includes('--help') || rest.includes('-h')) return false; +// `goto` / `navigate` are documented aliases of `open`, so a Dormouse target +// resolves the same in all three. +const AGENT_BROWSER: BrowserCliDescriptor = { + provider: 'agent-browser', + sessionNoun: 'an agent-browser session name', + navigationVerbs: new Set(['open', 'goto', 'navigate']), // `close` tears the session down; the Wall notices the stream dropping and // placeholders the surface, so opening one here would be self-defeating. - const subcommand = rest.find((arg) => !arg.startsWith('-')); - return subcommand !== undefined && subcommand !== 'close'; + noBind: new Set(['close']), + informational: new Set(['--help', '-h']), + // Its sessions are global to the socket directory: a command runs where the + // caller is, with the caller's executable. + projectScoped: false, + missingBinaryMessage, + exec: (options) => options.execAgentBrowser, + // Under the caller's own socket directory and CLI, whatever state files + // that CLI writes (docs/specs/dor-browser.md → "agent-browser"). + streamStatus: streamStatusArgs, +}; + +export function runAgentBrowserCli(args: string[], options: CliOptions): Promise { + return runBrowserCli(AGENT_BROWSER, args, options); } diff --git a/dor/src/commands/browser-cli.ts b/dor/src/commands/browser-cli.ts index 3a2a253fd..da8debe81 100644 --- a/dor/src/commands/browser-cli.ts +++ b/dor/src/commands/browser-cli.ts @@ -1,7 +1,30 @@ -/** Shared CLI addressing, navigation and executable plumbing for browser providers. */ -import { spawnAndCapture } from 'dor-lib-common'; -import type { CliOptions, ParseResult, AgentBrowserExecResult } from './types.js'; -import { requireControlClient, workspaceParam } from './shared.js'; +/** + * One runner for every browser provider's `dor` passthrough (`dor ab`, + * `dor pw`): identity flags, the host's binding, open-target resolution, the + * executable, the forwarded run, and the Surface it binds — each provider a + * `BrowserCliDescriptor` of what genuinely differs (docs/specs/dor-cli.md → + * "Browser Surface Addressing"). + */ +import { + BROWSER_PROVIDERS, + browserBinaryIsMissing, + isDirectory, + parseStreamPort, + resolveBinaryPath, + sessionForKey, + spawnAndCapture, + type BrowserAutomationProvider, +} from 'dor-lib-common'; +import type { + BrowserBinding, + BrowserExec, + BrowserExecResult, + CliOptions, + CliResult, + ControlClient, + ParseResult, +} from './types.js'; +import { callerWorkingDirectory, errorMessage, fail, requireControlClient, workspaceParam } from './shared.js'; import { inferredHttpUrl, isSpecialOpenTarget, isSurfaceOpenTarget, resolveSurfaceOpenTarget } from './open-target.js'; const KEY_PATTERN = /^[A-Za-z0-9._-]+$/; @@ -26,18 +49,9 @@ export type ResolvedSessionFlags = { rest: string[]; workspace?: string } & ( | { surface: string; session?: undefined; key?: undefined } ); -/** What differs between the providers sharing the identity flags. */ -export interface SessionFlagOptions { - /** How the `--key` charset error names the provider's sessions. */ - sessionNoun?: string; - /** The provider CLI's own spellings of `--session` (Playwright's `-s`); - * reported as `--session` in errors. */ - sessionAliases?: readonly string[]; -} - export function extractSessionFlags( args: string[], - { sessionNoun = 'an agent-browser session name', sessionAliases = [] }: SessionFlagOptions = {}, + { sessionNoun, sessionAliases = [] }: Pick, ): ParseResult { const values = new Map(); const rest: string[] = []; @@ -92,10 +106,6 @@ export function extractSessionFlags( return { ok: true, value: { key: key ?? 'default', rest, ...workspace } }; } -// agent-browser's URL-navigation verbs. `goto` / `navigate` are documented -// aliases of `open`, so a Dormouse target resolves the same in all three. -const OPEN_SUBCOMMANDS = new Set(['open', 'goto', 'navigate']); - /** * Rewrite a forwarded navigation argv so the provider receives a URL: * `surface:` handles resolve via the host port scan, a bare `:port`/`host:port` @@ -113,8 +123,8 @@ const OPEN_SUBCOMMANDS = new Set(['open', 'goto', 'navigate']); export async function resolveOpenTargetArgs( rest: string[], options: CliOptions, - workspace?: string, - verbs = OPEN_SUBCOMMANDS, + workspace: string | undefined, + verbs: ReadonlySet, ): Promise> { const subcommand = rest.find((arg) => !arg.startsWith('-')); if (subcommand === undefined || !verbs.has(subcommand)) return { ok: true, value: rest }; @@ -156,7 +166,7 @@ export function isMissingBinaryError(error: unknown): boolean { // The default exec: delegate the spawn/capture/Windows handling to // spawnAndCapture, and adapt its never-throws result to this call site's // throw-on-spawn-failure contract (callers catch ENOENT via isMissingBinaryError). -export async function execBrowserProcess(binary: string, args: string[], cwd?: string): Promise { +export async function execBrowserProcess(binary: string, args: string[], cwd?: string): Promise { const result = await spawnAndCapture(binary, args, { cwd }); if (!result.ok) { const error: Error & { code?: string } = new Error(result.error.message); @@ -165,3 +175,186 @@ export async function execBrowserProcess(binary: string, args: string[], cwd?: s } return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } + +/** What differs between the providers `runBrowserCli` drives. Their binary + * override, default name and allowlist come from the provider registry. */ +export interface BrowserCliDescriptor { + provider: BrowserAutomationProvider; + /** How the `--key` charset error names the provider's sessions. */ + sessionNoun: string; + /** The provider CLI's own spellings of `--session` (Playwright's `-s`); + * reported as `--session` in errors. */ + sessionAliases?: readonly string[]; + /** The verbs whose target `resolveOpenTargetArgs` rewrites. */ + navigationVerbs: ReadonlySet; + /** Native commands that never create or resurrect a Surface. */ + noBind: ReadonlySet; + /** Flags that make any command informational: nothing binds, and nothing + * is asked of the host. */ + informational: ReadonlySet; + /** Whether a session lives in its CLI's project scope, so every command + * runs in the binding's directory with its executable. Otherwise a command + * runs where and with what the caller has. */ + projectScoped: boolean; + /** The guidance printed when the binary is not installed. */ + missingBinaryMessage(binary: string): string; + /** A test's stand-in for the spawn. */ + exec(options: CliOptions): BrowserExec | undefined; + /** The argv that reads the session's stream port once a command has + * succeeded — the browser exists then, so asking starts none — under the + * caller's own environment; the port it prints is handed over with the + * bind. Absent when the host reports the port. */ + streamStatus?(session: string): string[]; +} + +/** + * Forward `args` to the provider's CLI against the session the identity flags + * name, then open or reuse the Surface bound to it (docs/specs/dor-browser.md + * → "Managed identity"). + */ +export async function runBrowserCli(d: BrowserCliDescriptor, args: string[], options: CliOptions): Promise { + const parsed = extractSessionFlags(args, d); + if (!parsed.ok) return fail(parsed.message); + const flags = parsed.value; + + const command = flags.rest.find((arg) => !arg.startsWith('-')); + const informational = flags.rest.some((arg) => d.informational.has(arg)); + const mayBind = command !== undefined && !d.noBind.has(command) && !informational; + + const spec = BROWSER_PROVIDERS[d.provider]; + const env = options.env ?? {}; + // An empty override is unset, as for the host. + const defaultBinary = env[spec.binEnv] || spec.defaultBin; + // Resolve the binary to an absolute path once: it proves the install present + // (below), is what we spawn, and travels to the host as `binaryPath` (a GUI + // host may not share this terminal's PATH). undefined means "not found on + // PATH" — or, for an explicit path, simply "returned verbatim", which + // browserBinaryIsMissing re-checks on disk. + const defaultBinaryPath = resolveBinaryPath(defaultBinary, env); + const client = requireControlClient(options); + const callerCwd = callerWorkingDirectory(undefined, env); + + // An informational command needs no binding: nothing binds, and a + // `--surface` one names no session at all. + const resolved: ParseResult> = informational + ? { ok: true, value: { session: flags.session ?? (flags.key === undefined ? undefined : sessionForKey(flags.key)) } } + : await resolveBinding(d, flags, client, mayBind ? { cwd: callerCwd, ...(defaultBinaryPath ? { binaryPath: defaultBinaryPath } : {}) } : undefined); + if (!resolved.ok) return fail(resolved.message); + const binding = resolved.value; + // A binding comes off saved pane params, so its directory can be gone (a + // removed worktree). Say so, rather than let the spawn's ENOENT read as a + // missing CLI on every later command. + if (d.projectScoped && binding.cwd !== undefined && !isDirectory(binding.cwd)) { + return fail(`The directory this ${spec.label} browser was first opened in no longer exists: ${binding.cwd}\nClose its Dormouse pane, or use another --key.`); + } + const cwd = d.projectScoped ? binding.cwd ?? callerCwd : undefined; + + // A Dormouse target (a Surface handle, a bare :port) resolves to a URL + // before forwarding, because the provider only understands URLs. Every other + // command's args pass through untouched. + const resolvedRest = await resolveOpenTargetArgs(flags.rest, options, flags.workspace, d.navigationVerbs); + if (!resolvedRest.ok) return fail(resolvedRest.message); + const rest = resolvedRest.value; + + // A project-scoped binding's pinned executable replaces the caller's, but it + // comes back from the host (and off a hand-editable session file), so it + // passes the same allowlist the host spawns under or the caller's own runs + // instead — as it also does once the pinned one is gone (an uninstall, a Node + // version switch). A pin that is the caller's own executable changes nothing. + let pinned = d.projectScoped && binding.binaryPath !== defaultBinaryPath && spec.isAllowedBinary(binding.binaryPath, env[spec.binEnv]) + ? binding.binaryPath + : undefined; + let replacedPin = ''; + if (pinned !== undefined && browserBinaryIsMissing(pinned, env, resolveBinaryPath(pinned, env))) { + replacedPin = `Warning: this browser's ${spec.defaultBin} (${pinned}) is gone; ran ${defaultBinaryPath ?? defaultBinary} instead.\n`; + pinned = undefined; + } + const binary = pinned ?? defaultBinary; + const binaryPath = binary === defaultBinary ? defaultBinaryPath : resolveBinaryPath(binary, env); + + // Detect a missing install deterministically, before spawning. A failed + // spawn on Windows emits BOTH 'error' (ENOENT) and 'close'; if 'close' wins + // that race the process resolves with a bogus exit code and no output. + // Skipped when a stub exec is injected (tests), which supplies its own + // ENOENT behavior via the catch below. + const stub = d.exec(options); + if (stub === undefined && browserBinaryIsMissing(binary, env, binaryPath)) return fail(d.missingBinaryMessage(binary)); + const exec = stub ?? execBrowserProcess; + // Spawn the resolved path, never the bare name: cross-spawn resolves a bare + // name through `which`, which checks `process.cwd()` *before* PATH on + // Windows, so an `agent-browser.cmd` sitting in a cloned repository would + // win the race against the real install (docs/specs/dor-cli.md -> + // "Spawning External Binaries"). `?? binary` is reached only by a stub. + const run = (argv: string[]) => (cwd === undefined ? exec(binaryPath ?? binary, argv) : exec(binaryPath ?? binary, argv, cwd)); + + let result: BrowserExecResult; + try { + result = await run([...(binding.session === undefined ? [] : spec.sessionArgs(binding.session)), ...rest]); + } catch (error) { + return isMissingBinaryError(error) ? fail(d.missingBinaryMessage(binary)) : fail(errorMessage(error)); + } + result.stderr = replacedPin + result.stderr; + + // Outside a Dormouse terminal there is no control endpoint; stay a pure + // passthrough rather than nagging about the missing surface. + if (result.exitCode === 0 && mayBind && binding.session !== undefined && !(client instanceof Error)) { + try { + const statusArgs = d.streamStatus?.(binding.session); + const wsPort = statusArgs === undefined ? undefined : parseStreamPort((await run(statusArgs)).stdout); + await client.browserSurface({ + provider: d.provider, + key: flags.key, + session: binding.session, + cwd: cwd ?? callerCwd, + ...(binaryPath ? { binaryPath } : {}), + ...(wsPort === undefined ? {} : { wsPort }), + ...workspaceParam(flags.workspace), + }); + } catch (error) { + result.stderr += `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; + } + } + return result; +} + +/** + * The binding to run with — one `surface.resolveBrowser` round trip for the two + * forms only the host can name: + * + * - `--session ` is already the session; nothing is asked. + * - `--key ` is namespaced under the Workspace that will hold the + * browser, which only that Workspace knows: the host answers with the + * binding of the Surface holding the key, or mints one, pinning `proposed` + * for a command that may bind. **Outside Dormouse the CLI namespaces it + * itself**, so the command stays a passthrough with no control endpoint. + * - `--surface ` is that Surface's binding. The host owns the gating: + * the target must have a browser of this provider with a session. + * + * The host's messages are printed verbatim. **A host that refuses fails the + * command** before the binary runs — there is no fallback to a CLI-namespaced + * key, which would name the wrong Workspace's browser + * (`docs/specs/dor-browser.md` → "Managed identity"). + */ +async function resolveBinding( + d: BrowserCliDescriptor, + flags: ResolvedSessionFlags, + client: ControlClient | Error, + proposed: Omit | undefined, +): Promise> { + if (flags.session !== undefined) return { ok: true, value: { session: flags.session } }; + if (client instanceof Error) { + return flags.key === undefined + ? { ok: false, message: client.message } + : { ok: true, value: { session: sessionForKey(flags.key) } }; + } + try { + const { binding } = await client.resolveBrowser({ + provider: d.provider, + ...(flags.key === undefined ? { surface: flags.surface } : { key: flags.key, ...(proposed ? { proposed } : {}) }), + ...workspaceParam(flags.workspace), + }); + return { ok: true, value: binding }; + } catch (error) { + return { ok: false, message: errorMessage(error) }; + } +} diff --git a/dor/src/commands/playwright.ts b/dor/src/commands/playwright.ts index 64fc49fd4..672272821 100644 --- a/dor/src/commands/playwright.ts +++ b/dor/src/commands/playwright.ts @@ -1,42 +1,12 @@ /** Playwright's native CLI with Dormouse addressing and a shared browser pane. */ -import { statSync } from 'node:fs'; import { buildCommand } from '@stricli/core'; -import { - browserBinaryIsMissing, - isAllowedPlaywrightBinary, - resolveBinaryPath, - sessionForKey, - DEFAULT_PLAYWRIGHT_BIN, - PLAYWRIGHT_BIN_ENV, -} from 'dor-lib-common'; -import { - execBrowserProcess, - extractSessionFlags, - isMissingBinaryError, - resolveOpenTargetArgs, - type ResolvedSessionFlags, -} from './browser-cli.js'; -import type { - BrowserBinding, - CliOptions, - CliResult, - Command, - ControlClient, - DorCommandContext, - ParseResult, -} from './types.js'; -import { - callerWorkingDirectory, - errorMessage, - fail, - requireControlClient, - stringParser, - workspaceFlag, - workspaceParam, -} from './shared.js'; +import { BROWSER_PROVIDERS, PLAYWRIGHT_BIN_ENV, type BrowserAutomationProvider } from 'dor-lib-common'; +import { runBrowserCli, type BrowserCliDescriptor } from './browser-cli.js'; +import type { CliOptions, CliResult, Command, DorCommandContext } from './types.js'; +import { stringParser, workspaceFlag } from './shared.js'; export const playwrightCommand: Command = { - name: 'playwright', + name: 'playwright' satisfies BrowserAutomationProvider, command: buildCommand<{ key?: string; session?: string; surface?: string; workspace?: string }, string[], DorCommandContext>({ docs: { brief: 'Drive a browser surface via your Playwright CLI install (alias: dor pw).', @@ -76,140 +46,23 @@ Examples: }), }; -// Native commands, and flags on any command, that never create or resurrect a -// Surface (docs/specs/dor-cli.md → "Playwright Surface Addressing"). -const NO_BIND = new Set(['close', 'detach', 'close-all', 'kill-all', 'delete-data', 'list', 'show', 'install', 'install-browser']); -const INFORMATIONAL_FLAGS = new Set(['--help', '-h', '--version', '-v']); - -// Playwright's URL-navigation verbs: `open` restarts the browser, `goto` -// navigates the current tab. -const NAVIGATION_VERBS = new Set(['open', 'goto']); - -function missing(binary: string): CliResult { - return fail(`playwright-cli is not installed (looked for '${binary}').\n\nInstall it with: npm i -g @playwright/cli\nOr set ${PLAYWRIGHT_BIN_ENV} to its full path.`); -} - -function isDirectory(path: string): boolean { - try { - return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false; - } catch { - return false; - } -} - -export async function runPlaywrightCli(args: string[], options: CliOptions): Promise { - const parsed = extractSessionFlags(args, { sessionNoun: 'a Playwright session name', sessionAliases: ['-s'] }); - if (!parsed.ok) return fail(parsed.message); - const flags = parsed.value; - - const nativeCommand = flags.rest.find((arg) => !arg.startsWith('-')); - const informational = flags.rest.some((arg) => INFORMATIONAL_FLAGS.has(arg)); - const mayBind = nativeCommand !== undefined && !NO_BIND.has(nativeCommand) && !informational; - - const env = options.env ?? {}; - // An empty override is unset, as for `dor ab` and the host. - const defaultBinary = env[PLAYWRIGHT_BIN_ENV] || DEFAULT_PLAYWRIGHT_BIN; - const defaultBinaryPath = resolveBinaryPath(defaultBinary, env); - const client = requireControlClient(options); - - // The caller's own binding, unless the host holds one for the key or Surface. - // A `--surface` names no session until the host answers. - let binding: BrowserBinding = { - session: flags.session ?? (flags.key === undefined ? '' : sessionForKey(flags.key)), - cwd: callerWorkingDirectory(undefined, env), - }; - if (flags.session === undefined && !informational) { - const proposed = mayBind ? { cwd: binding.cwd, binaryPath: defaultBinaryPath } : undefined; - const resolved = await resolveBinding(flags, client, proposed); - if (!resolved.ok) return fail(resolved.message); - if (resolved.value) { - binding = resolved.value; - // A binding comes off saved pane params, so its directory can be gone (a - // removed worktree). Say so, rather than let the spawn's ENOENT read as a - // missing playwright-cli on every later command. - if (binding.cwd !== undefined && !isDirectory(binding.cwd)) { - return fail(`The directory this Playwright browser was first opened in no longer exists: ${binding.cwd}\nClose its Dormouse pane, or use another --key.`); - } - } - } - - const resolvedRest = await resolveOpenTargetArgs(flags.rest, options, flags.workspace, NAVIGATION_VERBS); - if (!resolvedRest.ok) return fail(resolvedRest.message); - const rest = resolvedRest.value; - - // A binding's pinned executable replaces the caller's, but it comes back from - // the host (and off a hand-editable session file), so it passes the same - // allowlist the host spawns under or the caller's own runs instead — as it - // also does once the pinned one is gone (an uninstall, a Node version - // switch). Walk PATH only for one not resolved above. - let pinned = isAllowedPlaywrightBinary(binding.binaryPath, env[PLAYWRIGHT_BIN_ENV]) ? binding.binaryPath : undefined; - let replacedPin = ''; - if (pinned !== undefined && browserBinaryIsMissing(pinned, env, resolveBinaryPath(pinned, env))) { - replacedPin = `Warning: this browser's playwright-cli (${pinned}) is gone; ran ${defaultBinaryPath ?? defaultBinary} instead.\n`; - pinned = undefined; - } - const binary = pinned ?? defaultBinary; - const binaryPath = binary === defaultBinary ? defaultBinaryPath : resolveBinaryPath(binary, env); - if (options.execPlaywright === undefined && browserBinaryIsMissing(binary, env, binaryPath)) { - return missing(binary); - } - const exec = options.execPlaywright ?? execBrowserProcess; - try { - // Spawn the resolved path, never the bare name (docs/specs/dor-cli.md -> - // "Spawning External Binaries"): the spawn's cwd is the project directory. - const result = await exec(binaryPath ?? binary, [`--session=${binding.session}`, ...rest], binding.cwd); - result.stderr = replacedPin + result.stderr; - if (result.exitCode === 0 && mayBind && !(client instanceof Error)) { - try { - await client.browserSurface({ - provider: 'playwright', - key: flags.key, - session: binding.session, - cwd: binding.cwd, - ...(binaryPath ? { binaryPath } : {}), - ...workspaceParam(flags.workspace), - }); - } catch (error) { - result.stderr += `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; - } - } - return result; - } catch (error) { - return isMissingBinaryError(error) ? missing(binary) : fail(errorMessage(error)); - } -} +const PLAYWRIGHT: BrowserCliDescriptor = { + provider: 'playwright', + sessionNoun: 'a Playwright session name', + sessionAliases: ['-s'], + // `open` restarts the browser, `goto` navigates the current tab. + navigationVerbs: new Set(['open', 'goto']), + // Native commands that never create or resurrect a Surface + // (docs/specs/dor-cli.md → "Browser Surface Addressing"). + noBind: new Set(['close', 'detach', 'close-all', 'kill-all', 'delete-data', 'list', 'show', 'install', 'install-browser']), + informational: new Set(['--help', '-h', '--version', '-v']), + // A Playwright session lives in its CLI project scope: every command runs in + // the binding's directory, with the executable that first opened it. + projectScoped: true, + missingBinaryMessage: (binary) => `playwright-cli is not installed (looked for '${binary}').\n\nInstall it with: ${BROWSER_PROVIDERS.playwright.installHint}\nOr set ${PLAYWRIGHT_BIN_ENV} to its full path.`, + exec: (options) => options.execPlaywright, +}; -/** - * The binding the host holds for a `--key` or `--surface` — one - * `surface.resolveBrowser` round trip (docs/specs/dor-browser.md → Playwright - * Renderer). `proposed` offers the caller's cwd and executable when the command - * may bind a key's first launch; the host mints the session. - * - * - A key with no binding yet answers null, as does any key outside Dormouse: - * the caller namespaces it itself, so `dor pw` stays a passthrough with no - * control endpoint. - * - A Surface needs a live control endpoint and a bound session; either - * missing fails the command before the binary runs. - */ -async function resolveBinding( - flags: ResolvedSessionFlags, - client: ControlClient | Error, - proposed: Omit | undefined, -): Promise> { - if (client instanceof Error) { - return flags.surface === undefined ? { ok: true, value: null } : { ok: false, message: client.message }; - } - try { - const { binding } = await client.resolveBrowser({ - provider: 'playwright', - ...(flags.surface === undefined ? { key: flags.key, ...(proposed ? { proposed } : {}) } : { surface: flags.surface }), - ...workspaceParam(flags.workspace), - }); - if (!binding && flags.surface !== undefined) { - return { ok: false, message: 'The surface has no Playwright session yet.' }; - } - return { ok: true, value: binding ?? null }; - } catch (error) { - return { ok: false, message: errorMessage(error) }; - } +export function runPlaywrightCli(args: string[], options: CliOptions): Promise { + return runBrowserCli(PLAYWRIGHT, args, options); } diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ec5a91ea4..c3c0ea7a5 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -3,12 +3,14 @@ import type { CommandContext, StricliProcess, } from '@stricli/core'; +import type { BrowserAutomationProvider, BrowserBinding, SurfaceRenderMode } from 'dor-lib-common/browser-providers'; + +export type { BrowserAutomationProvider, BrowserBinding, SurfaceRenderMode }; export type IdFormat = 'refs' | 'ids' | 'both'; export type SplitDirection = 'left' | 'right' | 'up' | 'down' | 'auto'; export type ResolvedSplitDirection = 'left' | 'right' | 'up' | 'down'; export type SurfaceKind = 'terminal' | 'browser' | 'tool'; -export type SurfaceRenderMode = 'iframe' | 'ab-screencast' | 'ab-popout' | 'pw-screencast' | 'pw-popout'; /** What each kind is backed by (`docs/specs/glossary.md` → Panes and Surfaces). * The single source of capability gating; kind switches elsewhere go through @@ -405,74 +407,52 @@ export interface ResolveOpenTargetResponse { port: number; } -/** The two ways `dor ab` asks the host to name a session: a Surface handle whose - * bound session it wants, or a managed `--key`, whose session name is the - * answering Workspace's (`docs/specs/dor-browser.md` → Managed identity). Never - * both — a key names no Surface, and a Surface's session was minted long ago. */ -export type ResolveAgentBrowserSessionRequest = WorkspaceScopedRequest & ( +/** The two ways a browser command asks the host for its binding: a Surface + * handle, or a managed `--key`, which only the answering Workspace can + * namespace (`docs/specs/dor-browser.md` → Managed identity). Never both — a + * key names no Surface, and a Surface's session was minted long ago. */ +export type ResolveBrowserRequest = WorkspaceScopedRequest & { provider: BrowserAutomationProvider } & ( | { /** A Surface handle (surface:N, surface:, surface:self, * surface:focused, title:) naming the browser Surface to drive. */ surface: string; key?: undefined; + proposed?: undefined; } | { - /** A managed browser key (`dor ab --key`), which only the answering - * Workspace can namespace. */ key: string; surface?: undefined; + /** The caller's cwd and executable, offered for a key's first command + * that may bind a Surface; the host mints the session. */ + proposed?: Omit<BrowserBinding, 'session'>; } ); -export interface ResolveAgentBrowserSessionResponse { - /** The Surface the handle named; absent when the request named a `key`, which - * is answered whether or not a Surface holds that session yet. */ - surfaceId?: string; - surfaceRef?: string; - /** The agent-browser session — what `dor ab` forwards as `--session`. Includes - * GUI-minted sessions, which no `--key` can name. */ - session: string; -} - -export type BrowserAutomationProvider = 'agent-browser' | 'playwright'; - -/** What a provider's CLI command runs with: its native session, the project - * directory it runs in, and the executable. */ -export interface BrowserBinding { - session: string; - cwd?: string; - binaryPath?: string; -} - -export interface ResolveBrowserRequest extends WorkspaceScopedRequest { - provider: BrowserAutomationProvider; - key?: string; - surface?: string; - /** The caller's cwd and executable, offered for a key's first launch; the - * host mints the session. */ - proposed?: Omit<BrowserBinding, 'session'>; -} - export interface ResolveBrowserResponse { - /** null when the key or Surface has no binding yet. */ - binding: BrowserBinding | null; + /** The key's or Surface's binding; a key no Surface holds yet gets the + * session the host minted for it. */ + binding: BrowserBinding; } -export interface AgentBrowserSurfaceRequest extends WorkspaceScopedRequest { - provider?: BrowserAutomationProvider; - cwd?: string; +/** After a browser command succeeds: open or reuse the Surface bound to its + * session. The binding fields are what the command ran with. */ +export interface BrowserSurfaceRequest extends WorkspaceScopedRequest { + provider: BrowserAutomationProvider; /** Managed workspace-scoped key; absent when attaching via raw --session. */ key?: string; - /** Resolved agent-browser session name — the join key for the surface. */ session: string; - /** Session stream WebSocket port from `stream status --json`. */ - wsPort?: number; - /** Absolute path of the agent-browser binary, resolved with the invoking - * terminal's PATH so the host (which may lack it) can run tab/close. */ + /** The directory the command ran in. */ + cwd?: string; + /** Absolute path of the provider's binary, resolved with the invoking + * terminal's PATH so the host (which may lack it) can drive the browser. */ binaryPath?: string; + /** agent-browser only: the stream port `dor ab` read itself after the + * command; the Surface streams from it instead of asking the host. */ + wsPort?: number; + minimized?: boolean; } -export interface AgentBrowserSurfaceResponse { +export interface BrowserSurfaceResponse { status: 'created' | 'existing' | 'replaced'; surfaceId: string; surfaceRef: string; @@ -481,7 +461,7 @@ export interface AgentBrowserSurfaceResponse { } export interface ControlClient { - browserSurface(request: AgentBrowserSurfaceRequest): Promise<AgentBrowserSurfaceResponse>; + browserSurface(request: BrowserSurfaceRequest): Promise<BrowserSurfaceResponse>; resolveBrowser(request: ResolveBrowserRequest): Promise<ResolveBrowserResponse>; listSurfaces(request: ListSurfacesRequest): Promise<ListSurfacesResponse>; splitSurface(request: SplitSurfaceRequest): Promise<SplitSurfaceResponse>; @@ -492,11 +472,7 @@ export interface ControlClient { awaitSurface(request: AwaitSurfaceRequest): Promise<AwaitSurfaceResponse>; killSurface(request: KillSurfaceRequest): Promise<KillSurfaceResponse>; iframeSurface(request: IframeSurfaceRequest): Promise<IframeSurfaceResponse>; - agentBrowserSurface(request: AgentBrowserSurfaceRequest): Promise<AgentBrowserSurfaceResponse>; resolveOpenTarget(request: ResolveOpenTargetRequest): Promise<ResolveOpenTargetResponse>; - resolveAgentBrowserSession( - request: ResolveAgentBrowserSessionRequest, - ): Promise<ResolveAgentBrowserSessionResponse>; listWorkspaces(request: ListWorkspacesRequest): Promise<ListWorkspacesResponse>; newWorkspace(request: NewWorkspaceRequest): Promise<WorkspaceMutationResponse>; renameWorkspace(request: RenameWorkspaceRequest): Promise<WorkspaceMutationResponse>; @@ -506,7 +482,7 @@ export interface ControlClient { restartApp(): Promise<AppRestartResponse>; } -export interface AgentBrowserExecResult { +export interface BrowserExecResult { exitCode: number; stdout: string; stderr: string; @@ -514,7 +490,7 @@ export interface AgentBrowserExecResult { /** Runs the user's browser CLI binary, in `cwd` when given; injectable so CLI * tests stay hermetic. */ -export type AgentBrowserExec = (binary: string, args: string[], cwd?: string) => Promise<AgentBrowserExecResult>; +export type BrowserExec = (binary: string, args: string[], cwd?: string) => Promise<BrowserExecResult>; export interface CliEnv { [key: string]: string | undefined; @@ -525,8 +501,8 @@ export interface CliOptions { client?: ControlClient; readStdin?: () => Promise<string>; versionMetadata?: VersionMetadata; - execAgentBrowser?: AgentBrowserExec; - execPlaywright?: AgentBrowserExec; + execAgentBrowser?: BrowserExec; + execPlaywright?: BrowserExec; } export interface CliResult { diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index de499b856..1ac5ab02d 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -1,9 +1,9 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { createConnection } from 'node:net'; import type { - AgentBrowserSurfaceRequest, - AgentBrowserSurfaceResponse, AppRestartResponse, + BrowserSurfaceRequest, + BrowserSurfaceResponse, AwaitSurfaceRequest, AwaitSurfaceResponse, ControlClient, @@ -25,8 +25,6 @@ import type { WorkspaceMutationResponse, ReadSurfaceRequest, ReadSurfaceResponse, - ResolveAgentBrowserSessionRequest, - ResolveAgentBrowserSessionResponse, ResolveBrowserRequest, ResolveBrowserResponse, ResolveOpenTargetRequest, @@ -45,6 +43,7 @@ import { type DorControlMethod, } from './protocol.js'; import type { DorControlResult } from './protocol.js'; +import { BROWSER_REQUEST_TIMEOUT_MS } from 'dor-lib-common'; export interface SocketControlClientOptions { socketPath: string; @@ -150,31 +149,26 @@ export class SocketControlClient implements ControlClient { return this.request<IframeSurfaceResponse>(SURFACE_CONTROL_METHODS.iframe, request); } - browserSurface(request: AgentBrowserSurfaceRequest): Promise<AgentBrowserSurfaceResponse> { - return this.request<AgentBrowserSurfaceResponse>(SURFACE_CONTROL_METHODS.browser, request); + // The host asks the browser where it streams (`attach`) before answering, + // which can wait behind a launch or close of that browser for as long as + // the host's own request timeout; the socket deadline sits above it, so a + // bind that succeeds late is never reported as a failure. + browserSurface(request: BrowserSurfaceRequest): Promise<BrowserSurfaceResponse> { + return this.request<BrowserSurfaceResponse>( + SURFACE_CONTROL_METHODS.browser, + request, + { timeoutMs: BROWSER_REQUEST_TIMEOUT_MS + 5_000 }, + ); } resolveBrowser(request: ResolveBrowserRequest): Promise<ResolveBrowserResponse> { return this.request<ResolveBrowserResponse>(SURFACE_CONTROL_METHODS.resolveBrowser, request); } - agentBrowserSurface(request: AgentBrowserSurfaceRequest): Promise<AgentBrowserSurfaceResponse> { - return this.request<AgentBrowserSurfaceResponse>(SURFACE_CONTROL_METHODS.agentBrowser, request); - } - resolveOpenTarget(request: ResolveOpenTargetRequest): Promise<ResolveOpenTargetResponse> { return this.request<ResolveOpenTargetResponse>(SURFACE_CONTROL_METHODS.resolveOpen, request); } - resolveAgentBrowserSession( - request: ResolveAgentBrowserSessionRequest, - ): Promise<ResolveAgentBrowserSessionResponse> { - return this.request<ResolveAgentBrowserSessionResponse>( - SURFACE_CONTROL_METHODS.resolveAgentBrowser, - request, - ); - } - listWorkspaces(request: ListWorkspacesRequest): Promise<ListWorkspacesResponse> { return this.request<ListWorkspacesResponse>(WORKSPACE_CONTROL_METHODS.list, request); } diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 919ea6083..1ac115831 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -23,10 +23,13 @@ export const SURFACE_CONTROL_METHODS = { await: 'surface.await', kill: 'surface.kill', iframe: 'surface.iframe', - agentBrowser: 'surface.agentBrowser', browser: 'surface.browser', resolveBrowser: 'surface.resolveBrowser', resolveOpen: 'surface.resolveOpen', + /** Legacy aliases of `browser` / `resolveBrowser` for agent-browser, which a + * `dor` older than its host still sends (docs/specs/dor-cli.md → "Browser + * Surface Addressing"). No current `dor` sends them. */ + agentBrowser: 'surface.agentBrowser', resolveAgentBrowser: 'surface.resolveAgentBrowser', } as const; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 06cfabf78..ff66b3a7e 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -119,13 +119,16 @@ function fixtureWorkspace(target) { )) ?? fixtureWorkspaces[0]; } -/** The `surface.agentBrowser` request a `dor ab` run made, if it opened one at +/** The `surface.browser` request a `dor ab` run made, if it opened one at * all: every run now asks the host to name its session first, so the surface * call is never the first entry. */ function surfaceRequest(client) { - return client.requests.find((entry) => entry.method === 'agentBrowserSurface')?.request; + return client.requests.find((entry) => entry.method === 'browserSurface')?.request; } +/** What a `dor ab` run in this process offers for a key's first binding. */ +const proposedHere = { cwd: process.cwd() }; + function fixtureClient(surfacesFixture = fixtureSurfaces) { return { requests: [], @@ -262,8 +265,8 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { minimized: request.minimized, }; }, - async agentBrowserSurface(request) { - this.requests.push({ method: 'agentBrowserSurface', request }); + async browserSurface(request) { + this.requests.push({ method: 'browserSurface', request }); return { status: 'created', surfaceId: '33333333-3333-4333-8333-333333333333', @@ -276,24 +279,20 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { // GUI-minted session, surface:1 is a terminal and fails the host's gate. // Which gate rejected is the host's business (asserted in Wall.test.tsx); // the CLI has one catch and prints whatever comes back. - async resolveAgentBrowserSession(request) { - this.requests.push({ method: 'resolveAgentBrowserSession', request }); - // A managed key names no Surface: the answering Workspace namespaces it, + async resolveBrowser(request) { + this.requests.push({ method: 'resolveBrowser', request }); + // A managed key no Surface holds is the answering Workspace's session, // so the same key in `build` is another browser entirely. if (request.key !== undefined) { const workspace = fixtureWorkspaces.find((row) => ( request.workspace === row.name || request.workspace === row.ref )); - return { session: `dormouse.${workspace ? workspace.id : '1'}.${request.key}` }; + return { binding: { session: `dormouse.${workspace ? workspace.id : '1'}.${request.key}`, ...request.proposed } }; } if (request.surface === 'surface:1') { throw new Error("surface 'surface:1' has no browser (kind: terminal)"); } - return { - surfaceId: '33333333-3333-4333-8333-333333333333', - surfaceRef: 'surface:3', - session: 'dormouse.1.gui-a1b2c3', - }; + return { binding: { session: 'dormouse.1.gui-a1b2c3' } }; }, async listWorkspaces(request) { this.requests.push({ method: 'listWorkspaces', request }); @@ -1100,6 +1099,8 @@ test('agent-browser resolves --key to a namespaced session and opens a surface', const ab = fakeAgentBrowser(); const client = fixtureClient(); await runCli(['ab', '--key', 'storybook', 'open', 'http://localhost:6006'], { client, execAgentBrowser: ab.exec }); + // The command made the daemon, so `dor ab` asks it for its stream port — under + // its own socket directory and CLI — and hands that over. assert.deepEqual(ab.calls, [ ['agent-browser', '--session', 'dormouse.1.storybook', 'open', 'http://localhost:6006'], ['agent-browser', '--session', 'dormouse.1.storybook', 'stream', 'status', '--json'], @@ -1107,8 +1108,8 @@ test('agent-browser resolves --key to a namespaced session and opens a surface', assert.deepEqual(client.requests, [ // The host names the session, because only the Workspace that will hold the // browser can namespace a key. - { method: 'resolveAgentBrowserSession', request: { key: 'storybook' } }, - { method: 'agentBrowserSurface', request: { key: 'storybook', session: 'dormouse.1.storybook', wsPort: 61141 } }, + { method: 'resolveBrowser', request: { provider: 'agent-browser', key: 'storybook', proposed: proposedHere } }, + { method: 'browserSurface', request: { provider: 'agent-browser', key: 'storybook', session: 'dormouse.1.storybook', cwd: process.cwd(), wsPort: 61141 } }, ]); }); @@ -1124,10 +1125,10 @@ test('agent-browser --key in another Workspace drives that Workspace own session ['agent-browser', '--session', 'dormouse.workspace-2b1c.default', 'stream', 'status', '--json'], ]); assert.deepEqual(client.requests, [ - { method: 'resolveAgentBrowserSession', request: { key: 'default', workspace: 'build' } }, + { method: 'resolveBrowser', request: { provider: 'agent-browser', key: 'default', proposed: proposedHere, workspace: 'build' } }, { - method: 'agentBrowserSurface', - request: { key: 'default', session: 'dormouse.workspace-2b1c.default', wsPort: 61141, workspace: 'build' }, + method: 'browserSurface', + request: { provider: 'agent-browser', key: 'default', session: 'dormouse.workspace-2b1c.default', cwd: process.cwd(), wsPort: 61141, workspace: 'build' }, }, ]); }); @@ -1137,7 +1138,7 @@ test('agent-browser defaults to --key default', async () => { const client = fixtureClient(); await runCli(['agent-browser', 'open', 'http://localhost:5173'], { client, execAgentBrowser: ab.exec }); assert.equal(ab.calls[0][2], 'dormouse.1.default'); - assert.deepEqual(client.requests[1].request, { key: 'default', session: 'dormouse.1.default', wsPort: 61141 }); + assert.deepEqual(client.requests[1].request, { provider: 'agent-browser', key: 'default', session: 'dormouse.1.default', cwd: process.cwd(), wsPort: 61141 }); }); test('agent-browser raw --session skips key namespacing', async () => { @@ -1148,7 +1149,7 @@ test('agent-browser raw --session skips key namespacing', async () => { // A raw session is already the session: nothing is asked of the host but the // surface it binds to. assert.deepEqual(client.requests, [ - { method: 'agentBrowserSurface', request: { key: undefined, session: 'mine', wsPort: 61141 } }, + { method: 'browserSurface', request: { provider: 'agent-browser', key: undefined, session: 'mine', cwd: process.cwd(), wsPort: 61141 } }, ]); }); @@ -1165,9 +1166,9 @@ test('agent-browser --workspace names the Workspace and never reaches the binary // Every host round trip the run makes names it: the session namespace, the // handle resolution, and the surface the browser lands in. assert.deepEqual(client.requests.map((entry) => [entry.method, entry.request.workspace]), [ - ['resolveAgentBrowserSession', 'build'], + ['resolveBrowser', 'build'], ['resolveOpenTarget', 'build'], - ['agentBrowserSurface', 'build'], + ['browserSurface', 'build'], ]); }); @@ -1273,8 +1274,8 @@ test('agent-browser --surface drives the session the host says the surface is bo // The handle is resolved first, then everything else is forwarded verbatim // against the resolved session — a GUI-minted name no `--key` can produce. assert.deepEqual(client.requests[0], { - method: 'resolveAgentBrowserSession', - request: { surface: 'surface:3' }, + method: 'resolveBrowser', + request: { provider: 'agent-browser', surface: 'surface:3' }, }); assert.deepEqual(ab.calls, [ ['agent-browser', '--session', 'dormouse.1.gui-a1b2c3', 'click', '@e3'], @@ -1282,8 +1283,8 @@ test('agent-browser --surface drives the session the host says the surface is bo ]); // No `key`: a surface-addressed session is not necessarily a managed one. assert.deepEqual(client.requests[1], { - method: 'agentBrowserSurface', - request: { key: undefined, session: 'dormouse.1.gui-a1b2c3', wsPort: 61141 }, + method: 'browserSurface', + request: { provider: 'agent-browser', key: undefined, session: 'dormouse.1.gui-a1b2c3', cwd: process.cwd(), wsPort: 61141 }, }); }); @@ -1316,7 +1317,7 @@ test('agent-browser fails fast when the host refuses to name a managed key, neve // (`docs/specs/dor-browser.md` → "Managed identity"). const ab = fakeAgentBrowser(); const client = fixtureClient(); - client.resolveAgentBrowserSession = async () => { + client.resolveBrowser = async () => { throw new Error("workspace 'workspace:1' is still mounting"); }; const result = await runCli(['ab', 'tab', 'list'], { client, execAgentBrowser: ab.exec }); @@ -1415,7 +1416,7 @@ test('agent-browser spawns the PATH-resolved absolute path, never the bare name' // resolveBinaryPath splits on the same, so a POSIX-only `:` would hide dir. env: { PATH: ['/nonexistent', dir].join(delimiter) }, }); - // Both spawns take the resolved path. Spawning the bare name instead would + // The spawn takes the resolved path. Spawning the bare name instead would // hand the inherited cwd a code-execution primitive on Windows, where // cross-spawn's `which` searches it before PATH — docs/specs/dor-cli.md -> // "Spawning External Binaries". diff --git a/dor/test/control-client.test.mjs b/dor/test/control-client.test.mjs index 224834958..67a8d5d29 100644 --- a/dor/test/control-client.test.mjs +++ b/dor/test/control-client.test.mjs @@ -51,6 +51,27 @@ test('a dor client and the host control server complete a request', skipOnWindow }); }); +test('a browser bind waits past the host\'s browser request timeout, not the client default', skipOnWindows, async () => { + await withTempSocket(async (socketPath) => { + // The host answers late: its `attach` queued behind a launch of the browser. + const server = createDorControlServer({ + socketPath, + token: 'shared-secret', + send(event, data) { + if (event === 'dor:controlRequest') setTimeout(() => server.respond({ requestId: data.requestId, ok: true, result: { status: 'created' } }), 200); + }, + }); + await server.ready; + try { + const client = new SocketControlClient({ socketPath, token: 'shared-secret', timeoutMs: 50 }); + assert.deepEqual(await client.browserSurface({ provider: 'playwright', session: 's' }), { status: 'created' }); + await assert.rejects(client.listSurfaces({}), /timed out waiting for surface.list/); + } finally { + server.close(); + } + }); +}); + test('a dor client refuses a host whose token does not match', skipOnWindows, async () => { await withTempSocket(async (socketPath) => { const server = createDorControlServer({ socketPath, token: 'shared-secret', send() {} }); diff --git a/dor/test/playwright.test.mjs b/dor/test/playwright.test.mjs index 9e3e3f0a2..2e9d11f9d 100644 --- a/dor/test/playwright.test.mjs +++ b/dor/test/playwright.test.mjs @@ -9,19 +9,29 @@ import { runCli } from '../dist/cli.js'; const root = mkdtempSync(join(tmpdir(), 'dor-pw-')); const firstProject = join(root, 'first-project'); const guiProject = join(root, 'gui'); +// The directory `dor pw` runs from, which the host pins for a key's first command. +const caller = join(root, 'caller'); mkdirSync(firstProject); mkdirSync(guiProject); +mkdirSync(caller); const pinnedCli = join(root, 'first', 'playwright-cli'); mkdirSync(join(root, 'first')); writeFileSync(pinnedCli, '#!/bin/sh\n'); chmodSync(pinnedCli, 0o755); +/** `binding` is what the host holds for the key or Surface; without one, a key + * is minted as the host would and a Surface has no session to drive. */ function fixture(binding = null) { const calls = []; const options = { - env: { PWD: '/caller', DORMOUSE_PLAYWRIGHT_BIN: '/tools/playwright-cli' }, + env: { PWD: caller, DORMOUSE_PLAYWRIGHT_BIN: '/tools/playwright-cli' }, client: { - resolveBrowser: async request => { calls.push(['resolve', request]); return { binding }; }, + resolveBrowser: async request => { + calls.push(['resolve', request]); + if (binding) return { binding }; + if (request.surface !== undefined) throw new Error(`surface '${request.surface}' has no playwright session yet`); + return { binding: { session: `dormouse.1.${request.key}`, ...request.proposed } }; + }, browserSurface: async request => { calls.push(['surface', request]); return {}; }, }, execPlaywright: async (...args) => { calls.push(['exec', ...args]); return { exitCode: 0, stdout: 'native output\n', stderr: '' }; }, @@ -34,7 +44,7 @@ test('pw alias forwards native arguments and binds only to Playwright', async () const result = await runCli(['pw', '--key', 'app', 'open', ':5173', '--headed'], options); assert.equal(result.stdout, 'native output\n'); assert.equal(result.exitCode, 0); - assert.deepEqual(calls.find(c => c[0] === 'exec'), ['exec', '/tools/playwright-cli', ['--session=dormouse.1.app', 'open', 'http://localhost:5173/', '--headed'], '/caller']); + assert.deepEqual(calls.find(c => c[0] === 'exec'), ['exec', '/tools/playwright-cli', ['--session=dormouse.1.app', 'open', 'http://localhost:5173/', '--headed'], caller]); assert.equal(calls.at(-1)[1].provider, 'playwright'); }); test('existing key pins executable, cwd and native session across terminal directories', async () => { @@ -79,7 +89,7 @@ test('raw -s bypasses workspace addressing', async () => { const { calls, options } = fixture(); await runCli(['pw', '-s=raw-session', 'goto', ':8080'], options); assert.equal(calls.some(c => c[0] === 'resolve'), false); - assert.deepEqual(calls[0].slice(2), [['--session=raw-session', 'goto', 'http://localhost:8080/'], '/caller']); + assert.deepEqual(calls[0].slice(2), [['--session=raw-session', 'goto', 'http://localhost:8080/'], caller]); assert.equal(calls.at(-1)[1].key, undefined); }); test('surface addressing resolves GUI-created sessions and rejects missing sessions', async () => { diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index 0ee90894e..4daa9fc92 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -50,7 +50,7 @@ const config: StorybookConfig = { // specifier to source too. 'remote-lib-common': path.resolve(here, '..', '..', 'remote-lib-common', 'src'), // And `Wall` → `useDorControl` → `connect-port` imports - // `dor-lib-common/agent-browser`, whose `exports` point at the same kind of + // `dor-lib-common/browser-providers`, whose `exports` point at the same kind of // unbuilt `dist`. The directory alias covers the subpath and the bare // specifier both. 'dor-lib-common': path.resolve(here, '..', '..', 'dor-lib-common', 'src'), diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index c504e2b6b..99d39e712 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -10,7 +10,7 @@ import { act } from 'react'; import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { sessionForKey } from 'dor-lib-common/browser-providers'; import { Wall } from './Wall'; import * as helpers from '../lib/helper-terminal'; import * as agentBrowserScreen from './wall/agent-browser-screen'; @@ -22,6 +22,7 @@ import { setDevServerResolution } from './wall/agent-browser-ports'; import { setPlatform } from '../lib/platform'; import { FakePtyAdapter } from '../lib/platform/fake-adapter'; import type { PlatformAdapter } from '../lib/platform/types'; +import type { BrowserRequest } from '../lib/platform/browser-automation'; import type { PersistedSession } from '../lib/session-types'; import * as terminalRegistry from '../lib/terminal-registry'; import { UNNAMED_PANEL_TITLE } from '../lib/terminal-registry'; @@ -30,8 +31,8 @@ import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; import { addPlainNote, beginClosing, clearAllNotepads, getNotes, setOpenNotepadId } from '../lib/notepad/notepad-store'; import type { NotepadArchiveV1 } from '../lib/notepad/types'; import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; -import { getWallHandle, listWallHandles } from './wall/wall-handles'; -import { mountWallHarness, type WallHarness } from './wall/wall-test-utils'; +import { getWallHandle, listWallHandles, registerWallHandle, stubWallHandle } from './wall/wall-handles'; +import { installBrowserHost, mountWallHarness, type WallHarness } from './wall/wall-test-utils'; import { DEFAULT_WORKSPACE_ID } from '../lib/session-types'; import { clearTerminalActivity, setTerminalActivity } from '../lib/session-activity-store'; import { createAlertEpisode } from '../lib/alert-episode'; @@ -120,6 +121,13 @@ async function flushFrame(): Promise<void> { await act(async () => { await new Promise((r) => requestAnimationFrame(() => r(undefined))); }); } +/** `installBrowserHost`, on the adapter this file's tests read as `fake`. */ +function hostBrowsers(...args: Parameters<typeof installBrowserHost>): ReturnType<typeof installBrowserHost> { + const host = installBrowserHost(...args); + fake = host.platform; + return host; +} + describe('Wall on the Lath engine', () => { /** The alarm treatment is the leaf overlay, so it must reach a ringing terminal * through the engine's overlay slot and leave a quiet neighbour alone. */ @@ -522,6 +530,7 @@ describe('Wall on the Lath engine', () => { // this realm cannot (`DORMOUSE_AGENT_BROWSER_BIN` matches by exact value, and // only the host reads its own environment). it('drops a binaryPath that is not an agent-browser without failing the request', async () => { + const { requests } = hostBrowsers(); await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); }); @@ -543,6 +552,34 @@ describe('Wall on the Lath engine', () => { expect(response?.ok).toBe(true); expect(response?.error).toBeUndefined(); expect(response?.result?.surfaceId).toBeTruthy(); + expect(requests('attach')[0]).toEqual({ provider: 'agent-browser', binding: { session: 'dormouse.1.gate' }, op: 'attach' }); + }); + + it('streams from a port `dor ab` read itself, asking the host nothing; a Playwright port is the host\'s to report', async () => { + // An agent-browser that writes no state files, or keeps them in a socket + // directory of its own, is one the host cannot find + // (docs/specs/dor-browser.md → "agent-browser"). + const { browser, requests } = hostBrowsers({ attach: async () => ({ ok: false, error: 'not running' }) }); + await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); }); + await flush(); + const bind = async (params: Record<string, unknown>) => { + let response: { ok: boolean; error?: string; result?: { surfaceId: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { method: SURFACE_CONTROL_METHODS.browser, params, respond: (r: typeof response) => { response = r; } }, + })); + }); + await flush(); + return response; + }; + + const bound = await bind({ provider: 'agent-browser', session: 'mine', wsPort: 61218 }); + expect(bound?.ok).toBe(true); + expect(requests('attach')).toEqual([]); + expect(browser).toHaveBeenCalledWith(expect.objectContaining({ op: 'streamUrl', port: 61218 })); + + expect((await bind({ provider: 'playwright', session: 'dormouse.pw.x', cwd: '/project', wsPort: 61219 }))?.ok).toBe(false); + expect(requests('attach')).toEqual([{ provider: 'playwright', binding: { session: 'dormouse.pw.x', cwd: '/project' }, op: 'attach' }]); }); // The control socket is a wire protocol, not the CLI: `dor iframe` validates @@ -718,9 +755,48 @@ describe('Wall on the Lath engine', () => { } }); + it.each(['playwright', 'agent-browser'] as const)('never binds a %s key to a session a Surface in another Workspace still holds', async (provider) => { + const { requests } = hostBrowsers({ attach: async () => ({ ok: true, wsPort: 4555 }) }); + const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); + const disposers: Array<() => void> = []; + try { + await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); }); + await flush(); + const control = async (method: string, params: Record<string, unknown>) => { + let response: { ok: boolean; result?: { binding?: { session: string }; surfaceId?: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { method, params, surfaceId: 'pane-a', respond: (r: typeof response) => { response = r; } }, + })); + }); + await flush(); + return response; + }; + // `dor --key foo open` binds a pane here… + const first = (await control(SURFACE_CONTROL_METHODS.resolveBrowser, { provider, key: 'foo', proposed: { cwd: '/project' } }))?.result?.binding; + const bound = await control(SURFACE_CONTROL_METHODS.browser, { provider, key: 'foo', session: first!.session, cwd: '/project' }); + expect(bound?.ok).toBe(true); + // …which moves to another Workspace, still bound to that session. + expect((await dispatchKill(bound!.result!.surfaceId!))?.ok).toBe(true); + disposers.push(registerWallHandle(stubWallHandle('ws-elsewhere', { + browserSessions: (asked) => (asked === provider ? [first!.session] : []), + }))); + + // The same command here opens a browser of its own, so neither pane's + // close ends the other's. + const again = (await control(SURFACE_CONTROL_METHODS.resolveBrowser, { provider, key: 'foo', proposed: { cwd: '/project' } }))?.result?.binding; + expect(again?.session).toBe(`${first!.session}.2`); + expect(requests('attach')).toHaveLength(1); + } finally { + disposers.forEach((dispose) => dispose()); + untouchedSpy.mockRestore(); + } + }); + it('keeps a Playwright key on its session when no viewer can attach to the browser it opened', async () => { - const playwright = vi.fn(async () => ({ ok: false, error: 'Dormouse currently views Chromium Playwright sessions only. The native CLI command still ran.' })); - Object.assign(fake, { playwright }); + const { requests } = hostBrowsers({ + attach: async () => ({ ok: false, error: 'Dormouse currently views Chromium Playwright sessions only. The native CLI command still ran.' }), + }); await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); }); await flush(); const control = async (method: string, params: Record<string, unknown>) => { @@ -737,6 +813,7 @@ describe('Wall on the Lath engine', () => { const first = (await control(SURFACE_CONTROL_METHODS.resolveBrowser, { provider: 'playwright', key: 'app', proposed: { cwd: '/project' } }))?.result?.binding; expect(first).toMatchObject({ cwd: '/project' }); expect((await control(SURFACE_CONTROL_METHODS.browser, { provider: 'playwright', key: 'app', session: first!.session, cwd: '/project' }))?.ok).toBe(false); + expect(requests('attach')).toEqual([{ provider: 'playwright', binding: { session: first!.session, cwd: '/project' }, op: 'attach' }]); // Past the two minutes an unbound first launch is held for. const now = Date.now(); @@ -752,11 +829,7 @@ describe('Wall on the Lath engine', () => { it('reveals a restored context-port browser instead of opening a second', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); const helperSpy = vi.spyOn(helpers, 'openHelper').mockResolvedValue({ id: 'context-helper', parentId: 'pane-a', command: '', status: 'preserved' }); - const open = vi.fn(async () => ({ ok: true, session: 'second', wsPort: 1 })); - Object.assign(fake, { - agentBrowserOpen: open, - agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), - }); + const { requests } = hostBrowsers({ launch: async () => ({ ok: true, session: 'second', wsPort: 1 }) }); try { // The key a pre-Playwright build persisted for this port's agent-browser pane. await act(async () => { @@ -779,7 +852,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); - expect(open).not.toHaveBeenCalled(); + expect(requests('launch')).toEqual([]); expect(container.querySelector('[data-lath-leaf="restored-ab"]')).not.toBeNull(); expect(leafCount()).toBe(2); } finally { @@ -791,13 +864,10 @@ describe('Wall on the Lath engine', () => { it('pops out a reused context-port browser at the port, never navigating into the relaunch', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); const helperSpy = vi.spyOn(helpers, 'openHelper').mockResolvedValue({ id: 'context-helper', parentId: 'pane-a', command: '', status: 'preserved' }); - const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - const popOut = vi.fn(() => new Promise<never>(() => {})); - Object.assign(fake, { - agentBrowserOpen: vi.fn(async () => ({ ok: true, session: 'second', wsPort: 1 })), - agentBrowserAttach: vi.fn(async () => ({ ok: true, wsPort: 4321 })), - agentBrowserCommand: command, - agentBrowserPopOut: popOut, + // A relaunch of the named session hangs; a mint answers at once. + const { browser, requests } = hostBrowsers({ + launch: (request) => request.binding.session ? new Promise<never>(() => {}) : Promise.resolve({ ok: true, session: 'second', wsPort: 1 }), + attach: async () => ({ ok: true, wsPort: 4321 }), }); try { // The port's browser, navigated away from the port's page since. @@ -825,7 +895,7 @@ describe('Wall on the Lath engine', () => { })); }); await flush(); - command.mockClear(); + browser.mockClear(); await act(async () => { document.querySelector<HTMLButtonElement>('[data-terminal-context] button[aria-label="Open in agent-browser popout"]')!.click(); }); @@ -834,8 +904,10 @@ describe('Wall on the Lath engine', () => { // One relaunch, at the port — not at the page it was on, with the port's // `open` queued behind it. expect(leafCount()).toBe(2); - expect(popOut).toHaveBeenCalledExactlyOnceWith('restored', expect.objectContaining({ url: 'http://localhost:5173/' }), undefined); - expect(command).not.toHaveBeenCalledWith('restored', ['open', 'http://localhost:5173/'], undefined); + expect(requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'restored' }, op: 'launch', url: 'http://localhost:5173/', headed: true, + }]); + expect(requests('navigate')).toEqual([]); } finally { helperSpy.mockRestore(); untouchedSpy.mockRestore(); @@ -845,12 +917,10 @@ describe('Wall on the Lath engine', () => { it('pops out a restored context-port Door at the port', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); const helperSpy = vi.spyOn(helpers, 'openHelper').mockResolvedValue({ id: 'context-helper', parentId: 'pane-a', command: '', status: 'preserved' }); - const popOut = vi.fn(() => new Promise<never>(() => {})); - Object.assign(fake, { - agentBrowserOpen: vi.fn(async () => ({ ok: true, session: 'second', wsPort: 1 })), - agentBrowserAttach: vi.fn(async () => ({ ok: true, wsPort: 4321 })), - agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), - agentBrowserPopOut: popOut, + // A relaunch of the named session hangs; a mint answers at once. + const { requests } = hostBrowsers({ + launch: (request) => request.binding.session ? new Promise<never>(() => {}) : Promise.resolve({ ok: true, session: 'second', wsPort: 1 }), + attach: async () => ({ ok: true, wsPort: 4321 }), }); try { await act(async () => { @@ -873,7 +943,9 @@ describe('Wall on the Lath engine', () => { }); await flush(); - expect(popOut).toHaveBeenCalledExactlyOnceWith('restored', expect.objectContaining({ url: 'http://localhost:5173/' }), undefined); + expect(requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'restored' }, op: 'launch', url: 'http://localhost:5173/', headed: true, + }]); } finally { helperSpy.mockRestore(); untouchedSpy.mockRestore(); @@ -886,8 +958,9 @@ describe('Wall on the Lath engine', () => { // its parent closed — the one alert the context shows ahead of a port error. const helperSpy = vi.spyOn(helpers, 'openHelper').mockResolvedValue({ id: 'context-helper', parentId: 'pane-a', command: '', status: 'preserved' }); // A launch the host answers without a session or a reason of its own. - const playwright = vi.fn(async () => ({ ok: false })); - Object.assign(fake, { playwright, agentBrowserOpen: vi.fn(async () => ({ ok: true, session: 'ab', wsPort: 1 })) }); + const { browser } = hostBrowsers({ + launch: async (request) => request.provider === 'playwright' ? { ok: false } : { ok: true, session: 'ab', wsPort: 1 }, + }); try { await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); @@ -906,7 +979,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); - expect(playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'open', url: 'http://localhost:5173/' })); + expect(browser).toHaveBeenCalledWith(expect.objectContaining({ provider: 'playwright', op: 'launch', url: 'http://localhost:5173/' })); expect(document.querySelector('[data-terminal-context] [role="alert"]')?.textContent).toBe('Could not open Playwright'); // The pane made for it goes with the failure. expect(leafCount()).toBe(1); @@ -923,12 +996,10 @@ describe('Wall on the Lath engine', () => { const openResult = new Promise<{ ok: boolean; session: string; wsPort: number }>((resolve) => { resolveOpen = resolve; }); - const agentBrowserCommand = vi.fn(async (_session: string, args: string[]) => { - return { exitCode: 0, stdout: '', stderr: '' }; + const { requests } = hostBrowsers({ + launch: () => openResult, + attach: async () => ({ ok: true, wsPort: 4321 }), }); - (fake as PlatformAdapter).agentBrowserCommand = agentBrowserCommand; - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => openResult); - (fake as PlatformAdapter).agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 4321 })); try { await act(async () => { @@ -1001,7 +1072,7 @@ describe('Wall on the Lath engine', () => { expect(container.querySelectorAll('[data-door-id]')).toHaveLength(1); expect((await dispatchKill(browserId))?.ok).toBe(true); - expect(agentBrowserCommand).toHaveBeenCalledWith(defaultSession, ['close'], undefined); + expect(requests('close')).toContainEqual({ provider: 'agent-browser', binding: { session: defaultSession }, op: 'close' }); } finally { untouchedSpy.mockRestore(); } @@ -1010,11 +1081,8 @@ describe('Wall on the Lath engine', () => { it('binds a minimized pane restored after a failed provider swap to a live controller', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); const pwOpen = Promise.withResolvers<{ ok: boolean; error?: string }>(); - const relaunch = vi.fn(async () => ({ ok: true, session: 'relaunched', wsPort: 4321 })); - Object.assign(fake, { - playwright: vi.fn((request: { op: string }) => request.op === 'open' ? pwOpen.promise : Promise.resolve({ ok: true })), - agentBrowserOpen: relaunch, - agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), + const { requests } = hostBrowsers({ + launch: (request) => request.provider === 'playwright' ? pwOpen.promise : Promise.resolve({ ok: true, session: 'relaunched', wsPort: 4321 }), }); try { await act(async () => { @@ -1040,7 +1108,9 @@ describe('Wall on the Lath engine', () => { // the session it had. await act(async () => { pwOpen.resolve({ ok: false, error: 'playwright-cli is not installed' }); }); await flush(); - expect(relaunch).toHaveBeenCalledWith('http://localhost:5173/', { headed: false, session: 'ab-live' }, undefined); + expect(requests('launch')).toContainEqual({ + provider: 'agent-browser', binding: { session: 'ab-live' }, op: 'launch', url: 'http://localhost:5173/', headed: false, + }); expect(getAgentBrowserSurfaceController(eagerId)?.provider).toBe('agent-browser'); expect(getAgentBrowserScreenController(eagerId)?.snapshot().renderMode).toBe('ab-screencast'); } finally { @@ -1050,10 +1120,7 @@ describe('Wall on the Lath engine', () => { it('gives a pane restored mid-launch its creator\'s fallback when the launch fails', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); - Object.assign(fake, { - agentBrowserOpen: vi.fn(async () => ({ ok: false, error: 'agent-browser binary not found' })), - agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), - }); + hostBrowsers({ launch: async () => ({ ok: false, error: 'agent-browser binary not found' }) }); try { // Persisted before either launch answered: a new-tab pane, and a swap // away from the embed. @@ -1091,9 +1158,7 @@ describe('Wall on the Lath engine', () => { const openResult = new Promise<{ ok: boolean; session?: string; wsPort?: number; binaryPath?: string }>((resolve) => { resolveOpen = resolve; }); - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => openResult); - const agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - (fake as PlatformAdapter).agentBrowserCommand = agentBrowserCommand; + const { requests } = hostBrowsers({ launch: () => openResult }); try { await act(async () => { @@ -1126,11 +1191,7 @@ describe('Wall on the Lath engine', () => { ok: true, result: { surfaceId: eagerId, surfaceRef: 'surface:1', session: 'dormouse.1.gui-minimized' }, }); - expect(agentBrowserCommand).not.toHaveBeenCalledWith( - 'dormouse.1.gui-minimized', - ['close'], - '/usr/bin/agent-browser', - ); + expect(requests('close').filter((request) => request.binding.session === 'dormouse.1.gui-minimized')).toEqual([]); } finally { untouchedSpy.mockRestore(); } @@ -1142,7 +1203,7 @@ describe('Wall on the Lath engine', () => { const openResult = new Promise<{ ok: boolean; error?: string }>((resolve) => { resolveOpen = resolve; }); - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => openResult); + hostBrowsers({ launch: () => openResult }); try { await act(async () => { @@ -1157,6 +1218,7 @@ describe('Wall on the Lath engine', () => { await flush(); const eagerLeaf = container.querySelector<HTMLElement>('[data-lath-leaf]')!; const eagerId = eagerLeaf.dataset.lathLeaf!; + expect(eagerId).not.toBe(iframeId); await act(async () => { eagerLeaf.querySelector<HTMLButtonElement>('[aria-label="Minimize"]')!.click(); }); @@ -1184,23 +1246,25 @@ describe('Wall on the Lath engine', () => { const events: string[] = []; let failPlaywright!: (result: { ok: boolean; error?: string }) => void; let landClose!: () => void; - Object.assign(fake, { - agentBrowserAttach: vi.fn(async () => ({ ok: true, wsPort: 4321 })), - agentBrowserCommand: vi.fn(async (session: string, args: string[]) => { - events.push(`${args.join(' ')} ${session}`); - if (args[0] === 'close') { - await new Promise<void>((resolve) => { landClose = resolve; }); - events.push('close landed'); - } - return { exitCode: 0, stdout: '', stderr: '' }; - }), - agentBrowserOpen: vi.fn(async (url: string, opts: { session?: string }) => { - events.push(`open ${opts.session} ${url}`); - return { ok: true, session: opts.session, wsPort: 5555 }; - }), - playwright: vi.fn((request: { op: string }) => (request.op === 'open' - ? new Promise((resolve) => { failPlaywright = resolve; }) - : Promise.resolve({ ok: true }))), + // Every agent-browser command and launch, in order; attaching, the stream + // URL and captures are not what this test watches. + const { browser } = hostBrowsers(); + browser.mockImplementation(async (request: BrowserRequest) => { + if (request.op === 'attach') return { ok: true, wsPort: 4321 }; + if (request.provider === 'playwright') { + return request.op === 'launch' ? new Promise((resolve) => { failPlaywright = resolve; }) : { ok: true }; + } + if (request.op === 'streamUrl' || request.op === 'screenshot') return { ok: true }; + if (request.op === 'launch') { + events.push(`launch ${request.binding.session} ${request.url}`); + return { ok: true, session: request.binding.session, wsPort: 5555 }; + } + events.push(`${request.op} ${request.binding.session}`); + if (request.op === 'close') { + await new Promise<void>((resolve) => { landClose = resolve; }); + events.push('close landed'); + } + return { ok: true }; }); try { await act(async () => { @@ -1234,14 +1298,13 @@ describe('Wall on the Lath engine', () => { await act(async () => { failPlaywright({ ok: false, error: 'playwright-cli was not found' }); }); await flush(); warn.mockRestore(); + // Back to agent-browser in the Door it was minimized to, reopened in the + // session its key names once the swap's close of it has been answered… + expect(container.querySelector(`[data-door-id="${eagerId}"]`)).not.toBeNull(); expect(events).toEqual([`close ${defaultSession}`]); await act(async () => { landClose(); }); await flush(); - - // Back to agent-browser in the Door it was minimized to, reopened in the - // session its key names once that session's close has landed… - expect(container.querySelector(`[data-door-id="${eagerId}"]`)).not.toBeNull(); - expect(events).toEqual([`close ${defaultSession}`, 'close landed', `open ${defaultSession} http://localhost:5173/`]); + expect(events).toEqual([`close ${defaultSession}`, 'close landed', `launch ${defaultSession} http://localhost:5173/`]); expect(await dispatchResolveAgentBrowser(eagerId)).toMatchObject({ ok: true, result: { session: defaultSession } }); // …so `dor ab --key default` drives it rather than opening a second pane. let reused: { ok: boolean; result?: { status: string; surfaceId: string } } | undefined; @@ -1263,9 +1326,7 @@ describe('Wall on the Lath engine', () => { it('restores an eager render swap to iframe when launch rejects', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(async () => { - throw new Error('transport failed'); - }); + hostBrowsers({ launch: async () => { throw new Error('transport failed'); } }); try { await act(async () => { @@ -1298,9 +1359,12 @@ describe('Wall on the Lath engine', () => { // and stranded the pane. const offered = vi.spyOn(browserAutomation, 'offeredRenderModes') .mockReturnValue(['ab-screencast', 'ab-popout', 'pw-screencast', 'pw-popout', 'iframe']); - const playwright = vi.fn(async () => ({ ok: true, session: 'gui-pw', wsPort: 4321 })); - const open = vi.fn(async () => ({ ok: true, session: 'gui-ab', wsPort: 4322 })); - Object.assign(fake, { playwright, agentBrowserOpen: open, agentBrowserPopOut: vi.fn(async () => ({ ok: true })) }); + const { browser, requests } = hostBrowsers({ + launch: async (request) => request.provider === 'playwright' + ? { ok: true, session: 'gui-pw', wsPort: 4321 } + : { ok: true, session: 'gui-ab', wsPort: 4322 }, + }); + const playwrightRequests = () => browser.mock.calls.filter(([request]) => request.provider === 'playwright'); // Serving: a Tool whose command is not running retires its browser. terminalRegistry.applyTerminalSemanticEvents('tool-a', [ { type: 'commandLine', commandLine: 'pnpm storybook' }, @@ -1341,13 +1405,15 @@ describe('Wall on the Lath engine', () => { await act(async () => { controller()?.actions.setRenderMode?.(mode); }); await flush(); } - expect(playwright).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); + expect(playwrightRequests()).toEqual([]); + expect(requests('launch')).toEqual([]); expect(controller()?.snapshot().renderMode).toBe('iframe'); await act(async () => { controller()?.actions.setRenderMode?.('ab-screencast'); }); await flush(); - expect(open).toHaveBeenCalledWith('http://localhost:6006/', { headed: false, session: 'dormouse.1.tool.tool-a' }, undefined); + expect(requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'dormouse.1.tool.tool-a', cwd: '/repo' }, op: 'launch', url: 'http://localhost:6006/', headed: false, + }]); } finally { offered.mockRestore(); act(() => terminalRegistry.removeTerminalPaneState('tool-a')); @@ -1355,7 +1421,7 @@ describe('Wall on the Lath engine', () => { }); it('gives a Tool its embed back when its swap to agent-browser cannot launch', async () => { - Object.assign(fake, { agentBrowserOpen: vi.fn(async () => ({ ok: false, error: 'agent-browser binary not found' })) }); + const { requests } = hostBrowsers({ launch: async () => ({ ok: false, error: 'agent-browser binary not found' }) }); terminalRegistry.applyTerminalSemanticEvents('tool-a', [ { type: 'commandLine', commandLine: 'pnpm storybook' }, { type: 'commandStart' }, @@ -1382,7 +1448,9 @@ describe('Wall on the Lath engine', () => { await flush(); await act(async () => { getAgentBrowserScreenController('tool-a')?.actions.setRenderMode?.('ab-screencast'); }); await flush(); - expect(fake.agentBrowserOpen).toHaveBeenCalledWith('http://localhost:6006/', { headed: false, session: 'dormouse.1.tool.tool-a' }, undefined); + expect(requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'dormouse.1.tool.tool-a', cwd: '/repo' }, op: 'launch', url: 'http://localhost:6006/', headed: false, + }]); expect(getAgentBrowserScreenController('tool-a')?.snapshot().renderMode).toBe('iframe'); } finally { act(() => terminalRegistry.removeTerminalPaneState('tool-a')); @@ -1394,9 +1462,11 @@ describe('Wall on the Lath engine', () => { ['pw-screencast', 'Playwright'], ] as const)('refuses a render swap to %s away from an iframe surface holding a non-http(s) URL', async (mode, provider) => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); - const open = vi.fn(async () => ({ ok: true, session: 'dormouse.1.gui-a1b2c3', wsPort: 4321 })); - const playwright = vi.fn(async () => ({ ok: true, session: 'gui-pw', wsPort: 4322 })); - Object.assign(fake, { agentBrowserOpen: open, playwright }); + const { requests } = hostBrowsers({ + launch: async (request) => request.provider === 'playwright' + ? { ok: true, session: 'gui-pw', wsPort: 4322 } + : { ok: true, session: 'dormouse.1.gui-a1b2c3', wsPort: 4321 }, + }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { @@ -1421,8 +1491,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); - expect(open).not.toHaveBeenCalled(); - expect(playwright).not.toHaveBeenCalled(); + expect(requests('launch')).toEqual([]); expect(getAgentBrowserScreenController(iframeId)?.snapshot().renderMode).toBe('iframe'); // The Display modal closes itself on Apply, so the console is the only // channel this refusal has of its own. @@ -1438,7 +1507,7 @@ describe('Wall on the Lath engine', () => { it('resolves a browser surface handle to its agent-browser session, and gates the rest', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); - (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + hostBrowsers(); try { await act(async () => { @@ -1473,12 +1542,36 @@ describe('Wall on the Lath engine', () => { error: `surface '${iframeRef}' is not agent-browser rendered (render_mode: iframe) — an iframe cannot be driven; open its page with dor ab open http://localhost:5173/`, }); - // A managed `--key` names no Surface, and a bare Wall — VS Code, the - // website — keeps the unscoped session names it always had. - expect(await dispatchResolveAgentBrowserKey('storybook')).toEqual({ - ok: true, - result: { session: sessionForKey('storybook') }, + // A managed `--key` no Surface holds is the key's own session. A bare + // Wall — a VS Code webview, the website — has no Workspace id, so it + // mints a scope of its own: two webviews' `--key default` are two + // browsers, and a Wall mounted afresh is another scope again. + const keyed = await dispatchResolveAgentBrowserKey('storybook') as { ok: boolean; result: { session: string } }; + expect(keyed.result.session).toMatch(/^dormouse\.w[0-9a-f]{8}\.storybook$/); + expect(await dispatchResolveAgentBrowserKey('storybook')).toEqual(keyed); + await act(async () => { + root.render(<Wall key="remounted" initialPaneIds={['pane-a']} initialMode="command" />); }); + await flush(); + const remounted = await dispatchResolveAgentBrowserKey('storybook') as { ok: boolean; result: { session: string } }; + expect(remounted.result.session).toMatch(/^dormouse\.w[0-9a-f]{8}\.storybook$/); + expect(remounted.result.session).not.toBe(keyed.result.session); + + // A key a Surface holds keeps the session it was bound to — a pane saved + // under the old bare-Wall name included — through the merged pair. + await dispatchAgentBrowser({ key: 'app', session: 'dormouse.1.app', surface: 'surface:1' }); + let resolved: unknown; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.resolveBrowser, + params: { provider: 'agent-browser', key: 'app', proposed: { cwd: '/elsewhere' } }, + respond: (r: unknown) => { resolved = r; }, + }, + })); + }); + await flush(); + expect(resolved).toEqual({ ok: true, result: { binding: { session: 'dormouse.1.app' } } }); } finally { untouchedSpy.mockRestore(); } @@ -1498,8 +1591,7 @@ describe('Wall on the Lath engine', () => { toJSON() {}, }) as DOMRect; const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); - const agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - (fake as PlatformAdapter).agentBrowserCommand = agentBrowserCommand; + const { requests } = hostBrowsers(); try { await act(async () => { @@ -1546,11 +1638,9 @@ describe('Wall on the Lath engine', () => { expect(container.querySelector(`[data-lath-leaf="${browserId}"]`)?.hasAttribute('data-lath-parked')).toBe(false); await dispatchKill(browserId); - expect(agentBrowserCommand).toHaveBeenCalledWith( - 'browser-session', - ['close'], - '/new/agent-browser', - ); + expect(requests('close')).toContainEqual({ + provider: 'agent-browser', binding: { session: 'browser-session', binaryPath: '/new/agent-browser' }, op: 'close', + }); } finally { untouchedSpy.mockRestore(); HTMLElement.prototype.getBoundingClientRect = originalRect; @@ -3543,6 +3633,7 @@ describe('Wall on the Lath engine', () => { it('keeps dor agent-browser focus-neutral but enters passthrough for a user port activation', async () => { const defaultSession = sessionForKey('default'); + hostBrowsers(); const onEvent = vi.fn(); const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); @@ -3570,8 +3661,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); - (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(async () => ({ ok: true, session: 'context-browser', wsPort: 4321 })); + const { requests } = hostBrowsers({ launch: async () => ({ ok: true, session: 'context-browser', wsPort: 4321 }) }); if (!fake.hasPty('pane-a')) fake.spawnPty('pane-a'); fake.setOpenPorts('pane-a', [{ protocol: 'tcp', @@ -3610,7 +3700,9 @@ describe('Wall on the Lath engine', () => { expect(onEvent).toHaveBeenCalledWith({ type: 'selectionChange', id: expect.any(String), kind: 'pane' }); expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull(); expect(onEvent).toHaveBeenCalledWith({ type: 'modeChange', mode: 'passthrough' }); - expect((fake as PlatformAdapter).agentBrowserOpen).toHaveBeenCalledWith('http://localhost:5173/', { headed: false }, undefined); + expect(requests('launch')).toContainEqual({ + provider: 'agent-browser', binding: {}, op: 'launch', url: 'http://localhost:5173/', headed: false, + }); } finally { untouchedSpy.mockRestore(); } @@ -4094,10 +4186,7 @@ describe('Wall on the Lath engine', () => { }); it('names the command that drives a browser run by the other provider', async () => { - (fake as PlatformAdapter).playwright = vi.fn(async (request: { op: string }) => ( - request.op === 'attach' ? { ok: true, wsPort: 4555 } : { ok: true } - )); - (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + hostBrowsers({ attach: async (request) => request.provider === 'playwright' ? { ok: true, wsPort: 4555 } : { ok: true } }); const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); try { await act(async () => root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />)); @@ -4166,9 +4255,8 @@ describe('Wall on the Lath engine', () => { it('opens a new https:// tab from an iframe as an agent-browser pane bound to its launch', async () => { const launches: Array<(result: { ok: boolean; session?: string; wsPort?: number; error?: string }) => void> = []; + const { requests } = hostBrowsers({ launch: () => new Promise((resolve) => { launches.push(resolve); }) }); (fake as PlatformAdapter).createIframeProxyUrl = vi.fn(async () => ({ ok: true as const, url: 'http://127.0.0.1:61234/' })); - (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => new Promise((resolve) => { launches.push(resolve); })); - (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); try { await act(async () => root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />)); @@ -4188,7 +4276,9 @@ describe('Wall on the Lath engine', () => { await openTab('https://accounts.example/login'); const [tab] = leafIds().filter((id) => !before.includes(id)); expect(tab).toBeTruthy(); - expect(fake.agentBrowserOpen).toHaveBeenCalledWith('https://accounts.example/login', { headed: false }, undefined); + expect(requests('launch')).toEqual([{ + provider: 'agent-browser', binding: {}, op: 'launch', url: 'https://accounts.example/login', headed: false, + }]); // The pane is there at once; `dor ab --surface` has nothing to drive until the launch names it. expect(await dispatchResolveAgentBrowser(tab)).toMatchObject({ ok: false }); await act(async () => { launches[0]({ ok: true, session: 'dormouse.1.gui-abc', wsPort: 4321 }); }); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 83aba5e0e..33a8a3b5a 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,7 +24,8 @@ const RemotePairingModalHost = lazy(() => })), ); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; -import { automationProvider, browserPlatform, PROVIDER_LABEL } from './wall/browser-automation'; +import { BROWSER_PROVIDER_GUI, hostSupportsBrowser, providerUnavailable } from './wall/browser-automation'; +import { parseRenderMode, type BrowserAutomationProvider } from 'dor-lib-common/browser-providers'; import { isToolRender } from '../lib/platform/tool-types'; import { closeBrowserSurface, requestBrowserRenderMode, whenBrowserLaunched } from './wall/agent-browser-surface-controller'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; @@ -998,6 +999,15 @@ export function Wall({ [lath, memberSurfaceIds, surfaceRefForId], ); + const browserSessions = useCallback( + (provider: BrowserAutomationProvider): string[] => memberSurfaceIds().flatMap((id) => { + const params = lath.getMeta(id)?.params as { renderMode?: unknown; session?: unknown; launchSession?: unknown } | undefined; + if (!params || parseRenderMode(params.renderMode).provider !== provider) return []; + return [params.session, params.launchSession].filter((session): session is string => typeof session === 'string'); + }), + [lath, memberSurfaceIds], + ); + /** Whether a member Surface has a PTY behind it, as against a browser view. */ const surfaceHasTerminal = useCallback( (id: string): boolean => hasTerminal(surfaceKindFromParams(lath.getMeta(id)?.params)), @@ -1772,6 +1782,7 @@ export function Wall({ surfaceIds: memberSurfaceIds, ownsSurface, iframeSurfaceRefs, + browserSessions, hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { // A browser Surface has no "untouched" notion and always holds a page, so // it counts; so does a Tool, before its terminal exists to be asked. A @@ -1982,8 +1993,7 @@ export function Wall({ if (isToolParams(params)) { if (mode === currentRenderMode || !isToolRender(mode)) return; const url = browserUrlFromParams(params); - const platform = getPlatform(); - if (!url || (mode === 'ab-screencast' && !platform.agentBrowserOpen)) return; + if (!url || (mode === 'ab-screencast' && !hostSupportsBrowser('agent-browser'))) return; closeBrowserSurface(id, params); // The Tool's browser launches itself, in the Tool's own session. lath.store.updateParams(id, mode === 'ab-screencast' @@ -2014,8 +2024,8 @@ export function Wall({ // the browser at the URL — headed for a popout, so it mounts already // popped out — and binds the session it answers with // (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). - const provider = automationProvider(mode); - const currentProvider = automationProvider(currentRenderMode); + const provider = parseRenderMode(mode).provider; + const currentProvider = parseRenderMode(currentRenderMode).provider; if (currentRenderMode !== null && provider !== null && provider !== currentProvider) { const chromeUrl = getAgentBrowserScreenController(id)?.chrome().url; const rawUrl = (typeof chromeUrl === 'string' && chromeUrl) @@ -2032,10 +2042,10 @@ export function Wall({ const url = browserSurfaceUrl(rawUrl); if (!url) { const why = rawUrl ? `'${rawUrl}' is not an http(s) URL` : 'no URL observed yet'; - console.warn(`[dormouse] cannot swap surface '${id}' to ${PROVIDER_LABEL[provider]}: ${why}`); + console.warn(`[dormouse] cannot swap surface '${id}' to ${BROWSER_PROVIDER_GUI[provider].label}: ${why}`); return; } - if (!browserPlatform(provider, cwd).agentBrowserOpen) return; + if (!hostSupportsBrowser(provider)) return; // A browser that cannot come up gives the previous renderer back in // place, even minimized meanwhile, at the URL: the embed, or the // previous provider reopened in its own session, so its key and handle @@ -2061,7 +2071,7 @@ export function Wall({ // or an agent-browser pane for a page the iframe would refuse. const reference = buildDorSurfaces().find((s) => s.id === id); if (!reference) return; - const agentBrowser = !!iframeRefusal(url) && !!getPlatform().agentBrowserOpen; + const agentBrowser = !!iframeRefusal(url) && hostSupportsBrowser('agent-browser'); createContentSurface({ minimized: false, // A launch that fails takes its pane with it. @@ -2101,8 +2111,7 @@ export function Wall({ if (mode === 'system') { getPlatform().openExternal?.(entry.url); return; } const cwd = getTerminalPaneState(id)?.cwd?.path; // Null for the iframe embed, which launches no browser. - const provider = automationProvider(mode); - const platform = provider ? browserPlatform(provider, cwd) : null; + const provider = parseRenderMode(mode).provider; // Persisted as `contextPortKey`: agent-browser keeps the `agent` it had // before Playwright, so a restored pane is still found and revealed. const key = `${id}:${entry.port}:${provider === 'agent-browser' ? 'agent' : provider ?? 'iframe'}`; @@ -2121,7 +2130,7 @@ export function Wall({ else updateSurfaceParams(existing.id, { url: entry.url }); return; } - if (provider && !platform?.agentBrowserOpen) throw new Error(`${PROVIDER_LABEL[provider]} is unavailable on this host`); + if (provider && !hostSupportsBrowser(provider)) throw new Error(providerUnavailable(provider)); const created = createContentSurface({ minimized: false, reference, preserveSource: true, params: { surfaceType: 'browser', renderMode: mode, url: entry.url, cwd, syncEngaged: true, contextPortKey: key, diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index fde24393e..676b6190b 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -560,8 +560,8 @@ describe('WorkspaceWindow', () => { // The Playwright arm asks the host for the viewer before it creates // anything, so the guard above is not the last word. const status = Promise.withResolvers<{ ok: boolean; wsPort: number; headed: boolean }>(); - const playwright = vi.fn(() => status.promise); - Object.assign(fake, { playwright }); + const browser = vi.fn(() => status.promise); + Object.assign(fake, { browserProviders: ['agent-browser', 'playwright'], browser }); await render(); await act(async () => { createWorkspace({ id: 'ws-2' }); }); await flush(); @@ -579,7 +579,7 @@ describe('WorkspaceWindow', () => { }); }); await flush(); - expect(playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'attach', session: 'late' })); + expect(browser).toHaveBeenCalledWith(expect.objectContaining({ provider: 'playwright', op: 'attach', binding: expect.objectContaining({ session: 'late' }) })); await act(async () => { expect(await handle.closeAll('silent')).toBeNull(); }); await act(async () => status.resolve({ ok: true, wsPort: 4321, headed: false })); await flush(); diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index 603515449..c19e3ead7 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -5,7 +5,6 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; -import type { AgentBrowserPopResult, AgentBrowserAttachResult, PlatformAdapter } from '../../lib/platform/types'; import type { PaneProps } from './pane-props'; import { AgentBrowserPanel, HIDDEN_PARK_DELAY_MS } from './AgentBrowserPanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; @@ -18,7 +17,7 @@ import { } from './agent-browser-surface-controller'; import type { RenderMode } from './agent-browser-screen'; import { ModeContext, PaneWriteContext, SelectedIdContext, WallActionsContext, WorkspaceActiveContext, type PaneWriteActions } from './wall-context'; -import { stubWallActions as stubActions } from './wall-test-utils'; +import { installBrowserHost, stubWallActions as stubActions, type BrowserAnswers } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -34,6 +33,11 @@ type TestPanelParams = { const DEFAULT_PARAMS: TestPanelParams = { surfaceType: 'agent-browser', session: 'browser-session' }; +// The Playwright host serves its own viewer, so it names the stream URL. +const PLAYWRIGHT_STREAM: BrowserAnswers = { streamUrl: async ({ port }) => ({ ok: true, url: `ws://127.0.0.1:${port}` }) }; +/** The operations that drive a live browser, rather than bind or view one. */ +const DRIVES = new Set(['navigate', 'history', 'tab', 'viewport', 'device', 'cdpUrl', 'close']); + class ResizeObserverMock { observe() {} unobserve() {} @@ -162,19 +166,10 @@ describe('AgentBrowserPanel placeholders', () => { describe('AgentBrowserPanel render mode controller', () => { it('relaunches screencast sessions as popout and publishes the mode immediately', async () => { const updateParameters = vi.fn(); - const popOut = vi.fn<PlatformAdapter['agentBrowserPopOut']>(async (): Promise<AgentBrowserPopResult> => ({ - ok: true, - wsPort: 3456, - })); - const attach = vi.fn<PlatformAdapter['agentBrowserAttach']>(async (): Promise<AgentBrowserAttachResult> => ({ - ok: true, - wsPort: 1234, - })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopOut' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserPopOut = popOut; - platform.agentBrowserAttach = attach; - setPlatform(platform); + const host = installBrowserHost({ + launch: async () => ({ ok: true, wsPort: 3456 }), + attach: async () => ({ ok: true, wsPort: 1234 }), + }); await renderPanel(paneProps('ab-panel'), updateParameters); @@ -182,7 +177,7 @@ describe('AgentBrowserPanel render mode controller', () => { getAgentBrowserScreenController('ab-panel')?.actions.setRenderMode?.('ab-popout'); }); - expect(popOut).toHaveBeenCalledWith('browser-session', expect.objectContaining({ url: undefined }), undefined); + expect(host.requests('launch')).toEqual([{ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'launch', headed: true }]); expect(updateParameters).toHaveBeenCalledWith({ renderMode: 'ab-popout' }); expect(getAgentBrowserScreenController('ab-panel')?.snapshot().renderMode).toBe('ab-popout'); expect(container.textContent).toContain('This browser is running in a separate window.'); @@ -191,15 +186,10 @@ describe('AgentBrowserPanel render mode controller', () => { it('relaunches popped-out sessions back into screencast', async () => { const updateParameters = vi.fn(); - const popIn = vi.fn<PlatformAdapter['agentBrowserPopIn']>(async (): Promise<AgentBrowserPopResult> => ({ - ok: true, - wsPort: 4567, - })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopIn' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserPopIn = popIn; - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); - setPlatform(platform); + const host = installBrowserHost({ + launch: async () => ({ ok: true, wsPort: 4567 }), + attach: async () => ({ ok: true, wsPort: 1234 }), + }); await renderPanel( paneProps('ab-panel', { surfaceType: 'browser', renderMode: 'ab-popout', session: 'browser-session' }), @@ -212,22 +202,17 @@ describe('AgentBrowserPanel render mode controller', () => { getAgentBrowserScreenController('ab-panel')?.actions.setRenderMode?.('ab-screencast'); }); - expect(popIn).toHaveBeenCalledWith('browser-session', expect.objectContaining({ url: undefined }), undefined); + expect(host.requests('launch')).toEqual([{ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'launch', headed: false }]); expect(updateParameters).toHaveBeenCalledWith({ renderMode: 'ab-screencast' }); expect(getAgentBrowserScreenController('ab-panel')?.snapshot().renderMode).toBe('ab-screencast'); }); it('pop-in uses the latest observed headed-window tab URL over stale params', async () => { const updateParameters = vi.fn(); - const popIn = vi.fn<PlatformAdapter['agentBrowserPopIn']>(async (): Promise<AgentBrowserPopResult> => ({ - ok: true, - wsPort: 4567, - })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopIn' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserPopIn = popIn; - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); - setPlatform(platform); + const host = installBrowserHost({ + launch: async () => ({ ok: true, wsPort: 4567 }), + attach: async () => ({ ok: true, wsPort: 1234 }), + }); await renderPanel( paneProps('ab-panel', { @@ -253,15 +238,14 @@ describe('AgentBrowserPanel render mode controller', () => { getAgentBrowserScreenController('ab-panel')?.actions.setRenderMode?.('ab-screencast'); }); - expect(popIn).toHaveBeenCalledWith('browser-session', expect.objectContaining({ url: 'https://example.com/' }), undefined); + expect(host.requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'launch', url: 'https://example.com/', headed: false, + }]); }); it('mirrors popped-out stream tab URL updates when the stream reports id instead of tabId', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); - setPlatform(platform); + installBrowserHost({ attach: async () => ({ ok: true, wsPort: 1234 }) }); await renderPanel( paneProps('ab-panel', { @@ -286,13 +270,10 @@ describe('AgentBrowserPanel render mode controller', () => { it('mirrors popped-out manual navigation from CDP target events', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = vi.fn(async (_session, args) => { - if (args.join(' ') === 'get cdp-url') return { exitCode: 0, stdout: 'ws://127.0.0.1:9222/devtools/browser/test', stderr: '' }; - return { exitCode: 0, stdout: '', stderr: '' }; + const host = installBrowserHost({ + cdpUrl: async () => ({ ok: true, url: 'ws://127.0.0.1:9222/devtools/browser/test' }), + attach: async () => ({ ok: true, wsPort: 1234 }), }); - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); - setPlatform(platform); await renderPanel( paneProps('ab-panel', { @@ -320,14 +301,12 @@ describe('AgentBrowserPanel render mode controller', () => { })); }); - expect(platform.agentBrowserCommand).toHaveBeenCalledWith('browser-session', ['get', 'cdp-url'], undefined); + expect(host.requests('cdpUrl')).toEqual([{ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'cdpUrl' }]); expect(updateParameters).toHaveBeenCalledWith({ url: 'https://example.com/' }); }); it('actively selects a newly opened tab when the stream does not mark it active', async () => { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); + const host = installBrowserHost(); await renderPanel(paneProps('ab-panel', { surfaceType: 'browser', session: 'browser-session', wsPort: 1111 })); @@ -348,13 +327,11 @@ describe('AgentBrowserPanel render mode controller', () => { })); }); - expect(platform.agentBrowserCommand).toHaveBeenCalledWith('browser-session', ['tab', 't2'], undefined); + expect(host.requests('tab')).toContainEqual({ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'tab', action: 'select', tabId: 't2' }); }); it('does not force-select a provisional new tab that already reports active', async () => { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); + const host = installBrowserHost(); await renderPanel(paneProps('ab-panel', { surfaceType: 'browser', session: 'browser-session', wsPort: 1111 })); @@ -375,13 +352,11 @@ describe('AgentBrowserPanel render mode controller', () => { })); }); - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); + expect(host.browser.mock.calls.map(([request]) => request.op).filter((op) => DRIVES.has(op))).toEqual([]); }); it('selects a provisional new tab after it reaches its destination if it is not active', async () => { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); + const host = installBrowserHost(); await renderPanel(paneProps('ab-panel', { surfaceType: 'browser', session: 'browser-session', wsPort: 1111 })); @@ -412,7 +387,7 @@ describe('AgentBrowserPanel render mode controller', () => { })); }); - expect(platform.agentBrowserCommand).toHaveBeenCalledWith('browser-session', ['tab', 't2'], undefined); + expect(host.requests('tab')).toContainEqual({ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'tab', action: 'select', tabId: 't2' }); }); it('keeps the last known active tab when the stream emits a transient empty tab list', async () => { @@ -468,23 +443,19 @@ describe('AgentBrowserPanel Playwright params', () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue( { width: 800, height: 600, left: 0, top: 0, right: 800, bottom: 600, x: 0, y: 0, toJSON() {} } as DOMRect, ); - const playwright = vi.fn<NonNullable<PlatformAdapter['playwright']>>(async (request) => request.op === 'streamUrl' - ? { ok: true, url: `ws://127.0.0.1:${request.port}` } - : { ok: true, exitCode: 0, stdout: '', stderr: '' }); - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = playwright; - setPlatform(platform); - const commands = (cwd?: string) => playwright.mock.calls + const host = installBrowserHost(PLAYWRIGHT_STREAM); + // Every operation that drives the browser, in the directory it ran for. + const commands = (cwd?: string) => host.browser.mock.calls .map(([request]) => request) - .filter((request) => request.op === 'command' && (cwd === undefined || request.cwd === cwd)) - .map((request) => (request as { args: string[] }).args.join(' ')); + .filter((request) => DRIVES.has(request.op) && (cwd === undefined || request.binding.cwd === cwd)) + .map((request) => request.op === 'viewport' ? `set viewport ${request.width} ${request.height} ${request.dpr}` : request.op); const params = { surfaceType: 'browser', renderMode: 'pw-screencast', session: 'app', cwd: '/first', wsPort: 4321 }; await renderPanel(paneProps('pw-panel', params)); expect(commands()).toContain('set viewport 800 600 1'); const stream = (port: number) => WebSocketMock.instances.findLast((ws) => ws.url === `ws://127.0.0.1:${port}`)!; await act(async () => { stream(4321).emitMessage(JSON.stringify({ type: 'status', connected: true, screencasting: true })); }); - playwright.mockClear(); + host.browser.mockClear(); await renderPanel(paneProps('pw-panel', { ...params, renderMode: 'pw-popout', wsPort: 4322 })); expect(getAgentBrowserScreenController('pw-panel')?.snapshot().renderMode).toBe('pw-popout'); @@ -493,7 +464,7 @@ describe('AgentBrowserPanel Playwright params', () => { // The old browser's status is not the new window's: a disconnect before // the new stream reports is not the user closing that window. await act(async () => { stream(4322).emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); }); - expect(playwright.mock.calls.map(([request]) => request.op)).not.toContain('popIn'); + expect(host.requests('launch')).toEqual([]); await renderPanel(paneProps('pw-panel', { ...params, cwd: '/second', wsPort: 4323 })); expect(getAgentBrowserScreenController('pw-panel')?.snapshot().renderMode).toBe('pw-screencast'); @@ -501,12 +472,7 @@ describe('AgentBrowserPanel Playwright params', () => { }); it('keeps its own mode write over params that predate it', async () => { - const playwright = vi.fn<NonNullable<PlatformAdapter['playwright']>>(async (request) => request.op === 'streamUrl' - ? { ok: true, url: `ws://127.0.0.1:${request.port}` } - : { ok: true, wsPort: 4330 }); - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = playwright; - setPlatform(platform); + const host = installBrowserHost({ ...PLAYWRIGHT_STREAM, launch: async () => ({ ok: true, wsPort: 4330 }) }); const updateParameters = vi.fn(); const popped = { surfaceType: 'browser', renderMode: 'pw-popout', session: 'app', cwd: '/p', wsPort: 4321 }; await renderPanel(paneProps('pw-panel', popped), updateParameters); @@ -517,7 +483,9 @@ describe('AgentBrowserPanel Playwright params', () => { // back in and buffers its `renderMode` write until the next attach. await act(async () => { root.render(<div />); }); await act(async () => { stream.emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); }); - expect(playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'popIn', session: 'app' })); + expect(host.requests('launch')).toEqual([expect.objectContaining({ + provider: 'playwright', binding: expect.objectContaining({ session: 'app' }), op: 'launch', headed: false, + })]); expect(updateParameters).not.toHaveBeenCalledWith(expect.objectContaining({ renderMode: 'pw-screencast' })); // Reattached with the params the store still held: they predate the write. @@ -536,12 +504,7 @@ describe('AgentBrowserPanel across a provider change', () => { // A minimized pane keeps this view mounted while its Wall restores a failed // cross-provider swap in place: same id, the other provider's params. function withBothProviders() { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.playwright = vi.fn(async (request) => request.op === 'streamUrl' - ? { ok: true, url: `ws://127.0.0.1:${request.port}` } - : { ok: true }); - setPlatform(platform); + installBrowserHost(PLAYWRIGHT_STREAM); } const pw = { surfaceType: 'browser', renderMode: 'pw-screencast', session: 'failed-swap', wsPort: 4400 }; const ab = { surfaceType: 'browser', renderMode: 'ab-screencast', session: 'relaunched', wsPort: 4401 }; @@ -578,11 +541,7 @@ describe('AgentBrowserPanel across a provider change', () => { describe('AgentBrowserPanel after its controller is released', () => { it('takes a fresh controller for the params that follow, and none for the release alone', async () => { - const attach = vi.fn<PlatformAdapter['agentBrowserAttach']>(async () => ({ ok: true, wsPort: 4402 })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserAttach'>; - platform.agentBrowserAttach = attach; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); + const host = installBrowserHost({ attach: async () => ({ ok: true, wsPort: 4402 }) }); const first = { surfaceType: 'browser', renderMode: 'ab-screencast', session: 'first-run', wsPort: 4400 }; await renderPanel(paneProps('released-panel', first)); const released = getAgentBrowserSurfaceController('released-panel'); @@ -597,7 +556,7 @@ describe('AgentBrowserPanel after its controller is released', () => { const next = getAgentBrowserSurfaceController('released-panel'); expect(next).not.toBeNull(); expect(next).not.toBe(released); - expect(attach).toHaveBeenCalledWith('next-run', expect.anything(), undefined); + expect(host.requests('attach')).toContainEqual(expect.objectContaining({ binding: { session: 'next-run' } })); }); }); @@ -719,9 +678,7 @@ describe('AgentBrowserPanel visibility parking', () => { it('reconnects and repaints from the stream when it becomes visible again', async () => { const screenshot = vi.fn(async () => ({ ok: false as const, error: 'test' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); + installBrowserHost({ screenshot }); const { setVisible } = await renderVisibilityPanel({ surfaceType: 'browser', session: 'browser-session', wsPort: 4321, @@ -892,11 +849,8 @@ describe('AgentBrowserPanel tab strip actions', () => { // mid-press; under Lath the leaf div is never re-parented, so the node stays put // and the click survives. jsdom doesn't move the DOM, so a dispatched click here // just exercises the onClick → selectTab/closeTab wiring. - async function renderWithTwoTabs(): Promise<ReturnType<typeof vi.fn>> { - const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - platform.agentBrowserCommand = command; - setPlatform(platform); + async function renderWithTwoTabs(): Promise<ReturnType<typeof installBrowserHost>> { + const host = installBrowserHost(); const props = paneProps('ab-panel', { surfaceType: 'agent-browser', session: 'browser-session', wsPort: 4321 }); await renderPanel(props); const ws = WebSocketMock.instances[WebSocketMock.instances.length - 1]; @@ -906,29 +860,29 @@ describe('AgentBrowserPanel tab strip actions', () => { { tabId: 't2', title: 'GitHub', url: 'https://github.com/diffplug/dormouse', active: false }, ] })); }); - return command; + return host; } const chipFor = (url: string) => [...container.querySelectorAll('div[title]')] .find((e) => e.getAttribute('title') === url && (e.className || '').includes('cursor-pointer')) as HTMLElement; it('switches to an inactive tab on chip click', async () => { - const command = await renderWithTwoTabs(); + const host = await renderWithTwoTabs(); const chip = chipFor('https://github.com/diffplug/dormouse'); await act(async () => { chip.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); }); - expect(command).toHaveBeenCalledWith('browser-session', ['tab', 't2'], undefined); + expect(host.requests('tab')).toContainEqual({ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'tab', action: 'select', tabId: 't2' }); }); it('closes a tab on the × button click', async () => { - const command = await renderWithTwoTabs(); + const host = await renderWithTwoTabs(); const closeBtn = chipFor('https://github.com/diffplug/dormouse') .querySelector('button[aria-label="Close tab"]') as HTMLButtonElement; await act(async () => { closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); }); - expect(command).toHaveBeenCalledWith('browser-session', ['tab', 'close', 't2'], undefined); + expect(host.requests('tab')).toContainEqual({ provider: 'agent-browser', binding: { session: 'browser-session' }, op: 'tab', action: 'close', tabId: 't2' }); }); it('captures a fresh frame when the active tab changes, but not on other tab edits', async () => { @@ -936,9 +890,7 @@ describe('AgentBrowserPanel tab strip actions', () => { // is otherwise silent, so the panel forces one device screenshot so the canvas // follows the newly-active tab. const screenshot = vi.fn(async () => ({ ok: false as const, error: 'test' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); + installBrowserHost({ screenshot }); const props = paneProps('ab-panel', { surfaceType: 'agent-browser', session: 'browser-session', wsPort: 4321 }); await renderPanel(props); const ws = WebSocketMock.instances[WebSocketMock.instances.length - 1]; @@ -975,13 +927,7 @@ describe('the render modes a tool is offered (regression: PR #493 review)', () = // `agent-browser-surface-controller.ts`. The host can do everything, so a // refusal is the tool rule and not a missing capability. function withCapableHost() { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserOpen' | 'agentBrowserPopOut' | 'playwright'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserOpen = vi.fn(async () => ({ ok: true })); - platform.agentBrowserPopOut = vi.fn(async (): Promise<AgentBrowserPopResult> => ({ ok: true, wsPort: 1 })); - platform.playwright = vi.fn(async () => ({ ok: true })); - setPlatform(platform); - return platform; + return installBrowserHost({ launch: async () => ({ ok: true, wsPort: 1 }) }); } it('offers every mode on a plain browser surface', async () => { @@ -992,7 +938,7 @@ describe('the render modes a tool is offered (regression: PR #493 review)', () = }); it('offers a tool only its declarable renders, and refuses the rest', async () => { - const platform = withCapableHost(); + const host = withCapableHost(); const onSwapRenderMode = vi.fn(); await act(async () => { root.render( @@ -1009,7 +955,7 @@ describe('the render modes a tool is offered (regression: PR #493 review)', () = // The popout relaunches in-controller, never reaching the Wall's guard. await act(async () => { controller.actions.setRenderMode?.('ab-popout'); }); await act(async () => { controller.actions.setRenderMode?.('pw-screencast'); }); - expect(platform.agentBrowserPopOut).not.toHaveBeenCalled(); + expect(host.requests('launch')).toEqual([]); expect(onSwapRenderMode).not.toHaveBeenCalled(); await act(async () => { controller.actions.setRenderMode?.('iframe'); }); diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 71f96927c..ea6a57158 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -7,7 +7,7 @@ import { isEditableTarget } from '../../lib/dom'; import type { RenderMode } from './agent-browser-screen'; import { tabDisplayTitle } from './browser-url'; import { resolveRenderMode } from './browser-surface'; -import { automationCli, surfaceProvider } from './browser-automation'; +import { BROWSER_PROVIDER_GUI, surfaceProvider } from './browser-automation'; import { MOUSE_BUTTONS, MOUSE_BUTTON_MASKS, modifiers } from './agent-browser-input'; import { acquireAgentBrowserSurfaceController, @@ -55,7 +55,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // back to resolving it from params for a direct mount (tests) / legacy blob. const seededMode = renderModeProp ?? resolveRenderMode(params); const provider = surfaceProvider(seededMode); - const cli = automationCli(provider); + const cli = BROWSER_PROVIDER_GUI[provider].cli; // The surface-scoped controller: get-or-create, keyed by surface id. Survives // this component's unmount (minimize, layout churn, StrictMode). Keyed by @@ -98,8 +98,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // Feed later param changes into the controller (diffed internally). The // renderMode it gets back is mostly its own popOut/popIn write; it follows - // one only for Playwright, whose native `open` can change headedness outside - // Dormouse (`followParamsHeadedness`). + // one the host reported for a native launch (`followParamsHeadedness`). useEffect(() => { if (controller.released) setGeneration((current) => current + 1); else controller.updateParams({ session, launchSession, binaryPath, url, syncEngaged, key, cwd, renderMode: seededMode }); diff --git a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx index 8312ade9f..36b732ac1 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx @@ -8,7 +8,7 @@ import { FakePtyAdapter, setPlatform } from '../../lib/platform'; import type { PlatformAdapter } from '../../lib/platform/types'; import { AgentBrowserScreenModal } from './AgentBrowserScreenModal'; import { getAgentBrowserScreenController } from './agent-browser-screen'; -import { registerStubScreen, STUB_SCREEN } from './wall-test-utils'; +import { installBrowserHost, registerStubScreen, STUB_SCREEN } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -69,9 +69,7 @@ describe('AgentBrowserScreenModal', () => { }); it('offers Playwright with its own device registry and dispatches the selected provider', () => { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = async () => ({ ok: true }); - setPlatform(platform); + installBrowserHost(); const registration = registerStubScreen('playwright', { snapshot: { ...STUB_SCREEN, renderMode: 'pw-screencast' } }); const controller = getAgentBrowserScreenController('playwright')!; act(() => root.render(<AgentBrowserScreenModal controller={controller} label="surface:4" onClose={() => {}} />)); @@ -87,10 +85,7 @@ describe('AgentBrowserScreenModal', () => { it('offers only the render modes the controller declares, whatever the host supports', () => { // A tool on a host with every provider: Playwright and popout would strand it. - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = async () => ({ ok: true }); - platform.agentBrowserPopOut = async () => ({ ok: true }); - setPlatform(platform); + installBrowserHost(); const registration = registerStubScreen('tool', { snapshot: { ...STUB_SCREEN, renderMode: 'ab-screencast' }, renderModes: ['ab-screencast', 'iframe'], diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index 63c4b2e70..9373f7eb2 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -29,7 +29,8 @@ import { } from '../design'; import type { RenderMode, ScreenController, ScreenSnapshot } from './agent-browser-screen'; import { browserDisplayMode, useAgentBrowserChromeSnapshot, useAgentBrowserScreenSnapshot } from './agent-browser-screen'; -import { AUTOMATION_PROVIDERS, automationMode, automationProvider, isScreencast, PROVIDER_LABEL } from './browser-automation'; +import { BROWSER_PROVIDER_IDS, parseRenderMode, renderModeFor } from 'dor-lib-common/browser-providers'; +import { BROWSER_PROVIDER_GUI, surfaceProvider } from './browser-automation'; import { iframeRefusal } from './browser-url'; import { AgentRobotIcon, @@ -38,21 +39,6 @@ import { BrowserPresentationIcon, } from './BrowserDisplayIcon'; -// Fixed registry — the CLI's own device set. No custom descriptors; touch + -// mobile UA come only bundled inside `set device` (verified against 0.27.0). -const DEVICES = [ - 'iPhone 15', - 'iPhone 16', - 'iPhone 16 Pro', - 'iPhone 17', - 'iPad', - 'iPad Pro', - 'Pixel 9', - 'Galaxy S25', -] as const; - -const PLAYWRIGHT_DEVICES = ['iPhone 15', 'iPhone 16', 'iPhone 16 Pro', 'iPhone 17', 'iPad (gen 11)', 'iPad Pro 11', 'Pixel 9', 'Galaxy S24']; - type Target = 'sync' | 'device' | 'custom'; export function AgentBrowserScreenModal({ @@ -80,7 +66,7 @@ export function AgentBrowserScreenModal({ // A fixed device can't be pre-matched — the CLI exposes no dims map. const initialTarget: Target = initial?.syncEngaged ? 'sync' : 'custom'; const [target, setTarget] = useState<Target>(initialTarget); - const [device, setDevice] = useState<string>(DEVICES[1]); // iPhone 16 + const [device, setDevice] = useState<string>('iPhone 16'); const [customW, setCustomW] = useState(String(initial?.viewport.w ?? 1280)); const [customH, setCustomH] = useState(String(initial?.viewport.h ?? 720)); const [customDpi, setCustomDpi] = useState(String(initial?.viewport.dpr ?? 1)); @@ -97,7 +83,10 @@ export function AgentBrowserScreenModal({ const embedRefusal = currentMode === 'iframe' ? null : iframeRefusal(chrome?.url ?? ''); // Only the screencast backend has a Dormouse-settable viewport; pop-out is a // native OS window and embed renders at the pane size, so both grey it out. - const viewportDisabled = !isScreencast(renderMode); + const selected = parseRenderMode(renderMode); + const viewportDisabled = selected.presentation !== 'screencast'; + // Each screencast's device presets are its own provider's. + const devices = BROWSER_PROVIDER_GUI[surfaceProvider(renderMode)].devices; // Whether Apply changes the render backend (vs only tweaking the current // screencast's viewport). A swap is gated on whether its option is shown, not // on the viewport-drive capability below. @@ -135,7 +124,7 @@ export function AgentBrowserScreenModal({ // A mode swap; the viewport sub-controls don't apply to the outgoing // surface (and are inert on embed/popout controllers anyway). controller.actions.setRenderMode?.(renderMode); - } else if (isScreencast(renderMode)) { + } else if (!viewportDisabled) { if (target === 'sync') controller.actions.engageSync(); else if (target === 'device') controller.actions.applyDevice(device); else controller.actions.applyViewport(Number(customW), Number(customH), Number(customDpi)); @@ -195,7 +184,7 @@ export function AgentBrowserScreenModal({ className="rounded border border-border bg-app-bg px-1.5 py-1 font-mono text-foreground outline-none focus:border-focus-ring" > <option value="">none</option> - {(automationProvider(renderMode) === 'playwright' ? PLAYWRIGHT_DEVICES : DEVICES).map((name) => ( + {devices.map((name) => ( <option key={name} value={name}>{name}</option> ))} </select> @@ -230,9 +219,9 @@ export function AgentBrowserScreenModal({ <div className="mt-4 flex flex-col gap-3"> {/* Screencast owns the robot capability glyph; its nested resolution modes append the presentation glyph. */} - {AUTOMATION_PROVIDERS.map((provider) => { - const screencast = automationMode(provider, false); - const popout = automationMode(provider, true); + {BROWSER_PROVIDER_IDS.map((provider) => { + const screencast = renderModeFor(provider, 'screencast'); + const popout = renderModeFor(provider, 'popout'); if (!offered(screencast) && !offered(popout)) return null; const popoutDisplay = browserDisplayMode({ renderMode: popout, syncEngaged: false }); return ( @@ -242,7 +231,7 @@ export function AgentBrowserScreenModal({ checked={renderMode === screencast} onSelect={() => setRenderMode(screencast)} icon={<AgentRobotIcon size={14} className="shrink-0 text-muted" />} - label={`${PROVIDER_LABEL[provider]} screencast`} + label={`${BROWSER_PROVIDER_GUI[provider].label} screencast`} features={[[true, 'agents can read/write'], [true, 'any URL'], [false, 'laggy for humans']]} > {renderMode === screencast && <div className="ml-6 mt-2">{viewportControls}</div>} @@ -278,7 +267,7 @@ export function AgentBrowserScreenModal({ {!hostCapable && !viewportDisabled && !switchingMode && ( <p className="mt-3 text-xs text-muted"> - This host can't drive the browser viewport; run <span className="font-mono">{automationProvider(currentMode) === 'playwright' ? 'dor pw resize …' : 'dor ab set …'}</span> from a + This host can't drive the browser viewport; run <span className="font-mono">{BROWSER_PROVIDER_GUI[surfaceProvider(currentMode)].viewportHint}</span> from a terminal instead. </p> )} diff --git a/lib/src/components/wall/BrowserDisplayIcon.tsx b/lib/src/components/wall/BrowserDisplayIcon.tsx index 16f8799db..73e4f3809 100644 --- a/lib/src/components/wall/BrowserDisplayIcon.tsx +++ b/lib/src/components/wall/BrowserDisplayIcon.tsx @@ -5,30 +5,26 @@ import { type Icon, PictureInPictureIcon, } from '@phosphor-icons/react'; -import type { BrowserDisplayMode } from './agent-browser-screen'; +import { BROWSER_PROVIDER_IDS, BROWSER_PROVIDERS } from 'dor-lib-common/browser-providers'; +import { BROWSER_VIEWS, displayModeFor, displayView, type BrowserDisplayMode, type BrowserView } from './agent-browser-screen'; -export const BROWSER_DISPLAY_LABEL: Record<BrowserDisplayMode, string> = { - 'pw-resize': 'Playwright resizes with pane', - 'pw-fixed': 'Playwright fixed size', - 'pw-popout': 'Playwright popout', - 'ab-resize': 'agent-browser resizes with pane', - 'ab-fixed': 'agent-browser fixed size', - 'ab-popout': 'agent-browser popout', - iframe: 'iframe embed', -}; - -/** How the human view is presented, keyed like `BROWSER_DISPLAY_LABEL` so a new - * mode is a compile error in both rather than a silent fall-through. */ -const PRESENTATION_ICON: Record<BrowserDisplayMode, Icon> = { - 'pw-resize': FrameCornersIcon, - 'pw-fixed': PictureInPictureIcon, - 'pw-popout': ArrowSquareOutIcon, - 'ab-resize': FrameCornersIcon, - 'ab-fixed': PictureInPictureIcon, - 'ab-popout': ArrowSquareOutIcon, +const VIEW_LABEL: Record<BrowserView, string> = { resize: 'resizes with pane', fixed: 'fixed size', popout: 'popout' }; +/** How the human view is presented: one glyph per view, the embed framed like + * a pane-sized screencast. */ +const VIEW_ICON: Record<BrowserView | 'iframe', Icon> = { + resize: FrameCornersIcon, + fixed: PictureInPictureIcon, + popout: ArrowSquareOutIcon, iframe: FrameCornersIcon, }; +/** Every display mode's label: `<provider> <view>`, and the embed. */ +export const BROWSER_DISPLAY_LABEL = Object.fromEntries([ + ...BROWSER_PROVIDER_IDS.flatMap((provider) => BROWSER_VIEWS.map((view) => + [displayModeFor(provider, view), `${BROWSER_PROVIDERS[provider].label} ${VIEW_LABEL[view]}`])), + ['iframe', 'iframe embed'], +]) as Record<BrowserDisplayMode, string>; + /** Compact custom robot whose wide silhouette survives the 12–14px chrome. */ export function AgentRobotIcon({ size, @@ -68,7 +64,7 @@ export function BrowserPresentationIcon({ size: number; className?: string; }) { - const Glyph = PRESENTATION_ICON[mode]; + const Glyph = VIEW_ICON[displayView(mode)]; return <Glyph size={size} className={className} />; } diff --git a/lib/src/components/wall/BrowserPanel.tsx b/lib/src/components/wall/BrowserPanel.tsx index 36b3f59f2..9658627ac 100644 --- a/lib/src/components/wall/BrowserPanel.tsx +++ b/lib/src/components/wall/BrowserPanel.tsx @@ -26,7 +26,7 @@ export type BrowserPanelParams = { cwd?: string; nativeIdentity?: string; /** Bound once the browser is up; absent while the Surface's controller - * launches it (docs/specs/dor-browser.md → "Agent-Browser Connection"). */ + * launches it (docs/specs/dor-browser.md → "Browser Connection"). */ session?: string; /** With no `session`, the one the launch opens `url` in: a Tool's own, or * the previous provider's when a failed swap restores it. */ diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index b2965fe96..12fa5ac60 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -10,7 +10,7 @@ import type { PaneProps } from './pane-props'; import { IframePanel } from './IframePanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; import { PaneWriteContext, WallActionsContext, type PaneWriteActions, type WallActions } from './wall-context'; -import { stubWallActions as stubActions } from './wall-test-utils'; +import { installBrowserHost, stubWallActions as stubActions } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -204,9 +204,7 @@ describe('IframePanel', () => { it('drives iframe back and forward from the registered chrome actions', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen'>; - platform.agentBrowserOpen = vi.fn(); - setPlatform(platform); + installBrowserHost(); await renderPanel(stubActions(), paneProps('iframe-history'), updateParameters); await act(async () => { @@ -228,14 +226,12 @@ describe('IframePanel', () => { it('maps proxied frame location messages into chrome without updating params', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'createIframeProxyUrl'>; - platform.agentBrowserOpen = vi.fn(); + const { platform } = installBrowserHost(); platform.createIframeProxyUrl = vi.fn(async () => ({ - ok: true, + ok: true as const, url: 'http://127.0.0.1:61234/app', upstream: 'http://example.test/app', })); - setPlatform(platform); await renderPanel(stubActions(), paneProps('iframe-proxied'), updateParameters); await act(async () => { @@ -251,13 +247,11 @@ describe('IframePanel', () => { it('re-resolves the proxy on Back after an observed in-frame navigation', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'createIframeProxyUrl'>; - platform.agentBrowserOpen = vi.fn(); + const { platform } = installBrowserHost(); // Fixed URL so the proxy origin stays stable (the message handler gates on // it); re-resolution is observed via the call count, not a changed src. - const createProxy = vi.fn(async () => ({ ok: true, url: 'http://127.0.0.1:61234/app' })); + const createProxy = vi.fn(async () => ({ ok: true as const, url: 'http://127.0.0.1:61234/app' })); platform.createIframeProxyUrl = createProxy; - setPlatform(platform); await renderPanel(stubActions(), paneProps('iframe-back'), updateParameters); // Observe an in-frame navigation: it adds a history entry but, by design, @@ -285,8 +279,8 @@ describe('IframePanel', () => { describe('iframe failures offer a way out', () => { const PROXY = 'http://127.0.0.1:61234'; function proxyPlatform(result: Awaited<ReturnType<NonNullable<PlatformAdapter['createIframeProxyUrl']>>> = { ok: true, url: `${PROXY}/app` }, swapCapable = true) { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'createIframeProxyUrl'>; - if (swapCapable) platform.agentBrowserOpen = vi.fn(); + // A host that drives agent-browser can swap the pane to it. + const platform: PlatformAdapter = swapCapable ? installBrowserHost().platform : new FakePtyAdapter(); platform.createIframeProxyUrl = vi.fn(async () => result); setPlatform(platform); return platform; @@ -460,11 +454,7 @@ describe('the render modes a tool is offered (regression: PR #493 review)', () = // `FakePtyAdapter` launches no browser, so every mode would be absent off the // stock fake — make the host capable first, or the assertion is vacuous. function withCapableHost() { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'agentBrowserPopOut' | 'playwright'>; - platform.agentBrowserOpen = async () => ({ ok: true }); - platform.agentBrowserPopOut = async () => ({ ok: true }); - platform.playwright = async () => ({ ok: true }); - setPlatform(platform); + installBrowserHost(); } it('offers every mode on a plain browser surface', async () => { diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index d76e7a22a..f766113da 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -28,7 +28,7 @@ beforeEach(() => { setPlatform(new FakePtyAdapter()); ensureResizeObserver(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); props = { title: 'pnpm dev', surfaceRef: 'surface:3', cwd: '~/repo', titleSources: [{ source: 'OSC 2', value: 'pnpm dev', note: 'Used' }], scan: { status: 'loaded', entries: [port(5173)] }, - watchRule: 'pnpm', watching: false, todo: false, status: 'completed', command: 'git status', explorerLabel: 'Open in Finder', canExplore: true, canAgent: true, canIframe: true, + watchRule: 'pnpm', watching: false, todo: false, status: 'completed', command: 'git status', explorerLabel: 'Open in Finder', canExplore: true, browserProviders: ['agent-browser', 'playwright'], canIframe: true, onClose: vi.fn(), onCopyRef: vi.fn(), onCopyPath: vi.fn(), onExplore: vi.fn(), onWatch: vi.fn(), onTodo: vi.fn(), onPort: vi.fn(), onModify: vi.fn(async () => {}), onReset: vi.fn(async () => {}), onPromote: vi.fn(async () => {}), children: <div data-helper-terminal="helper"><textarea aria-label="Helper input" /></div> }; }); @@ -87,9 +87,10 @@ it.each(['scanning', 'failed', 'empty'] as const)('distinguishes %s ports', stat expect(button('Open in system browser')).toBeNull(); }); it('disables unsupported host capabilities with an explanation', () => { - props.canAgent = false; props.canExplore = false; render(); - expect(button('Agent browser unavailable on this host').disabled).toBe(true); - expect(button('Popout unavailable on this host').disabled).toBe(true); + props.browserProviders = ['playwright']; props.canExplore = false; render(); + // Both of agent-browser's targets name the provider the host lacks. + expect([...container.querySelectorAll<HTMLButtonElement>('button[aria-label="agent-browser unavailable on this host"]')].map(b => b.disabled)).toEqual([true, true]); + expect(button('Open in Playwright popout').disabled).toBe(false); expect(button('Directory unavailable on this host').disabled).toBe(true); }); it('opens title explanation as a disclosure', async () => { diff --git a/lib/src/components/wall/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index 3efa2ef3c..fce83ec56 100644 --- a/lib/src/components/wall/TerminalContext.tsx +++ b/lib/src/components/wall/TerminalContext.tsx @@ -12,6 +12,7 @@ import { buildAppTitleResolver, createTerminalPaneState, cwdDisplay, deriveSurfa import { focusSession, getRunningCommandWatchKey, getRunningCommandWatchRule, getTerminalInstance, getActivitySnapshot, getTerminalPaneStateSnapshot, setCommandWatched, subscribeToActivity, subscribeToTerminalPaneState, subscribeToWatchedCommands, getWatchedCommandsSnapshot, toggleSessionTodo } from '../../lib/terminal-registry'; import { writeTextToClipboard } from '../../lib/clipboard'; import { listenerUrlsByPort } from './port-url'; +import { hostBrowserProviders } from './browser-automation'; import { DEFAULT_HELPER_COMMAND } from '../../lib/terminal-context-types'; export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, placement }: TerminalContextState & { title?: string; tool?: boolean } & Pick<TerminalContextViewProps, 'placement'>) { @@ -59,7 +60,7 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni scan={scan} watchRule={offeredRule} watching={watchRule !== null} todo={activities.get(id)?.todo === true} notification={activities.get(id)?.notification} status={tool ? (state.currentCommand ? 'running' : 'completed') : helper?.status ?? 'waiting'} command={tool ? state.currentCommand?.rawCommandLine ?? state.lastCommand?.rawCommandLine ?? '' : helper?.command ?? defaultCommand} defaultCommand={defaultCommand} warning={warning} explorerLabel={IS_MAC ? 'Open in Finder' : IS_WINDOWS ? 'Open in Explorer' : 'Open folder'} canExplore={!!platform.terminalContext && !!cwd && !cwd.isRemote} - canPlaywright={!!platform.playwright} canAgent={!!platform.agentBrowserOpen} canIframe={!!platform.createIframeProxyUrl} + browserProviders={hostBrowserProviders()} canIframe={!!platform.createIframeProxyUrl} onClose={onClose} onCopyRef={() => copy(actions.resolveSurfaceRef(id))} onCopyPath={() => copy(cwd?.path ?? '')} onExplore={async () => { if (platform.terminalContext && cwd) await platform.terminalContext({ op: 'openDirectory', id, path: cwd.path }); }} onWatch={() => { if (offeredRule) setCommandWatched(offeredRule, watchRule === null); }} onTodo={() => toggleSessionTodo(id)} diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index f7d56ebd3..0e160ee7c 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -2,7 +2,9 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, import { ArrowCounterClockwiseIcon, ArrowLineUpIcon, ArrowSquareOutIcon, BugBeetleIcon, CheckIcon, CircleNotchIcon, CopyIcon, FrameCornersIcon, PauseIcon, SlidersHorizontalIcon, TerminalIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; import { ELEVATED_PANE_SHADOW, OnOffSwitch, POPUP_SURFACE_CLASS, SUBTLE_ACTION_COLOR_CLASS, SUBTLE_ACTION_INTERACTION_CLASS, SUBTLE_ACTION_REST_COLOR_CLASS, TERMINAL_CONTEXT_SURFACE_CLASS, TERMINAL_CONTEXT_EXIT_MS, TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; import { stepFocus } from '../focus-step'; +import { BROWSER_PROVIDER_IDS, renderModeFor, type BrowserAutomationProvider } from 'dor-lib-common/browser-providers'; import { AgentRobotIcon } from './BrowserDisplayIcon'; +import { BROWSER_PROVIDER_GUI } from './browser-automation'; import type { PortUrlEntry } from './port-url'; import type { RenderMode } from './agent-browser-screen'; import type { HelperStatus } from '../../lib/helper-terminal'; @@ -30,14 +32,19 @@ const HELPER_STATUS: Record<HelperStatus, { icon: ReactNode; label: (command: st exited: { icon: SETTLED, label: () => 'Helper exited', reset: true }, }; -/** The port row's launch targets; `needs` names the host capability that enables one. */ -const PORT_ACTIONS: readonly ({ mode: PortMode; label: string; icon: ReactNode; text: string } & ({ needs?: undefined } | { needs: 'canIframe' | 'canAgent' | 'canPlaywright'; unavailable: string }))[] = [ +/** The port row's launch targets: the system browser, the embed, then each + * provider's screencast and popout. `needs` names what the host must offer. */ +const PORT_ACTIONS: readonly ({ mode: PortMode; label: string; icon: ReactNode; text: string } & ({ needs?: undefined } | { needs: 'iframe' | BrowserAutomationProvider; unavailable: string }))[] = [ { mode: 'system', label: 'Open in system browser', icon: <ArrowSquareOutIcon size={15} />, text: 'System browser' }, - { mode: 'iframe', label: 'Open in iframe embed', needs: 'canIframe', unavailable: 'Iframe unavailable on this host', icon: <FrameCornersIcon size={15} />, text: 'Iframe' }, - { mode: 'ab-screencast', label: 'Open in agent-browser screencast', needs: 'canAgent', unavailable: 'Agent browser unavailable on this host', icon: <AgentRobotIcon size={17} />, text: 'Agent browser' }, - { mode: 'ab-popout', label: 'Open in agent-browser popout', needs: 'canAgent', unavailable: 'Popout unavailable on this host', icon: <><AgentRobotIcon size={17} /><ArrowSquareOutIcon size={13} /></>, text: 'Popout' }, - { mode: 'pw-screencast', label: 'Open in Playwright screencast', needs: 'canPlaywright', unavailable: 'Playwright unavailable on this host', icon: <AgentRobotIcon size={17} />, text: 'Playwright' }, - { mode: 'pw-popout', label: 'Open in Playwright popout', needs: 'canPlaywright', unavailable: 'Playwright unavailable on this host', icon: <><AgentRobotIcon size={17} /><ArrowSquareOutIcon size={13} /></>, text: 'Playwright popout' }, + { mode: 'iframe', label: 'Open in iframe embed', needs: 'iframe', unavailable: 'Iframe unavailable on this host', icon: <FrameCornersIcon size={15} />, text: 'Iframe' }, + ...BROWSER_PROVIDER_IDS.flatMap((provider) => { + const { label } = BROWSER_PROVIDER_GUI[provider]; + const unavailable = `${label} unavailable on this host`; + return [ + { mode: renderModeFor(provider, 'screencast'), label: `Open in ${label} screencast`, needs: provider, unavailable, icon: <AgentRobotIcon size={17} />, text: label }, + { mode: renderModeFor(provider, 'popout'), label: `Open in ${label} popout`, needs: provider, unavailable, icon: <><AgentRobotIcon size={17} /><ArrowSquareOutIcon size={13} /></>, text: `${label} popout` }, + ]; + }), ]; const DETAILS = { @@ -58,8 +65,9 @@ export interface TerminalContextViewProps { scan: ContextScan; watchRule?: string | null; watching: boolean; todo: boolean; notification?: { title: string | null; body: string | null } | null; status: HelperStatus; command: string; warning?: string; - canPlaywright?: boolean; - explorerLabel: string; canExplore: boolean; canAgent: boolean; canIframe: boolean; + /** The providers this host can launch a browser with. */ + browserProviders: readonly BrowserAutomationProvider[]; + explorerLabel: string; canExplore: boolean; canIframe: boolean; children: ReactNode; notepadAction?: ReactNode; notepadPanel?: ReactNode; onClose(): void; onCopyRef: Action; onCopyPath: Action; onExplore: Action; onWatch(): void; onTodo(): void; onPort(entry: PortUrlEntry, mode: PortMode): void | Promise<void>; @@ -224,7 +232,8 @@ export function TerminalContextView(p: TerminalContextViewProps) { {entries.length > 1 ? <div className="flex w-full min-w-0 items-center gap-2"><select aria-label="Port" value={selected.port} onChange={e => setPort(Number(e.target.value))} className="h-6 min-w-0 flex-1 rounded border border-input-border bg-input-bg px-1 text-foreground">{entries.map(entry => <option key={entry.port} value={entry.port}>{entry.host}:{entry.port}{entry.processName ? ` · ${entry.processName}` : ''}</option>)}</select><span className="text-muted">{entries.length} ports</span></div> : <><span>{selected.host}:{selected.port}</span><span className="text-muted">{selected.processName}</span></>} <div className="ml-1 flex min-w-0 flex-wrap items-center gap-1 border-l border-border pl-2"> {PORT_ACTIONS.map(action => { - const unavailable = action.needs && !p[action.needs] ? action.unavailable : null; + const offered = action.needs === 'iframe' ? p.canIframe : !action.needs || p.browserProviders.includes(action.needs); + const unavailable = action.needs && !offered ? action.unavailable : null; return <ContextAction key={action.mode} label={unavailable ?? action.label} disabled={!!unavailable} fit onClick={() => void attempt(() => p.onPort(selected, action.mode))}>{action.icon}<span className="truncate">{action.text}</span></ContextAction>; })} </div> diff --git a/lib/src/components/wall/agent-browser-connection.ts b/lib/src/components/wall/agent-browser-connection.ts index 41575b5aa..cafafc541 100644 --- a/lib/src/components/wall/agent-browser-connection.ts +++ b/lib/src/components/wall/agent-browser-connection.ts @@ -1,4 +1,4 @@ -import type { AgentBrowserCommandResult } from '../../lib/platform/types'; +import type { BrowserResult } from '../../lib/platform/browser-automation'; import { type AgentBrowserTab, parseAgentBrowserTabs } from '../../lib/agent-browser-tab'; // Re-exported so existing importers keep resolving the tab type/parser from here. @@ -86,9 +86,9 @@ export interface AgentBrowserDebugEvent { export interface AgentBrowserConnectionDeps { session: string; streamPort: number; - binaryPath?: string; getStreamUrl?: (port: number) => Promise<string | undefined>; - runCommand?: (session: string, args: string[], binaryPath?: string) => Promise<AgentBrowserCommandResult>; + /** Make `tabId` the active tab. */ + selectTab?: (tabId: string) => Promise<BrowserResult>; canSelectTabs?: () => boolean; /** Whether the current stream frame's JPEG bytes are useful to the consumer. * False keeps the idle hot path at hash+pulse without parsing the large JSON. */ @@ -352,10 +352,8 @@ export class AgentBrowserConnection { if (!canSelect) return; this.log(`[ab-panel] selecting tab ${JSON.stringify({ tabId: tab.tabId, url: tab.url, reason })}`); this.debug('select-tab', { tabId: tab.tabId, url: tab.url, reason }); - this.deps.runCommand?.(this.deps.session, ['tab', tab.tabId], this.deps.binaryPath).then((result) => { - if (result.exitCode !== 0) { - this.log(`[agent-browser] tab ${tab.tabId} failed: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`); - } + this.deps.selectTab?.(tab.tabId).then((result) => { + if (!result.ok) this.log(`[agent-browser] tab ${tab.tabId} failed: ${result.error ?? 'no reason given'}`); }).catch((err) => this.log(`[agent-browser] tab ${tab.tabId} failed: ${err instanceof Error ? err.message : String(err)}`)); }; diff --git a/lib/src/components/wall/agent-browser-input.ts b/lib/src/components/wall/agent-browser-input.ts index 9273b2900..2bc6231f9 100644 --- a/lib/src/components/wall/agent-browser-input.ts +++ b/lib/src/components/wall/agent-browser-input.ts @@ -72,3 +72,20 @@ export const MOUSE_BUTTON_MASKS: Record<number, number> = { 0: 1, 1: 4, 2: 2 }; export function modifiers(e: { altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): number { return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0); } + +/** A paste as agent-browser stream messages: its stream takes only key and + * mouse events, so a key down and up per character, a newline as Enter. */ +export function keyPairTextInputs(text: string): Record<string, unknown>[] { + const messages: Record<string, unknown>[] = []; + for (const ch of text) { + if (ch === '\r') continue; + if (ch === '\n') { + messages.push({ type: 'input_keyboard', eventType: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13, modifiers: 0 }); + messages.push({ type: 'input_keyboard', eventType: 'keyUp', key: 'Enter', code: 'Enter', text: '', windowsVirtualKeyCode: 13, modifiers: 0 }); + } else { + messages.push({ type: 'input_keyboard', eventType: 'keyDown', key: ch, code: '', text: ch, windowsVirtualKeyCode: 0, modifiers: 0 }); + messages.push({ type: 'input_keyboard', eventType: 'keyUp', key: ch, code: '', text: '', windowsVirtualKeyCode: 0, modifiers: 0 }); + } + } + return messages; +} diff --git a/lib/src/components/wall/agent-browser-screen.ts b/lib/src/components/wall/agent-browser-screen.ts index 2f21e05ab..0578aa297 100644 --- a/lib/src/components/wall/agent-browser-screen.ts +++ b/lib/src/components/wall/agent-browser-screen.ts @@ -1,25 +1,43 @@ /** Per-surface bridge from browser bodies to their separate header and modal; * see docs/specs/dor-browser.md → "Browser Chrome". */ import { useSyncExternalStore } from 'react'; +import { BROWSER_PROVIDERS, parseRenderMode, type BrowserAutomationProvider, type SurfaceRenderMode } from 'dor-lib-common/browser-providers'; export type ScreenState = 'SYNCED' | 'SCALED'; -/** Canonical renderer values; defaulting belongs to `resolveRenderMode`. */ -export type RenderMode = 'ab-screencast' | 'ab-popout' | 'pw-screencast' | 'pw-popout' | 'iframe'; +/** Canonical renderer values, one per provider presentation plus the embed + * (`dor-lib-common/src/browser-providers.ts`); defaulting belongs to + * `resolveRenderMode`. */ +export type RenderMode = SurfaceRenderMode; + +type ProviderAlias = (typeof BROWSER_PROVIDERS)[keyof typeof BROWSER_PROVIDERS]['alias']; + +/** How an automated browser's human view is presented: resizing with the + * pane, at a fixed size, or popped out. */ +export type BrowserView = 'resize' | 'fixed' | 'popout'; +export const BROWSER_VIEWS: readonly BrowserView[] = ['resize', 'fixed', 'popout']; /** Capability-first browser display identity shared by pane chrome, the Display - * modal, and minimized Doors. The agent-browser modes always carry the robot; - * the second glyph describes where/how the human view is presented. */ -export type BrowserDisplayMode = 'ab-resize' | 'ab-fixed' | 'ab-popout' | 'pw-resize' | 'pw-fixed' | 'pw-popout' | 'iframe'; + * modal, and minimized Doors: a provider's view, or the embed. The automated + * modes always carry the robot; the second glyph describes the view. */ +export type BrowserDisplayMode = 'iframe' | `${ProviderAlias}-${BrowserView}`; + +/** `provider`'s display mode for `view`. */ +export function displayModeFor(provider: BrowserAutomationProvider, view: BrowserView): BrowserDisplayMode { + return `${BROWSER_PROVIDERS[provider].alias}-${view}`; +} + +/** The view a display mode presents, or the embed. */ +export function displayView(mode: BrowserDisplayMode): BrowserView | 'iframe' { + return mode === 'iframe' ? 'iframe' : mode.slice(mode.lastIndexOf('-') + 1) as BrowserView; +} export function browserDisplayMode( snapshot: Pick<ScreenSnapshot, 'renderMode' | 'syncEngaged'>, ): BrowserDisplayMode { - if (snapshot.renderMode === 'pw-popout') return 'pw-popout'; - if (snapshot.renderMode === 'pw-screencast') return snapshot.syncEngaged ? 'pw-resize' : 'pw-fixed'; - if (snapshot.renderMode === 'iframe') return 'iframe'; - if (snapshot.renderMode === 'ab-popout') return 'ab-popout'; - return snapshot.syncEngaged ? 'ab-resize' : 'ab-fixed'; + const { provider, presentation } = parseRenderMode(snapshot.renderMode); + if (provider === null) return 'iframe'; + return displayModeFor(provider, presentation === 'popout' ? 'popout' : snapshot.syncEngaged ? 'resize' : 'fixed'); } export interface ScreenSnapshot { @@ -99,7 +117,7 @@ export interface ScreenController { subscribeChrome(listener: () => void): () => void; chrome(): ChromeSnapshot; readonly chromeActions: ChromeActions; - /** Whether the host can run `agentBrowserCommand` (false ⇒ resizes inert). */ + /** Whether the host can drive this Surface's browser (false ⇒ resizes inert). */ readonly hostCapable: boolean; /** The render modes this Surface can take — the Display modal offers these * (and the current mode) and nothing else. `offeredRenderModes` answers for diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts index 3656bc6d9..3fc5d2b37 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts @@ -2,19 +2,19 @@ * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakePtyAdapter, getPlatform, setPlatform } from '../../lib/platform'; -import type { AgentBrowserScreenshotResult, PlatformAdapter } from '../../lib/platform/types'; +import type { BrowserResult } from '../../lib/platform/browser-automation'; import { createScreenshotLoop } from './agent-browser-screenshot-loop'; // Drive the loop directly (no controller/React): it owns backpressure, byte -// dedup, and the draw-generation key. The host screenshot is faked through the -// platform; createImageBitmap is stubbed (jsdom has none). +// dedup, and the draw-generation key. The host capture is a fake the loop's +// `capture` calls; createImageBitmap is stubbed (jsdom has none). -function setScreenshot(fn: PlatformAdapter['agentBrowserScreenshot']): void { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = fn; - setPlatform(platform); +type HostScreenshot = (opts: { format: 'jpeg'; quality: number }) => Promise<BrowserResult>; +let hostScreenshot: HostScreenshot | undefined; +function setScreenshot(fn: HostScreenshot): void { + hostScreenshot = fn; } +const capture = (opts: { format: 'jpeg'; quality: number }) => hostScreenshot?.(opts) ?? null; beforeEach(() => { vi.useFakeTimers(); @@ -24,7 +24,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - setPlatform(new FakePtyAdapter()); + hostScreenshot = undefined; }); describe('screenshot loop byte dedup', () => { @@ -34,7 +34,7 @@ describe('screenshot loop byte dedup', () => { setScreenshot(screenshot); const draw = vi.fn(); const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, }); @@ -61,7 +61,7 @@ describe('screenshot loop byte dedup', () => { const draw = vi.fn(); let generation = 0; const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, getDrawGeneration: () => generation, @@ -87,13 +87,13 @@ describe('screenshot loop backpressure', () => { // provisionally, its pulse is consumed by the capture that frame started, and // the stream then goes quiet. Dropping the stale result without leaving work // pending would strand the pane on the blurry frame until the page changes. - let release: ((res: AgentBrowserScreenshotResult) => void) | undefined; - const screenshot = vi.fn(() => new Promise<AgentBrowserScreenshotResult>((r) => { release = r; })); - setScreenshot(screenshot as unknown as PlatformAdapter['agentBrowserScreenshot']); + let release: ((res: BrowserResult) => void) | undefined; + const screenshot = vi.fn(() => new Promise<BrowserResult>((r) => { release = r; })); + setScreenshot(screenshot as unknown as HostScreenshot); const draw = vi.fn(); let provisionalGeneration = 0; const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, getProvisionalGeneration: () => provisionalGeneration, @@ -128,7 +128,7 @@ describe('screenshot loop backpressure', () => { let provisionalGeneration = 0; let provisionalDeadline = 0; const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, getProvisionalGeneration: () => provisionalGeneration, @@ -162,7 +162,7 @@ describe('screenshot loop backpressure', () => { const draw = vi.fn(); let provisionalGeneration = 0; const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, getProvisionalGeneration: () => provisionalGeneration, @@ -191,13 +191,13 @@ describe('screenshot loop backpressure', () => { it('coalesces pulses during an in-flight capture into a single follow-up', async () => { // A capture that stays in flight until we resolve it, so we can pulse during it. - const releases: Array<(res: AgentBrowserScreenshotResult) => void> = []; - const screenshot = vi.fn(() => new Promise<AgentBrowserScreenshotResult>((resolve) => { releases.push(resolve); })); - setScreenshot(screenshot as unknown as PlatformAdapter['agentBrowserScreenshot']); + const releases: Array<(res: BrowserResult) => void> = []; + const screenshot = vi.fn(() => new Promise<BrowserResult>((resolve) => { releases.push(resolve); })); + setScreenshot(screenshot as unknown as HostScreenshot); const draw = vi.fn(); let provisionalGeneration = 0; const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, getProvisionalGeneration: () => provisionalGeneration, @@ -234,12 +234,12 @@ describe('screenshot loop backpressure', () => { describe('screenshot loop behind a blocking command', () => { it('reports the capture overdue, never re-issues it, and draws it when it lands', async () => { - const releases: Array<(res: AgentBrowserScreenshotResult) => void> = []; - const screenshot = vi.fn(() => new Promise<AgentBrowserScreenshotResult>((resolve) => { releases.push(resolve); })); - setScreenshot(screenshot as unknown as PlatformAdapter['agentBrowserScreenshot']); + const releases: Array<(res: BrowserResult) => void> = []; + const screenshot = vi.fn(() => new Promise<BrowserResult>((resolve) => { releases.push(resolve); })); + setScreenshot(screenshot as unknown as HostScreenshot); const draw = vi.fn(); const loop = createScreenshotLoop({ - capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, + capture, isCapable: () => true, draw, }); @@ -275,11 +275,11 @@ describe('screenshot loop behind a blocking command', () => { }); it('still owes a shot for the wait when the overdue capture fails', async () => { - const releases: Array<(res: AgentBrowserScreenshotResult) => void> = []; - const screenshot = vi.fn(() => new Promise<AgentBrowserScreenshotResult>((resolve) => { releases.push(resolve); })); - setScreenshot(screenshot as unknown as PlatformAdapter['agentBrowserScreenshot']); + const releases: Array<(res: BrowserResult) => void> = []; + const screenshot = vi.fn(() => new Promise<BrowserResult>((resolve) => { releases.push(resolve); })); + setScreenshot(screenshot as unknown as HostScreenshot); vi.spyOn(console, 'warn').mockImplementation(() => {}); - const loop = createScreenshotLoop({ capture: (opts) => getPlatform().agentBrowserScreenshot?.('sess', opts) ?? null, isCapable: () => true, draw: vi.fn() }); + const loop = createScreenshotLoop({ capture, isCapable: () => true, draw: vi.fn() }); loop.pulse(); await vi.advanceTimersByTimeAsync(300); diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 164682278..c1fae3220 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -1,4 +1,4 @@ -import type { AgentBrowserScreenshotResult } from '../../lib/platform/types'; +import type { BrowserResult } from '../../lib/platform/browser-automation'; // Fast non-cryptographic hash (djb2, xor length) over the raw screenshot bytes — // the byte analogue of the connection's frame dedup. A static page the daemon @@ -13,7 +13,7 @@ function djb2Bytes(bytes: Uint8Array): number { export interface ScreenshotLoopDeps { /** Start one host capture, or null when none can be taken now — the * controller's daemon gate decides. */ - capture: (opts: { format: 'jpeg'; quality: number }) => Promise<AgentBrowserScreenshotResult> | null; + capture: (opts: { format: 'jpeg'; quality: number }) => Promise<BrowserResult> | null; isCapable: () => boolean; draw: (bitmap: ImageBitmap) => void; /** A monotonic draw-target generation. Included in the byte-dedup key so a diff --git a/lib/src/components/wall/agent-browser-surface-controller.test.ts b/lib/src/components/wall/agent-browser-surface-controller.test.ts index 1eb76eeb9..80867f86c 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -3,8 +3,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; -import type { AgentBrowserAttachResult, AgentBrowserOpenResult, AgentBrowserPopResult, PlatformAdapter } from '../../lib/platform/types'; -import { PLAYWRIGHT_TEXT_INPUT_MAX } from '../../lib/platform/browser-automation'; +import type { PlatformAdapter } from '../../lib/platform/types'; +import { PLAYWRIGHT_TEXT_INPUT_MAX, type BrowserRequest, type BrowserResult } from '../../lib/platform/browser-automation'; import { getAgentBrowserScreenController } from './agent-browser-screen'; import { forgetLaunchBinaryPaths, launchBinaryPath, rememberLaunchBinaryPath } from './browser-automation'; import { @@ -21,6 +21,9 @@ import { getAgentBrowserSurfaceController, type AgentBrowserViewSink, } from './agent-browser-surface-controller'; +import { installBrowserHost, type BrowserAnswers } from './wall-test-utils'; +import { createBrowserHost } from '../../host/browser-host'; +import { fakeProvider } from '../../host/browser-host-test-utils'; // These tests drive the controller directly, with NO React — it owns the whole // non-React lifecycle, so it can be exercised in isolation. @@ -89,18 +92,28 @@ function withPort(id: string, params: AgentBrowserSurfaceParams, port: number): return controller; } -/** The pages a controller opened with a daemon command, in order. */ -const opens = (platform: Pick<PlatformAdapter, 'agentBrowserCommand'>) => - vi.mocked(platform.agentBrowserCommand!).mock.calls.filter(([, args]) => args[0] === 'open').map(([, args]) => args[1]); +/** The pages a controller navigated its live browser to, in order. */ +const opens = (host: Pick<ReturnType<typeof installBrowserHost>, 'requests'>) => + host.requests('navigate').map((request) => request.url); const streamSockets = (port: number) => WebSocketMock.instances.filter((ws) => ws.url === `ws://127.0.0.1:${port}`); const streamSocket = (port: number) => streamSockets(port).at(-1); async function flushMicrotasks(): Promise<void> { - for (let i = 0; i < 6; i++) await Promise.resolve(); + for (let i = 0; i < 12; i++) await Promise.resolve(); } +/** A browser that stays pending, until `resolve` answers it. */ +function pending(): { promise: Promise<BrowserResult>; resolve: (result: BrowserResult) => void } { + let resolve!: (result: BrowserResult) => void; + const promise = new Promise<BrowserResult>((r) => { resolve = r; }); + return { promise, resolve }; +} + +/** An agent-browser request for session `sess`. */ +const onSess = (op: Record<string, unknown>) => ({ provider: 'agent-browser', binding: { session: 'sess' }, ...op }); + beforeEach(() => { vi.stubGlobal('WebSocket', WebSocketMock); vi.stubGlobal('ResizeObserver', ResizeObserverMock); @@ -182,12 +195,13 @@ describe('provisional stream paint', () => { * canvas under the test's control. Captures never answer unless `screenshot` says. */ async function paintFixture( screenshot: () => Promise<Shot> = () => new Promise<never>(() => {}), - extra: Partial<Pick<PlatformAdapter, 'agentBrowserEdit' | 'readClipboardText'>> = {}, + clipboardText?: string, ) { const clock = { now: 1000 }; vi.spyOn(performance, 'now').mockImplementation(() => clock.now); - const platform = Object.assign(new FakePtyAdapter(), { agentBrowserScreenshot: vi.fn(screenshot), ...extra }); - setPlatform(platform); + const host = installBrowserHost({ screenshot }); + const platform: PlatformAdapter = host.platform; + if (clipboardText !== undefined) platform.readClipboardText = vi.fn(async () => clipboardText); const bitmap = { width: 40, height: 30, close: vi.fn() } as unknown as ImageBitmap; vi.stubGlobal('createImageBitmap', vi.fn(async () => bitmap)); const sink = makeSink(); @@ -201,15 +215,15 @@ describe('provisional stream paint', () => { await flushMicrotasks(); }; const decodes = () => vi.mocked(createImageBitmap).mock.calls.length; - return { clock, platform, sink, bitmap, drawImage, controller, frame, decodes }; + return { clock, host, platform, sink, bitmap, drawImage, controller, frame, decodes }; } it('draws the native stream frame before the crisp screenshot resolves', async () => { - const { clock, platform, sink, bitmap, drawImage, controller, frame, decodes } = await paintFixture(); + const { clock, host, sink, bitmap, drawImage, controller, frame, decodes } = await paintFixture(); controller.send({ type: 'input_mouse', eventType: 'mouseMoved', x: 1, y: 1 }); await frame('low-latency-frame'); - expect(platform.agentBrowserScreenshot).toHaveBeenCalled(); + expect(host.requests('screenshot').length).toBeGreaterThan(0); expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 0); expect(sink.canvas.width).toBe(40); expect(sink.canvas.height).toBe(30); @@ -223,10 +237,7 @@ describe('provisional stream paint', () => { }); it('paints the stream frame after keys, pasted text and editing chords, not only after pointer input', async () => { - const { clock, platform, controller, frame, decodes } = await paintFixture(undefined, { - agentBrowserEdit: vi.fn(async () => ({ ok: true })), - readClipboardText: vi.fn(async () => 'pasted'), - }); + const { clock, host, platform, controller, frame, decodes } = await paintFixture(undefined, 'pasted'); await frame('first'); expect(decodes()).toBe(1); @@ -250,21 +261,21 @@ describe('provisional stream paint', () => { // A select-all runs through the host rather than the stream. clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; controller.handleKeyDownLike({ key: 'a', code: 'KeyA', ctrlKey: true, metaKey: false, altKey: false, shiftKey: false }); - expect(platform.agentBrowserEdit).toHaveBeenCalledWith('sess', 'selectAll', undefined); + expect(host.requests('edit')).toEqual([onSess({ op: 'edit', edit: 'selectAll' })]); await frame('selected'); expect(decodes()).toBe(4); }); it('paints the stream while a crisp capture waits behind a blocking command, then draws that capture', async () => { const releases: Array<(shot: Shot) => void> = []; - const { clock, platform, drawImage, frame, decodes } = await paintFixture(() => new Promise((resolve) => { releases.push(resolve); })); + const { clock, host, drawImage, frame, decodes } = await paintFixture(() => new Promise((resolve) => { releases.push(resolve); })); // The first image paints from the stream, superseding the capture it pulsed; // its replacement is the one a page-loading `open` then holds. await frame('previous page'); clock.now += 300; releases[0]({ ok: true, bytes: new Uint8Array([1]), mime: 'image/jpeg' }); await flushMicrotasks(); - expect(platform.agentBrowserScreenshot).toHaveBeenCalledTimes(2); + expect(host.requests('screenshot')).toHaveLength(2); const decoded = decodes(); clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; @@ -289,7 +300,7 @@ describe('provisional stream paint', () => { it('repaints a byte-identical crisp capture over a provisional paint', async () => { // A provisional paint changes the canvas behind the loop's byte-dedup, so a // resting page's byte-identical capture must still repaint over the blur. - const { clock, platform, drawImage, controller, frame } = await paintFixture( + const { clock, host, drawImage, controller, frame } = await paintFixture( async () => ({ ok: true, bytes: new Uint8Array([9, 9, 9]), mime: 'image/jpeg' }), ); await frame('first'); @@ -300,7 +311,7 @@ describe('provisional stream paint', () => { clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; await frame('rest'); const afterCrisp = drawImage.mock.calls.length; - expect(platform.agentBrowserScreenshot).toHaveBeenCalled(); + expect(host.requests('screenshot').length).toBeGreaterThan(0); controller.send({ type: 'input_mouse', eventType: 'mouseMoved', x: 1, y: 1 }); await frame('hover'); @@ -318,10 +329,7 @@ describe('sync-to-pane', () => { afterEach(() => vi.useRealTimers()); it('issues one viewport once a pane resize settles, and re-syncs a display-scale change at once', async () => { - const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - platform.agentBrowserCommand = command; - setPlatform(platform); + const host = installBrowserHost(); // The pane observer fires whenever the test resizes the pane. const observers: ResizeObserverCallback[] = []; vi.stubGlobal('ResizeObserver', class { @@ -348,15 +356,13 @@ describe('sync-to-pane', () => { size = { width, height }; observers.at(-1)?.([{ contentRect: { width, height } } as ResizeObserverEntry], {} as ResizeObserver); }; - const viewports = () => command.mock.calls - .map((call) => (call as unknown as [string, string[]])[1]) - .filter((args) => args[0] === 'set' && args[1] === 'viewport'); + const viewports = () => host.requests('viewport').map(({ width, height, dpr }) => [width, height, dpr]); const controller = withPort('id', { session: 'sess' }, 4321); controller.attachView(sink); await flushMicrotasks(); // Sync is engaged by default, so attaching sized the browser to the pane. - expect(viewports().at(-1)).toEqual(['set', 'viewport', '800', '600', '1']); + expect(host.requests('viewport').at(-1)).toEqual(onSess({ op: 'viewport', width: 800, height: 600, dpr: 1 })); const issued = viewports().length; // A window drag resizes the pane every frame; one viewport lands after it settles. @@ -366,11 +372,11 @@ describe('sync-to-pane', () => { } expect(viewports()).toHaveLength(issued); await vi.advanceTimersByTimeAsync(200); - expect(viewports().slice(issued)).toEqual([['set', 'viewport', '860', '600', '1']]); + expect(viewports().slice(issued)).toEqual([[860, 600, 1]]); dpr = 2; queries.at(-1)!.onChange!(); - expect(viewports().at(-1)).toEqual(['set', 'viewport', '860', '600', '2']); + expect(viewports().at(-1)).toEqual([860, 600, 2]); // Re-armed for the new scale. expect(queries.at(-1)!.media).toBe('(resolution: 2dppx)'); expect(queries.at(-1)!.onChange).toBeDefined(); @@ -381,11 +387,7 @@ describe('sync-to-pane while parked', () => { it('pushes a resize made behind a hidden pane once it is live again', async () => { vi.useFakeTimers(); try { - const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserAttach'>; - platform.agentBrowserCommand = command; - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 4321 })); - setPlatform(platform); + const host = installBrowserHost({ attach: async () => ({ ok: true, wsPort: 4321 }) }); const observers: ResizeObserverCallback[] = []; vi.stubGlobal('ResizeObserver', class { constructor(callback: ResizeObserverCallback) { observers.push(callback); } @@ -395,9 +397,7 @@ describe('sync-to-pane while parked', () => { const sink = makeSink(); let size = { width: 800, height: 600 }; sink.viewport.getBoundingClientRect = () => ({ ...size }) as DOMRect; - const viewports = () => command.mock.calls - .map((call) => (call as unknown as [string, string[]])[1]) - .filter((args) => args[1] === 'viewport'); + const viewports = () => host.requests('viewport').map(({ width, height, dpr }) => [width, height, dpr]); const controller = withPort('id', { session: 'sess' }, 4321); controller.attachView(sink); @@ -409,12 +409,12 @@ describe('sync-to-pane while parked', () => { size = { width: 1000, height: 700 }; observers.at(-1)?.([{ contentRect: { width: 1000, height: 700 } } as ResizeObserverEntry], {} as ResizeObserver); await vi.advanceTimersByTimeAsync(250); - expect(viewports().at(-1)).toEqual(['set', 'viewport', '800', '600', '1']); + expect(viewports().at(-1)).toEqual([800, 600, 1]); // Unparked at the same port: the size it missed still goes out. controller.setVisible(true); await vi.advanceTimersByTimeAsync(0); - expect(viewports().at(-1)).toEqual(['set', 'viewport', '1000', '700', '1']); + expect(viewports().at(-1)).toEqual([1000, 700, 1]); } finally { vi.useRealTimers(); } @@ -427,9 +427,7 @@ describe('parking', () => { it('detach parks after the debounce and resets hasFrame', async () => { const screenshot = vi.fn(async () => ({ ok: true as const, bytes: new Uint8Array([1, 2, 3]), mime: 'image/jpeg' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); + installBrowserHost({ screenshot }); // Give the draw path a bitmap so hasFrame can flip true without a real canvas. vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 4, height: 4, close: vi.fn() }))); @@ -464,9 +462,7 @@ describe('re-attach repaint', () => { vi.useFakeTimers(); try { const screenshot = vi.fn(async () => ({ ok: true as const, bytes: new Uint8Array([1, 2, 3]), mime: 'image/jpeg' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); + installBrowserHost({ screenshot }); vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 4, height: 4, close: vi.fn() }))); const controller = withPort('id', { session: 'sess' }, 4321); @@ -493,11 +489,9 @@ describe('re-attach repaint', () => { describe('param-write buffering', () => { it('buffers writes while detached and flushes them on the next attach', async () => { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand'>; - // Returning no cdp-url keeps the popped-out CDP observer from opening a - // second socket, so only the stream socket exists. - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); + // Answering no CDP endpoint keeps the popped-out CDP observer from opening + // a second socket, so only the stream socket exists. + installBrowserHost(); // Popped out ⇒ exempt from parking, so a detached (minimized) pane keeps its // stream observer and can still record a URL change. @@ -545,21 +539,27 @@ describe('updateParams', () => { expect(streamSockets(1111).length).toBe(1); }); + it('follows a headedness the host reports, for either provider', async () => { + installBrowserHost(); + for (const [id, screencast, popout] of [['ab', 'ab-screencast', 'ab-popout'], ['pw', 'pw-screencast', 'pw-popout']] as const) { + const controller = withPort(id, { renderMode: screencast, session: 'sess' }, 1111); + controller.attachView(makeSink()); + await flushMicrotasks(); + // `surface.browser` records a native `open --headed` in params. + controller.updateParams({ renderMode: popout, session: 'sess' }); + expect(controller.snapshot().poppedOut, id).toBe(true); + expect(getAgentBrowserScreenController(id)!.snapshot().renderMode).toBe(popout); + } + }); }); describe('launch', () => { - type Open = PlatformAdapter['agentBrowserOpen']; - function launchPlatform(open: NonNullable<Open>) { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'agentBrowserCommand' | 'agentBrowserAttach'>; - platform.agentBrowserOpen = vi.fn(open); - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 9999 })); - setPlatform(platform); - return platform; + function launchHost(launch: NonNullable<BrowserAnswers['launch']>) { + return installBrowserHost({ launch, attach: async () => ({ ok: true, wsPort: 9999 }) }); } it('a session-less pane opens its page, binds the session the host answers with, and streams', async () => { - const platform = launchPlatform(async () => ({ ok: true, session: 'dormouse.1.gui-abc', wsPort: 4321, binaryPath: '/usr/bin/agent-browser' })); + const host = launchHost(async () => ({ ok: true, session: 'dormouse.1.gui-abc', wsPort: 4321, binaryPath: '/usr/bin/agent-browser' })); const launched = whenBrowserLaunched('id'); // A restored pane whose launch never landed is the same pane. const controller = acquireAgentBrowserSurfaceController('id', { @@ -570,40 +570,44 @@ describe('launch', () => { expect(controller.snapshot().phase).toBe('launching'); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenCalledExactlyOnceWith('https://page.example/', { headed: false }, '/usr/bin/agent-browser'); + expect(host.requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { binaryPath: '/usr/bin/agent-browser' }, op: 'launch', url: 'https://page.example/', headed: false, + }]); expect(sink.updateParameters).toHaveBeenCalledWith({ session: 'dormouse.1.gui-abc', binaryPath: '/usr/bin/agent-browser' }); expect(streamSocket(4321)?.readyState).toBe(1); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); + expect(host.requests('attach')).toEqual([]); expect(await launched).toBeNull(); // Driven as the session it bound. getAgentBrowserScreenController('id')!.chromeActions.reload(); - expect(platform.agentBrowserCommand).toHaveBeenLastCalledWith('dormouse.1.gui-abc', ['reload'], '/usr/bin/agent-browser'); + expect(host.browser).toHaveBeenLastCalledWith({ + provider: 'agent-browser', binding: { session: 'dormouse.1.gui-abc', binaryPath: '/usr/bin/agent-browser' }, op: 'history', dir: 'reload', + }); // Params that predate that write — a remounted view feeding them before its // flush — do not take the session away and launch again. controller.updateParams({ renderMode: 'ab-screencast', url: 'https://page.example/' }); controller.updateParams({ renderMode: 'ab-screencast', url: 'https://page.example/', session: 'dormouse.1.gui-abc' }); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenCalledTimes(1); + expect(host.requests('launch')).toHaveLength(1); expect(streamSockets(4321)).toHaveLength(1); }); it('opens in the session params name, headed for a pop-out, and binds it', async () => { - const platform = launchPlatform(async () => ({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 })); + const host = launchHost(async () => ({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 })); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-popout', url: 'http://localhost:6006/', launchSession: 'dormouse.1.tool.t', }); const sink = makeSink(); controller.attachView(sink); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenCalledWith('http://localhost:6006/', { headed: true, session: 'dormouse.1.tool.t' }, undefined); + expect(host.requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { session: 'dormouse.1.tool.t' }, op: 'launch', url: 'http://localhost:6006/', headed: true, + }]); expect(sink.updateParameters).toHaveBeenCalledWith({ session: 'dormouse.1.tool.t', launchSession: undefined, launchFallback: undefined }); }); it('a failed launch says why, in the pane and to whoever awaited it', async () => { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = vi.fn(async () => ({ ok: false })); - setPlatform(platform); + installBrowserHost({ launch: async () => ({ ok: false }) }); const launched = whenBrowserLaunched('pw'); const controller = acquireAgentBrowserSurfaceController('pw', { renderMode: 'pw-screencast', url: 'https://page.example/' }); controller.attachView(makeSink()); @@ -614,8 +618,8 @@ describe('launch', () => { }); it('a Surface closed mid-launch closes the browser that comes up, and its waiter hears it is gone', async () => { - let answer!: (res: { ok: boolean; session?: string; wsPort?: number }) => void; - const platform = launchPlatform(() => new Promise((resolve) => { answer = resolve; })); + const launch = pending(); + const host = launchHost(() => launch.promise); const launched = whenBrowserLaunched('id'); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }); controller.attachView(makeSink()); @@ -623,30 +627,30 @@ describe('launch', () => { closeBrowserSurface('id', { surfaceType: 'browser', renderMode: 'ab-screencast', url: 'https://page.example/' }); expect(await launched).toBeNull(); - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); + expect(host.requests('close')).toEqual([]); - answer({ ok: true, session: 'dormouse.1.gui-late', wsPort: 4321 }); + launch.resolve({ ok: true, session: 'dormouse.1.gui-late', wsPort: 4321 }); await flushMicrotasks(); - expect(platform.agentBrowserCommand).toHaveBeenCalledExactlyOnceWith('dormouse.1.gui-late', ['close'], undefined); + expect(host.requests('close')).toEqual([{ provider: 'agent-browser', binding: { session: 'dormouse.1.gui-late' }, op: 'close' }]); expect(WebSocketMock.instances).toHaveLength(0); }); it('a navigation out of a failed launch launches at its page, and loads it once', async () => { - const answers = [{ ok: false, error: 'boom' }, { ok: true, session: 'dormouse.1.gui-n', wsPort: 4321 }]; - const platform = launchPlatform(async () => answers.shift()!); + const answers: BrowserResult[] = [{ ok: false, error: 'boom' }, { ok: true, session: 'dormouse.1.gui-n', wsPort: 4321 }]; + const host = launchHost(async () => answers.shift()!); acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }).attachView(makeSink()); await flushMicrotasks(); getAgentBrowserScreenController('id')!.chromeActions.navigate('https://next.example/'); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenLastCalledWith('https://next.example/', { headed: false }, undefined); + expect(host.requests('launch').at(-1)).toEqual({ provider: 'agent-browser', binding: {}, op: 'launch', url: 'https://next.example/', headed: false }); expect(streamSocket(4321)?.readyState).toBe(1); - expect(opens(platform)).toEqual([]); + expect(opens(host)).toEqual([]); }); it('a Tool re-framed while its browser opens goes to the new page once live', async () => { - let answer!: (res: AgentBrowserOpenResult) => void; - const platform = launchPlatform(() => new Promise((resolve) => { answer = resolve; })); + const launch = pending(); + const host = launchHost(() => launch.promise); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: 'dormouse.1.tool.t', }); @@ -655,14 +659,14 @@ describe('launch', () => { // A new announcement: the same session, another page. controller.updateParams({ renderMode: 'ab-screencast', url: 'http://localhost:6007/docs', launchSession: 'dormouse.1.tool.t' }); - answer({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); + launch.resolve({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); await flushMicrotasks(); expect(streamSocket(4321)?.readyState).toBe(1); - expect(opens(platform)).toEqual(['http://localhost:6007/docs']); + expect(opens(host)).toEqual(['http://localhost:6007/docs']); }); it('ignores params that predate the session its launch bound', async () => { - const platform = launchPlatform(async () => ({ ok: true, session: 'dormouse.1.gui-abc', wsPort: 4321 })); + const host = launchHost(async () => ({ ok: true, session: 'dormouse.1.gui-abc', wsPort: 4321 })); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }); controller.attachView(makeSink()); await flushMicrotasks(); @@ -670,13 +674,13 @@ describe('launch', () => { // A remounted view feeds the params it rendered with, before the write shows. controller.updateParams({ renderMode: 'ab-screencast', url: 'https://page.example/' }); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenCalledOnce(); + expect(host.requests('launch')).toHaveLength(1); expect(streamSocket(4321)?.readyState).toBe(1); }); it('never closes the session a launch opened when the Surface has since bound it', async () => { - let answer!: (res: { ok: boolean; session?: string; wsPort?: number }) => void; - const platform = launchPlatform(() => new Promise((resolve) => { answer = resolve; })); + const launch = pending(); + const host = launchHost(() => launch.promise); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: 'dormouse.1.tool.t', }); @@ -685,23 +689,25 @@ describe('launch', () => { controller.updateParams({ renderMode: 'ab-screencast', url: 'http://localhost:6006/', session: 'dormouse.1.tool.t' }); controller.handOver(4321); - answer({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); + launch.resolve({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); await flushMicrotasks(); - expect(platform.agentBrowserCommand).not.toHaveBeenCalledWith('dormouse.1.tool.t', ['close'], undefined); + expect(host.requests('close')).toEqual([]); expect(streamSocket(4321)?.readyState).toBe(1); }); it('launches with the binary `dor ab` last resolved, and remembers the one it ran', async () => { - const platform = launchPlatform(async () => ({ ok: true, session: 'dormouse.1.gui-b', wsPort: 4321, binaryPath: '/opt/ab/agent-browser' })); + const host = launchHost(async () => ({ ok: true, session: 'dormouse.1.gui-b', wsPort: 4321, binaryPath: '/opt/ab/agent-browser' })); rememberLaunchBinaryPath('agent-browser', '/usr/local/bin/agent-browser'); acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }).attachView(makeSink()); await flushMicrotasks(); - expect(platform.agentBrowserOpen).toHaveBeenCalledWith('https://page.example/', { headed: false }, '/usr/local/bin/agent-browser'); + expect(host.requests('launch')).toEqual([{ + provider: 'agent-browser', binding: { binaryPath: '/usr/local/bin/agent-browser' }, op: 'launch', url: 'https://page.example/', headed: false, + }]); expect(launchBinaryPath('agent-browser')).toBe('/opt/ab/agent-browser'); }); it('tells the Wall about a failed launch, once a view is attached to hear it', async () => { - launchPlatform(async () => ({ ok: false, error: 'boom' })); + launchHost(async () => ({ ok: false, error: 'boom' })); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }); const first = makeSink(); const handle = controller.attachView(first); @@ -714,8 +720,8 @@ describe('launch', () => { }); it('a launch released without a close leaves a session it named, and closes one the host minted', async () => { - const answers: Array<(res: { ok: boolean; session?: string; wsPort?: number }) => void> = []; - const platform = launchPlatform(() => new Promise((resolve) => { answers.push(resolve); })); + const answers: Array<(res: BrowserResult) => void> = []; + const host = launchHost(() => new Promise((resolve) => { answers.push(resolve); })); const named = acquireAgentBrowserSurfaceController('named', { renderMode: 'ab-screencast', url: 'https://page.example/', launchSession: 'dormouse.1.tool.t' }); named.attachView(makeSink()); const minted = acquireAgentBrowserSurfaceController('minted', { renderMode: 'ab-screencast', url: 'https://page.example/' }); @@ -728,11 +734,11 @@ describe('launch', () => { answers[0]({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); answers[1]({ ok: true, session: 'dormouse.1.gui-x', wsPort: 4322 }); await flushMicrotasks(); - expect(platform.agentBrowserCommand).toHaveBeenCalledExactlyOnceWith('dormouse.1.gui-x', ['close'], undefined); + expect(host.requests('close')).toEqual([{ provider: 'agent-browser', binding: { session: 'dormouse.1.gui-x' }, op: 'close' }]); }); it('a controller released before it ever started still settles its waiter', async () => { - launchPlatform(async () => ({ ok: true })); + launchHost(async () => ({ ok: true })); const launched = whenBrowserLaunched('id'); acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'https://page.example/' }); disposeAgentBrowserSurfaceController('id'); @@ -740,7 +746,7 @@ describe('launch', () => { }); it('a Surface killed before its view ever mounted still settles its waiter', async () => { - launchPlatform(async () => ({ ok: true })); + launchHost(async () => ({ ok: true })); const launched = whenBrowserLaunched('never-mounted'); closeBrowserSurface('never-mounted', { surfaceType: 'browser', renderMode: 'ab-screencast', url: 'https://page.example/' }); expect(await launched).toBeNull(); @@ -750,110 +756,161 @@ describe('launch', () => { describe('a closed Surface and the next launch into its session', () => { // A Tool swapped to its embed and back, or re-framed after its dev server // restarts, while its browser was still coming up: the Surface closes, and - // the next launch opens the same `tool.<leafId>` session. + // the next launch opens the same `tool.<leafId>` session. The host orders + // the two (docs/specs/dor-browser.md → "Browser Host"), so these run the + // controller against the real one. const session = 'dormouse.1.tool.t'; - type Pending<T> = { resolve: (value: T) => void }; - function hostPlatform() { - const pending = { open: [] as Pending<AgentBrowserOpenResult>[], popOut: [] as Pending<AgentBrowserPopResult>[], attach: [] as Pending<AgentBrowserAttachResult>[] }; - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'agentBrowserCommand' | 'agentBrowserAttach' | 'agentBrowserPopOut' | 'agentBrowserPopIn'>; - platform.agentBrowserOpen = vi.fn(() => new Promise<AgentBrowserOpenResult>((resolve) => { pending.open.push({ resolve }); })); - platform.agentBrowserAttach = vi.fn(() => new Promise<AgentBrowserAttachResult>((resolve) => { pending.attach.push({ resolve }); })); - platform.agentBrowserPopOut = vi.fn(() => new Promise<AgentBrowserPopResult>((resolve) => { pending.popOut.push({ resolve }); })); - platform.agentBrowserPopIn = vi.fn(async () => ({ ok: true, wsPort: 5555 })); - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); - return { platform, pending }; + const page = 'http://localhost:6006/'; + /** `deliver` stands in for the transport: it hands a request to the host, + * by default at once. */ + function realHost(deliver: (request: BrowserRequest, send: () => Promise<BrowserResult>) => Promise<BrowserResult> = (_request, send) => send()) { + const fake = fakeProvider(); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + const browser = vi.fn((request: BrowserRequest) => deliver(request, () => host.request(request))); + setPlatform(Object.assign(new FakePtyAdapter(), { browserProviders: ['agent-browser'] as const, browser })); + // What the host did to the session's browser: bring it up, or close it. + const lifecycle = () => fake.calls.filter((call) => /^(stop|open|close) /.test(call)); + return { fake, browser, lifecycle }; } - /** Close `id` mid-flight, start the next launch into `session`, and check it - * waits for the first to land and close the session again. */ - async function expectNextLaunchWaits( - { platform }: ReturnType<typeof hostPlatform>, - id: string, - land: () => void, - ) { - // Closes stay unanswered until released, so the wait is for the answer. - const answerCloses: Array<() => void> = []; - vi.mocked(platform.agentBrowserCommand!).mockImplementation((_session, args) => new Promise((resolve) => { - const answer = () => resolve({ exitCode: 0, stdout: '', stderr: '' }); - if (args[0] === 'close') answerCloses.push(answer); - else answer(); - })); - const closesOfSession = () => vi.mocked(platform.agentBrowserCommand!).mock.calls - .filter(([name, args]) => name === session && args[0] === 'close').length; - const nextOpen = () => vi.mocked(platform.agentBrowserOpen!).mock.calls.filter(([, opts]) => opts.session === session).length; - const opensBefore = nextOpen(); + /** `id`'s work into the session is held in the host (at its `stop`): close + * `id`, start the next launch into the session, let the work land, and + * check the close came after it and the next launch after the close. */ + async function expectNextLaunchAfterClose(host: ReturnType<typeof realHost>, id: string, work: string[]) { void closeBrowserSurface(id, {}); - acquireAgentBrowserSurfaceController('next', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: session }) - .attachView(makeSink()); - const closesBefore = closesOfSession(); - answerCloses.splice(0).forEach((answer) => answer()); - // Every promise chain settled, not a fixed number of turns. - const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - await settle(); - expect(nextOpen()).toBe(opensBefore); - - // It lands, and closes the session again: the next launch waits for that answer too. - land(); - await settle(); - expect(closesOfSession()).toBe(closesBefore + 1); - expect(nextOpen()).toBe(opensBefore); - answerCloses.splice(0).forEach((answer) => answer()); - await vi.waitFor(() => expect(nextOpen()).toBe(opensBefore + 1)); + const next = acquireAgentBrowserSurfaceController('next', { renderMode: 'ab-screencast', url: page, launchSession: session }); + next.attachView(makeSink()); + await flushMicrotasks(); + expect(host.lifecycle()).toEqual([`stop ${session}`]); + + host.fake.release(`stop ${session}`); + await vi.waitFor(() => expect(next.snapshot().phase).toBe('live')); + expect(host.lifecycle()).toEqual([...work, `close ${session}`, `stop ${session}`, `open ${session} ${page}`]); } - it('waits for a launch that was opening it', async () => { - const host = hostPlatform(); - acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: session }) + it.each([ + ['bound to the session', false], + ['still launching it', true], + ])('sends the next launch only once the close of a Surface %s is answered, on a transport that delivers the launch first', async (_name, launching) => { + // Tauri runs each command on a worker pool, so a close and a launch sent + // an instant apart can reach the host in either order. + let deliverClose!: () => void; + const closeHeld = new Promise<void>((resolve) => { deliverClose = resolve; }); + const host = realHost((request, send) => (request.op === 'close' ? closeHeld.then(send) : send())); + if (launching) { + host.fake.gate(`stop ${session}`); + acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: page, launchSession: session }).attachView(makeSink()); + } else { + withPort('first', { session, url: page }, 1111).attachView(makeSink()); + } + await flushMicrotasks(); + const firstWork = launching ? [`stop ${session}`] : []; + + void closeBrowserSurface('first', {}); + const next = acquireAgentBrowserSurfaceController('next', { renderMode: 'ab-screencast', url: page, launchSession: session }); + next.attachView(makeSink()); + await flushMicrotasks(); + // The close is still on its way, so the next launch has not been sent. + const sent = () => host.browser.mock.calls.map(([request]) => request.op).filter((op) => op === 'close' || op === 'launch'); + expect(sent()).toEqual([...(launching ? ['launch'] : []), 'close']); + expect(host.lifecycle()).toEqual(firstWork); + + deliverClose(); + if (launching) host.fake.release(`stop ${session}`); + await vi.waitFor(() => expect(next.snapshot().phase).toBe('live')); + expect(host.lifecycle()).toEqual([ + ...(launching ? [`stop ${session}`, `open ${session} ${page}`] : []), + `close ${session}`, `stop ${session}`, `open ${session} ${page}`, + ]); + }); + + it.each([ + ['launch naming it', 'launch'], + ['pop-out', 'launch'], + ['attach relaunching it', 'attach'], + ] as const)('a Surface closed while its own %s is on its way opens nothing, though the close reaches the host first', async (name, op) => { + // Tauri runs each command on a worker pool, so a request sent an instant + // before the close can reach the host after it. + let deliver!: () => void; + const held = new Promise<void>((resolve) => { deliver = resolve; }); + const host = realHost((request, send) => (request.op === op ? held.then(send) : send())); + if (name === 'launch naming it') { + acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: page, launchSession: session }).attachView(makeSink()); + } else if (name === 'pop-out') { + const first = withPort('first', { session, url: page }, 1111); + first.attachView(makeSink()); + await flushMicrotasks(); + first.setRenderMode('ab-popout'); + } else { + acquireAgentBrowserSurfaceController('first', { session, url: page }).attachView(makeSink()); + } + await flushMicrotasks(); + const sent = host.browser.mock.calls.map(([request]) => request).find((request) => request.op === op) as { requestId?: string }; + expect(sent.requestId).toEqual(expect.any(String)); + + await closeBrowserSurface('first', {}); + expect(host.browser.mock.calls.map(([request]) => request).find((request) => request.op === 'close')).toMatchObject({ cancels: [sent.requestId] }); + deliver(); + // Every request answered, the late one included. + await Promise.all(host.browser.mock.results.map(({ value }) => value)); + expect(host.lifecycle()).toEqual([`close ${session}`]); + }); + + it('closes a launch that was opening it before the next launch', async () => { + const host = realHost(); + host.fake.gate(`stop ${session}`); + acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: page, launchSession: session }) .attachView(makeSink()); await flushMicrotasks(); - await expectNextLaunchWaits(host, 'first', () => host.pending.open[0].resolve({ ok: true, session, wsPort: 4321 })); + await expectNextLaunchAfterClose(host, 'first', [`stop ${session}`, `open ${session} ${page}`]); }); - it('a launch released while it waits opens nothing', async () => { - const host = hostPlatform(); - acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: session }) + it('a launch closed before its turn opens nothing', async () => { + const host = realHost(); + host.fake.gate(`stop ${session}`); + acquireAgentBrowserSurfaceController('first', { renderMode: 'ab-screencast', url: page, launchSession: session }) .attachView(makeSink()); await flushMicrotasks(); void closeBrowserSurface('first', {}); - acquireAgentBrowserSurfaceController('next', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: session }) + acquireAgentBrowserSurfaceController('next', { renderMode: 'ab-screencast', url: page, launchSession: session }) .attachView(makeSink()); await flushMicrotasks(); // Swapped away again before the first launch landed. - void closeBrowserSurface('next', {}); - host.pending.open[0].resolve({ ok: true, session, wsPort: 4321 }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(host.platform.agentBrowserOpen).toHaveBeenCalledOnce(); + const closed = closeBrowserSurface('next', {}); + host.fake.release(`stop ${session}`); + await closed; + expect(host.lifecycle()).toEqual([`stop ${session}`, `open ${session} ${page}`, `close ${session}`, `close ${session}`]); }); - it('waits for a pop-out that was relaunching it', async () => { - const host = hostPlatform(); - const first = withPort('first', { session, url: 'http://localhost:6006/' }, 1111); + it('closes a pop-out that was relaunching it before the next launch', async () => { + const host = realHost(); + const first = withPort('first', { session, url: page }, 1111); first.attachView(makeSink()); await flushMicrotasks(); + host.fake.gate(`stop ${session}`); first.setRenderMode('ab-popout'); - await expectNextLaunchWaits(host, 'first', () => host.pending.popOut[0].resolve({ ok: true, wsPort: 4321 })); + await flushMicrotasks(); + await expectNextLaunchAfterClose(host, 'first', [`stop ${session}`, `open ${session} ${page} headed`]); }); - it('waits for an attach that was relaunching it', async () => { - const host = hostPlatform(); - acquireAgentBrowserSurfaceController('first', { session, url: 'http://localhost:6006/' }).attachView(makeSink()); + it('closes an attach that was relaunching it before the next launch', async () => { + const host = realHost(); + // Gone, but its name still held: the relaunch stops it first. + host.fake.provider.find = async () => ({ gone: 'not running', named: true }); + host.fake.gate(`stop ${session}`); + acquireAgentBrowserSurfaceController('first', { session, url: page }).attachView(makeSink()); await flushMicrotasks(); - expect(host.platform.agentBrowserAttach).toHaveBeenCalled(); - await expectNextLaunchWaits(host, 'first', () => host.pending.attach[0].resolve({ ok: true, wsPort: 4321, relaunched: true })); + expect(host.browser).toHaveBeenCalledWith(expect.objectContaining({ op: 'attach', url: page })); + await expectNextLaunchAfterClose(host, 'first', [`stop ${session}`, `open ${session} ${page}`]); }); }); describe('attach', () => { - function attachPlatform(attach: PlatformAdapter['agentBrowserAttach']) { - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserAttach' | 'agentBrowserCommand'>; - platform.agentBrowserAttach = vi.fn(attach); - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - setPlatform(platform); - return platform; + function attachHost(attach: NonNullable<BrowserAnswers['attach']>) { + return installBrowserHost({ attach }); } it('a restored pane attaches at the page and presentation it had', async () => { - const platform = attachPlatform(async () => ({ ok: true, wsPort: 2222 })); + const host = attachHost(async () => ({ ok: true, wsPort: 2222 })); const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', renderMode: 'ab-popout', url: 'https://restored.example/', }); @@ -861,12 +918,12 @@ describe('attach', () => { controller.attachView(sink); await flushMicrotasks(); - expect(platform.agentBrowserAttach).toHaveBeenCalledExactlyOnceWith('sess', { url: 'https://restored.example/', headed: true }, undefined); + expect(host.requests('attach')).toEqual([onSess({ op: 'attach', url: 'https://restored.example/', headed: true })]); expect(streamSocket(2222)?.readyState).toBe(1); }); it('streams from a port `dor` hands over, out of `ended` too', async () => { - attachPlatform(async () => ({ ok: false, error: 'not running' })); + attachHost(async () => ({ ok: false, error: 'not running' })); const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', url: 'https://page.example/' }); controller.attachView(makeSink()); await flushMicrotasks(); @@ -879,7 +936,7 @@ describe('attach', () => { }); it('a browser that cannot be reopened says why', async () => { - attachPlatform(async () => ({ ok: false, error: 'agent-browser binary not found' })); + attachHost(async () => ({ ok: false, error: 'agent-browser binary not found' })); const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', url: 'https://restored.example/' }); controller.attachView(makeSink()); await flushMicrotasks(); @@ -888,8 +945,8 @@ describe('attach', () => { }); /** A live pane on 1111, parked. */ - async function parkedAt1111(attach: PlatformAdapter['agentBrowserAttach']) { - const platform = attachPlatform(attach); + async function parkedAt1111(attach: NonNullable<BrowserAnswers['attach']>) { + const host = attachHost(attach); const controller = withPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); controller.attachView(makeSink()); await vi.advanceTimersByTimeAsync(0); @@ -898,20 +955,20 @@ describe('attach', () => { await vi.advanceTimersByTimeAsync(HIDDEN_PARK_DELAY_MS + 50); expect(controller.isParked()).toBe(true); expect(streamSocket(1111)?.readyState).toBe(3); - return { platform, controller }; + return { host, controller }; } it('never attaches while parked, and an unpark whose port still answers asks the host nothing', async () => { vi.useFakeTimers(); try { - const { platform, controller } = await parkedAt1111(async () => ({ ok: true, wsPort: 2222 })); + const { host, controller } = await parkedAt1111(async () => ({ ok: true, wsPort: 2222 })); // Hidden and shown again, a headless pane never left `live` for its view. expect(controller.snapshot().phase).toBe('live'); controller.setVisible(true); await vi.advanceTimersByTimeAsync(0); expect(streamSockets(1111)).toHaveLength(2); expect(streamSocket(1111)?.readyState).toBe(1); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); + expect(host.requests('attach')).toEqual([]); } finally { vi.useRealTimers(); } @@ -920,13 +977,13 @@ describe('attach', () => { it('an unpark whose port fails asks the host, without a page, where the stream moved', async () => { vi.useFakeTimers(); try { - const { platform, controller } = await parkedAt1111(async () => ({ ok: true, wsPort: 2222 })); + const { host, controller } = await parkedAt1111(async () => ({ ok: true, wsPort: 2222 })); WebSocketMock.failPorts.add(1111); controller.setVisible(true); await vi.advanceTimersByTimeAsync(0); // Without a page: a daemon gone while hidden has ended, it is not // relaunched behind the user's back. - expect(platform.agentBrowserAttach).toHaveBeenCalledExactlyOnceWith('sess', { url: undefined, headed: false }, undefined); + expect(host.requests('attach')).toEqual([onSess({ op: 'attach', url: undefined, headed: false })]); expect(streamSocket(2222)?.readyState).toBe(1); } finally { vi.useRealTimers(); @@ -940,7 +997,9 @@ describe('attach', () => { ])('an unpark drives nothing until the port it parked at %s', async (_name, answers, askedMeanwhile) => { vi.useFakeTimers(); try { - const platform = attachPlatform(async () => ({ ok: false, error: 'not running' })); + const host = attachHost(async () => ({ ok: false, error: 'not running' })); + // What reaches the browser: a stream URL is only the host's to build. + const sent = () => host.browser.mock.calls.map(([request]) => request).filter((request) => request.op !== 'streamUrl'); const sink = makeSink(); let size = { width: 800, height: 600 }; sink.viewport.getBoundingClientRect = () => ({ ...size }) as DOMRect; @@ -953,7 +1012,7 @@ describe('attach', () => { // While hidden: the Door comes back at another size, and a page is asked for. size = { width: 1000, height: 700 }; getAgentBrowserScreenController('id')!.chromeActions.navigate('https://next.example/'); - vi.mocked(platform.agentBrowserCommand!).mockClear(); + host.browser.mockClear(); // A daemon gone meanwhile would be started again by any CLI command. if (!answers) WebSocketMock.failPorts.add(1111); @@ -962,13 +1021,12 @@ describe('attach', () => { if (askedMeanwhile) getAgentBrowserScreenController('id')!.chromeActions.navigate('https://later.example/'); await vi.advanceTimersByTimeAsync(250); if (answers) { - expect(vi.mocked(platform.agentBrowserCommand!).mock.calls.map(([, args]) => args)).toEqual([ - ['set', 'viewport', '1000', '700', '1'], - ['open', askedMeanwhile ? 'https://later.example/' : 'https://next.example/'], + expect(sent()).toEqual([ + onSess({ op: 'viewport', width: 1000, height: 700, dpr: 1 }), + onSess({ op: 'navigate', url: askedMeanwhile ? 'https://later.example/' : 'https://next.example/' }), ]); } else { - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); - expect(platform.agentBrowserAttach).toHaveBeenCalledExactlyOnceWith('sess', { url: undefined, headed: false }, undefined); + expect(sent()).toEqual([onSess({ op: 'attach', headed: false })]); expect(controller.snapshot().phase).toBe('ended'); } } finally { @@ -990,7 +1048,7 @@ describe('attach', () => { }); it('a headless browser that drops ends, reached again only through attach or a handed-over port', async () => { - const platform = attachPlatform(async () => ({ ok: true, wsPort: 3333 })); + const host = attachHost(async () => ({ ok: true, wsPort: 3333 })); const controller = withPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1002,7 +1060,7 @@ describe('attach', () => { socket.emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); expect(controller.snapshot().phase).toBe('ended'); expect(socket.readyState).toBe(3); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); + expect(host.requests('attach')).toEqual([]); // `dor ab open` brings it back on the port it had. handOverBrowserPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); @@ -1016,30 +1074,29 @@ describe('attach', () => { streamSocket(1111)!.emitMessage(JSON.stringify({ type: 'status', connected: true, screencasting: true })); streamSocket(1111)!.emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); getAgentBrowserScreenController('id')!.chromeActions.navigate('https://next.example/'); - expect(platform.agentBrowserCommand).not.toHaveBeenCalledWith('sess', ['open', 'https://next.example/'], undefined); - expect(platform.agentBrowserAttach).toHaveBeenCalledExactlyOnceWith('sess', { url: 'https://next.example/', headed: false }, undefined); + expect(host.requests('navigate')).toEqual([]); + expect(host.requests('attach')).toEqual([onSess({ op: 'attach', url: 'https://next.example/', headed: false })]); await flushMicrotasks(); expect(streamSocket(3333)?.readyState).toBe(1); - expect(opens(platform)).toEqual(['https://next.example/']); + expect(host.requests('navigate')).toEqual([onSess({ op: 'navigate', url: 'https://next.example/' })]); }); it('a navigation that relaunches a gone daemon loads its page once', async () => { - const platform = attachPlatform(async () => ({ ok: false, error: 'not running' })); + const host = attachHost(async () => ({ ok: false, error: 'not running' })); acquireAgentBrowserSurfaceController('id', { session: 'sess', url: 'https://page.example/' }).attachView(makeSink()); await flushMicrotasks(); - vi.mocked(platform.agentBrowserAttach!).mockResolvedValue({ ok: true, wsPort: 3333, relaunched: true }); + host.answers.attach = async () => ({ ok: true, wsPort: 3333, relaunched: true }); getAgentBrowserScreenController('id')!.chromeActions.navigate('https://next.example/'); await flushMicrotasks(); - expect(platform.agentBrowserAttach).toHaveBeenLastCalledWith('sess', { url: 'https://next.example/', headed: false }, undefined); + expect(host.requests('attach').at(-1)).toEqual(onSess({ op: 'attach', url: 'https://next.example/', headed: false })); expect(streamSocket(3333)?.readyState).toBe(1); - expect(opens(platform)).toEqual([]); + expect(opens(host)).toEqual([]); }); it('does not query the daemon while a relaunch is in flight', async () => { - const platform = attachPlatform(async () => ({ ok: true, wsPort: 9999 })); - const popOut = vi.fn(() => new Promise<never>(() => {})); - Object.assign(platform, { agentBrowserPopOut: popOut }); + const host = attachHost(async () => ({ ok: true, wsPort: 9999 })); + host.answers.launch = () => new Promise<never>(() => {}); const controller = withPort('id', { session: 'sess' }, 1111); const sink = makeSink(); @@ -1047,13 +1104,13 @@ describe('attach', () => { await flushMicrotasks(); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); - vi.mocked(platform.agentBrowserCommand!).mockClear(); + expect(host.requests('launch')).toEqual([onSess({ op: 'launch', url: undefined, headed: true })]); + host.browser.mockClear(); // A stream drop mid-relaunch must not spawn a competing daemon. streamSocket(1111)?.emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); await flushMicrotasks(); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); + expect(host.browser).not.toHaveBeenCalled(); }); }); @@ -1077,18 +1134,15 @@ describe('dispose', () => { }); describe('closeBrowserSurface', () => { - function closePlatform() { - let resolvePopOut!: (res: { ok: boolean; wsPort?: number }) => void; - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopOut'>; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserPopOut = vi.fn(() => new Promise((r) => { resolvePopOut = r; })); - setPlatform(platform); - const closes = () => vi.mocked(platform.agentBrowserCommand!).mock.calls.filter(([, args]) => args[0] === 'close'); - return { platform, closes, resolvePopOut: (res: { ok: boolean; wsPort?: number }) => resolvePopOut(res) }; + function closeHost() { + const popOut = pending(); + const host = installBrowserHost({ launch: () => popOut.promise }); + const closes = () => host.requests('close'); + return { host, closes, resolvePopOut: popOut.resolve }; } - it('closes the session again when a relaunch in flight brings its daemon back', async () => { - const { closes, resolvePopOut } = closePlatform(); + it('closes the session once, at once, even with a relaunch in flight', async () => { + const { closes, resolvePopOut } = closeHost(); const controller = withPort('id', { session: 'sess' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1098,14 +1152,52 @@ describe('closeBrowserSurface', () => { expect(closes()).toHaveLength(1); expect(getAgentBrowserSurfaceController('id')).toBeNull(); + // The host runs that close after the relaunch, so closing again when it + // lands would close whoever launched the session next. resolvePopOut({ ok: true, wsPort: 3456 }); await flushMicrotasks(); - expect(closes()).toHaveLength(2); + expect(closes()).toHaveLength(1); expect(streamSockets(3456)).toHaveLength(0); }); + it('closes the session a launch names, at once', async () => { + const launch = pending(); + const host = installBrowserHost({ launch: () => launch.promise }); + acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-screencast', url: 'http://localhost:6006/', launchSession: 'dormouse.1.tool.t', binaryPath: '/opt/agent-browser' }) + .attachView(makeSink()); + await flushMicrotasks(); + + void closeBrowserSurface('id', {}); + // Through the binding the launch used, so the host orders the two. + expect(host.requests('close')).toEqual([{ provider: 'agent-browser', binding: host.requests('launch')[0].binding, op: 'close' }]); + expect(host.requests('close')[0].binding).toMatchObject({ session: 'dormouse.1.tool.t', binaryPath: '/opt/agent-browser' }); + launch.resolve({ ok: true, session: 'dormouse.1.tool.t', wsPort: 4321 }); + await flushMicrotasks(); + expect(host.requests('close')).toHaveLength(1); + }); + + it('cancels only its own requests the host has not answered', async () => { + const popIn = pending(); + const answers = [Promise.resolve<BrowserResult>({ ok: true, wsPort: 3456 }), popIn.promise]; + const host = installBrowserHost({ launch: () => answers.shift()! }); + const controller = withPort('id', { session: 'sess' }, 1111); + controller.attachView(makeSink()); + await flushMicrotasks(); + // A pop-out the host answered, then a pop-in still on its way. + controller.setRenderMode('ab-popout'); + await flushMicrotasks(); + controller.setRenderMode('ab-screencast'); + await flushMicrotasks(); + + void closeBrowserSurface('id', {}); + const sent = host.browser.mock.calls.map(([request]) => request as BrowserRequest & { requestId?: string; cancels?: string[] }); + const [popOut, pendingPopIn] = sent.filter((request) => request.op === 'launch'); + expect(popOut.requestId).not.toBe(pendingPopIn.requestId); + expect(sent.find((request) => request.op === 'close')?.cancels).toEqual([pendingPopIn.requestId]); + }); + it('a release that closes nothing leaves a relaunch in flight to whoever holds the session next', async () => { - const { closes, resolvePopOut } = closePlatform(); + const { closes, resolvePopOut } = closeHost(); const controller = withPort('id', { session: 'sess' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1118,28 +1210,29 @@ describe('closeBrowserSurface', () => { }); it('closes a session no controller holds from its params, with only a checked binary', async () => { - const { platform } = closePlatform(); + const { host } = closeHost(); closeBrowserSurface('never-mounted', { surfaceType: 'browser', renderMode: 'ab-screencast', session: 'sess', binaryPath: '/usr/bin/curl' }); closeBrowserSurface('iframe', { surfaceType: 'browser', renderMode: 'iframe', url: 'http://localhost:5173/' }); - expect(platform.agentBrowserCommand).toHaveBeenCalledExactlyOnceWith('sess', ['close'], undefined); + expect(host.browser).toHaveBeenCalledExactlyOnceWith(onSess({ op: 'close' })); }); }); describe('relaunch (pop-out / pop-in)', () => { - type RelaunchPlatform = FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopOut' | 'agentBrowserPopIn' | 'agentBrowserAttach'>; - function relaunchPlatform(): RelaunchPlatform & { resolvePopOut: (res: { ok: boolean; wsPort?: number }) => void } { - const platform = new FakePtyAdapter() as RelaunchPlatform; - let resolvePopOut!: (res: { ok: boolean; wsPort?: number }) => void; - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 9999 })); - platform.agentBrowserPopOut = vi.fn(() => new Promise<{ ok: boolean; wsPort?: number }>((r) => { resolvePopOut = r; })); - platform.agentBrowserPopIn = vi.fn(async () => ({ ok: true, wsPort: 5555 })); - setPlatform(platform); - return Object.assign(platform, { resolvePopOut: (res: { ok: boolean; wsPort?: number }) => resolvePopOut(res) }); + /** A host whose pop-out (a headed relaunch) waits for `resolvePopOut`, and + * whose pop-in answers port 5555. */ + function relaunchHost() { + const popOut = pending(); + const host = installBrowserHost({ + attach: async () => ({ ok: true, wsPort: 9999 }), + launch: (request) => request.headed ? popOut.promise : Promise.resolve({ ok: true, wsPort: 5555 }), + }); + /** The relaunches of a bound session, headed (pop-outs) or not (pop-ins). */ + const relaunches = (headed: boolean) => host.requests('launch').filter((request) => request.binding.session !== undefined && request.headed === headed); + return { ...host, resolvePopOut: popOut.resolve, relaunches }; } it('drops the stream up front and connects to the host\'s port only once the relaunch ends', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess' }, 1111); const sink = makeSink(); controller.attachView(sink); @@ -1156,19 +1249,19 @@ describe('relaunch (pop-out / pop-in)', () => { expect(controller.snapshot().poppedOut).toBe(true); // No daemon command while the relaunch is in flight: not even the popped-out // CDP observer's `get cdp-url`. - expect(platform.agentBrowserCommand).not.toHaveBeenCalledWith('sess', ['get', 'cdp-url'], undefined); + expect(host.requests('cdpUrl')).toEqual([]); - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await flushMicrotasks(); expect(controller.snapshot().phase).toBe('live'); expect(streamSockets(3456).length).toBe(1); expect(streamSockets(1111).length).toBe(1); - expect(platform.agentBrowserCommand).toHaveBeenCalledWith('sess', ['get', 'cdp-url'], undefined); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); + expect(host.requests('cdpUrl')).toEqual([onSess({ op: 'cdpUrl' })]); + expect(host.requests('attach')).toEqual([]); }); it('ignores a second pop-out or pop-in while one is in flight', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1178,32 +1271,33 @@ describe('relaunch (pop-out / pop-in)', () => { getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); await flushMicrotasks(); - expect(platform.agentBrowserPopOut).toHaveBeenCalledTimes(1); - expect(platform.agentBrowserPopIn).not.toHaveBeenCalled(); + expect(host.relaunches(true)).toHaveLength(1); + expect(host.relaunches(false)).toHaveLength(0); expect(controller.snapshot().poppedOut).toBe(true); - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await flushMicrotasks(); controller.popIn(); - expect(platform.agentBrowserPopIn).toHaveBeenCalledTimes(1); + expect(host.relaunches(false)).toHaveLength(1); }); it('pop-in while the first launch is in flight is a no-op', async () => { - const platform = relaunchPlatform(); - Object.assign(platform, { agentBrowserOpen: vi.fn(() => new Promise<never>(() => {})) }); + const host = relaunchHost(); + // The first launch opens a new session, and never answers. + host.answers.launch = () => new Promise<never>(() => {}); const controller = acquireAgentBrowserSurfaceController('id', { renderMode: 'ab-popout', url: 'https://page.example/' }); const sink = makeSink(); controller.attachView(sink); await flushMicrotasks(); controller.popIn(); - expect(platform.agentBrowserPopIn).not.toHaveBeenCalled(); + expect(host.requests('launch')).toEqual([{ provider: 'agent-browser', binding: {}, op: 'launch', url: 'https://page.example/', headed: true }]); expect(controller.snapshot()).toMatchObject({ poppedOut: true, phase: 'launching' }); expect(sink.updateParameters).not.toHaveBeenCalledWith({ renderMode: 'ab-screencast' }); }); it('a relaunch carries the URL the stream committed, not the one the last tabs snapshot reported', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://before.example/' }, 1111); const sink = makeSink(); controller.attachView(sink); @@ -1221,11 +1315,11 @@ describe('relaunch (pop-out / pop-in)', () => { expect(sink.updateParameters).toHaveBeenCalledWith({ url: 'https://slow.example/' }); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); - expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: 'https://slow.example/' }), undefined); + expect(host.relaunches(true)).toEqual([onSess({ op: 'launch', url: 'https://slow.example/', headed: true })]); }); it('relaunches at the last page the host can reopen, not a file: or data: tab', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://before.example/' }, 1111); const sink = makeSink(); controller.attachView(sink); @@ -1238,7 +1332,7 @@ describe('relaunch (pop-out / pop-in)', () => { expect(sink.updateParameters).not.toHaveBeenCalledWith({ url: 'file:///tmp/report.html' }); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); - expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: 'https://app.example/report' }), undefined); + expect(host.relaunches(true)).toEqual([onSess({ op: 'launch', url: 'https://app.example/report', headed: true })]); }); it('clears a stale title when navigation commits at the same URL', async () => { @@ -1269,9 +1363,7 @@ describe('relaunch (pop-out / pop-in)', () => { }); it('reaches no daemon from the header, Display modal, tabs, sync or edit chords mid-relaunch, and carries a navigation to the new browser', async () => { - const platform = relaunchPlatform(); - const edit = vi.fn(async () => ({ ok: true })); - Object.assign(platform, { agentBrowserEdit: edit }); + const host = relaunchHost(); const observers: ResizeObserverCallback[] = []; vi.stubGlobal('ResizeObserver', class { constructor(callback: ResizeObserverCallback) { observers.push(callback); } @@ -1295,7 +1387,8 @@ describe('relaunch (pop-out / pop-in)', () => { })); const screen = getAgentBrowserScreenController('id')!; screen.actions.setRenderMode?.('ab-popout'); - vi.mocked(platform.agentBrowserCommand!).mockClear(); + expect(host.relaunches(true)).toHaveLength(1); + host.browser.mockClear(); screen.chromeActions.back(); screen.chromeActions.forward(); @@ -1313,23 +1406,22 @@ describe('relaunch (pop-out / pop-in)', () => { await vi.advanceTimersByTimeAsync(250); controller.handleKeyDownLike({ key: 'a', code: 'KeyA', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }); await vi.advanceTimersByTimeAsync(0); - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); - expect(edit).not.toHaveBeenCalled(); + // Nothing at all reaches the host: no command, no edit, no capture. + expect(host.browser).not.toHaveBeenCalled(); // The relaunch lands: only the latest navigation runs, once. - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await vi.advanceTimersByTimeAsync(0); - const opens = vi.mocked(platform.agentBrowserCommand!).mock.calls.filter(([, args]) => args[0] === 'open'); - expect(opens).toEqual([['sess', ['open', 'https://latest.example/'], undefined]]); + expect(host.requests('navigate')).toEqual([onSess({ op: 'navigate', url: 'https://latest.example/' })]); } finally { vi.useRealTimers(); } }); it('keeps sync-to-pane out of a pop-in gap, and re-syncs once the headless browser streams', async () => { - const platform = relaunchPlatform(); - let resolvePopIn!: (res: { ok: boolean; wsPort?: number }) => void; - platform.agentBrowserPopIn = vi.fn(() => new Promise((r) => { resolvePopIn = r; })); + const host = relaunchHost(); + const popIn = pending(); + host.answers.launch = () => popIn.promise; const controller = withPort('id', { session: 'sess', renderMode: 'ab-popout' }, 1111); const sink = makeSink(); sink.viewport.getBoundingClientRect = () => ({ width: 800, height: 600 }) as DOMRect; @@ -1341,41 +1433,39 @@ describe('relaunch (pop-out / pop-in)', () => { expect(controller.snapshot().poppedOut).toBe(false); window.dispatchEvent(new Event('resize')); getAgentBrowserScreenController('id')!.actions.engageSync(); - const viewports = () => vi.mocked(platform.agentBrowserCommand!).mock.calls.filter(([, args]) => args[1] === 'viewport'); - expect(viewports()).toEqual([]); + expect(host.requests('viewport')).toEqual([]); - resolvePopIn({ ok: true, wsPort: 5555 }); + popIn.resolve({ ok: true, wsPort: 5555 }); await flushMicrotasks(); - expect(viewports()).toEqual([['sess', ['set', 'viewport', '800', '600', '1'], undefined]]); + expect(host.requests('viewport')).toEqual([onSess({ op: 'viewport', width: 800, height: 600, dpr: 1 })]); }); it('a pop-out asked for with a page relaunches there instead of navigating into the gap', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://before.example/' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); - vi.mocked(platform.agentBrowserCommand!).mockClear(); // The pane context menu's reuse of an existing port target. getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout', { url: 'http://localhost:5173/' }); - expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: 'http://localhost:5173/' }), undefined); - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + expect(host.relaunches(true)).toEqual([onSess({ op: 'launch', url: 'http://localhost:5173/', headed: true })]); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await flushMicrotasks(); - expect(platform.agentBrowserCommand).not.toHaveBeenCalledWith('sess', ['open', 'http://localhost:5173/'], undefined); + expect(host.requests('navigate')).toEqual([]); }); it('a navigation to the page a relaunch is opening loads it once', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); getAgentBrowserScreenController('id')!.chromeActions.navigate('https://page.example/'); - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await flushMicrotasks(); - expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: 'https://page.example/' }), undefined); - expect(opens(platform)).toEqual([]); + expect(host.relaunches(true)).toEqual([expect.objectContaining({ url: 'https://page.example/' })]); + expect(opens(host)).toEqual([]); }); it.each([ @@ -1384,7 +1474,7 @@ describe('relaunch (pop-out / pop-in)', () => { ])('a pop-out from a parked pane opens %s, once', async (_name, asked, opened) => { vi.useFakeTimers(); try { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); controller.attachView(makeSink()); await vi.advanceTimersByTimeAsync(0); @@ -1394,50 +1484,50 @@ describe('relaunch (pop-out / pop-in)', () => { getAgentBrowserScreenController('id')!.chromeActions.navigate('https://next.example/'); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout', asked ? { url: asked } : undefined); - expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: opened }), undefined); - platform.resolvePopOut({ ok: true, wsPort: 3456 }); + expect(host.relaunches(true)).toEqual([expect.objectContaining({ url: opened })]); + host.resolvePopOut({ ok: true, wsPort: 3456 }); await vi.advanceTimersByTimeAsync(0); - expect(opens(platform)).toEqual([]); + expect(opens(host)).toEqual([]); } finally { vi.useRealTimers(); } }); it('keeps a pop-out asked for before the browser is bound, and runs it with its page once live', async () => { - const platform = relaunchPlatform(); - let attached!: (res: { ok: boolean; wsPort?: number }) => void; - platform.agentBrowserAttach = vi.fn(() => new Promise((resolve) => { attached = resolve; })); + const host = relaunchHost(); + const attach = pending(); + host.answers.attach = () => attach.promise; const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', url: 'https://page.example/' }); // Before any view mounts it, as for a Door the context menu reveals. controller.setRenderMode('ab-popout', { url: 'http://localhost:5173/' }); controller.attachView(makeSink()); await flushMicrotasks(); - expect(platform.agentBrowserAttach).toHaveBeenCalled(); - expect(platform.agentBrowserPopOut).not.toHaveBeenCalled(); + expect(host.requests('attach')).toHaveLength(1); + expect(host.relaunches(true)).toEqual([]); - attached({ ok: true, wsPort: 1111 }); + attach.resolve({ ok: true, wsPort: 1111 }); await flushMicrotasks(); - expect(platform.agentBrowserPopOut).toHaveBeenCalledExactlyOnceWith('sess', expect.objectContaining({ url: 'http://localhost:5173/' }), undefined); + expect(host.relaunches(true)).toEqual([onSess({ op: 'launch', url: 'http://localhost:5173/', headed: true })]); }); it('a failed pop-out comes back in the pane, relaunching headless at its page if no daemon came up', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://page.example/' }, 1111); const sink = makeSink(); controller.attachView(sink); await flushMicrotasks(); getAgentBrowserScreenController('id')?.actions.setRenderMode?.('ab-popout'); - platform.resolvePopOut({ ok: false }); + host.resolvePopOut({ ok: false }); await flushMicrotasks(); - expect(platform.agentBrowserAttach).toHaveBeenCalledExactlyOnceWith('sess', { url: 'https://page.example/', headed: false }, undefined); + expect(host.requests('attach')).toEqual([onSess({ op: 'attach', url: 'https://page.example/', headed: false })]); expect(sink.updateParameters).toHaveBeenLastCalledWith({ renderMode: 'ab-screencast' }); expect(controller.snapshot()).toMatchObject({ poppedOut: false, phase: 'live' }); expect(streamSocket(9999)?.readyState).toBe(1); }); it('a `dor ab` re-run handing over a new port reconnects there and asks the daemon nothing', async () => { - const platform = relaunchPlatform(); + const host = relaunchHost(); const controller = withPort('id', { session: 'sess', url: 'https://x.example/' }, 1111); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1447,19 +1537,15 @@ describe('relaunch (pop-out / pop-in)', () => { await flushMicrotasks(); expect(streamSocket(1111)?.readyState).toBe(3); expect(streamSockets(4321)).toHaveLength(1); - expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); + expect(host.requests('attach')).toEqual([]); }); }); describe('Playwright provider', () => { it('pastes as whole-text messages the host inserts, not a key pair per character', async () => { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = vi.fn(async request => request.op === 'streamUrl' - ? { ok: true, url: `ws://127.0.0.1:${request.port}` } - : { ok: true, exitCode: 0, stdout: '', stderr: '' }); + const host = installBrowserHost({ streamUrl: async (request) => ({ ok: true, url: `ws://127.0.0.1:${request.port}` }) }); const pasted = `${'x'.repeat(PLAYWRIGHT_TEXT_INPUT_MAX + 10)}\r\nend`; - platform.readClipboardText = vi.fn(async () => pasted); - setPlatform(platform); + (host.platform as PlatformAdapter).readClipboardText = vi.fn(async () => pasted); const controller = withPort('pw', { renderMode: 'pw-screencast', session: 's' }, 4321); controller.attachView(makeSink()); await flushMicrotasks(); @@ -1475,11 +1561,7 @@ describe('Playwright provider', () => { }); it('names Playwright in a failed host command warning', async () => { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.playwright = vi.fn(async request => request.op === 'command' - ? { ok: false, error: 'boom', exitCode: 1, stdout: '', stderr: 'boom' } - : { ok: true }); - setPlatform(platform); + installBrowserHost({ history: async () => ({ ok: false, error: 'boom' }) }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const controller = withPort('pw', { renderMode: 'pw-screencast', session: 's' }, 4321); controller.attachView(makeSink()); @@ -1489,22 +1571,18 @@ describe('Playwright provider', () => { }); it('uses the shared controller with provider-scoped host calls and cwd', async () => { - const platform: PlatformAdapter = new FakePtyAdapter(); - platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); // The swap back to agent-browser is offered only where the host can launch one. - platform.agentBrowserOpen = vi.fn(async () => ({ ok: true })); - platform.playwright = vi.fn(async request => request.op === 'streamUrl' - ? { ok: true, url: `ws://127.0.0.1:${request.port}` } - : { ok: true, exitCode: 0, stdout: '', stderr: '', wsPort: 4321 }); - setPlatform(platform); + const host = installBrowserHost({ streamUrl: async (request) => ({ ok: true, url: `ws://127.0.0.1:${request.port}` }) }); const controller = withPort('pw', { renderMode: 'pw-screencast', session: 'shared-name', cwd: '/first-project' }, 4321); const sink = makeSink(); controller.attachView(sink); await flushMicrotasks(); getAgentBrowserScreenController('pw')!.chromeActions.navigate('https://example.com/next'); await flushMicrotasks(); - expect(platform.playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'command', session: 'shared-name', cwd: '/first-project', args: ['open', 'https://example.com/next'] })); - expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); + expect(host.requests('navigate')).toEqual([{ + provider: 'playwright', binding: { session: 'shared-name', cwd: '/first-project' }, op: 'navigate', url: 'https://example.com/next', + }]); + expect(host.browser.mock.calls.filter(([request]) => request.provider !== 'playwright')).toEqual([]); expect(getAgentBrowserScreenController('pw')!.snapshot().renderMode).toBe('pw-screencast'); getAgentBrowserScreenController('pw')!.actions.setRenderMode?.('ab-screencast'); expect(sink.requestRenderSwap).toHaveBeenCalledWith('ab-screencast'); diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 3a0917290..7940ad63e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -1,14 +1,12 @@ /** * Surface-scoped browser lifecycle; see docs/specs/dor-browser.md → - * "Agent-Browser Connection". The registry survives panel unmount and is + * "Browser Connection". The registry survives panel unmount and is * released by Wall on kill/render swap: `closeBrowserSurface` closes the * session too, `disposeAgentBrowserSurfaceController` only the client side. */ -import type { AgentBrowserAttachResult, AgentBrowserCommandResult, AgentBrowserOpenResult } from '../../lib/platform/types'; -import { isBrowsableUrl, playwrightTextInputs, type BrowserAutomationProvider } from '../../lib/platform/browser-automation'; +import { BROWSER_CLOSE_MAX_CANCELS, isBlankUrl, isBrowsableUrl, type BrowserAutomationProvider, type BrowserResult } from '../../lib/platform/browser-automation'; import { isAllowedBinaryFor } from '../../lib/agent-browser-binary'; import { readTextFromClipboard } from '../../lib/clipboard'; -import { messageOf } from '../../lib/errors'; import { isAbDebugLogsEnabled } from '../../lib/feature-flags'; import { registerAgentBrowserScreen, @@ -22,17 +20,19 @@ import { openAgentBrowserScreenModal, } from './agent-browser-screen'; import { hostPathDisplay, tabDisplayTitle } from './browser-url'; +import { parseRenderMode } from 'dor-lib-common/browser-providers'; import { - automationMode, - automationProvider, - browserPlatform, - isPopout, + BROWSER_PROVIDER_GUI, + browserHandle, + headedRenderMode, + hostSupportsBrowser, + isHeadedMode, offeredRenderModes, - PROVIDER_LABEL, launchBinaryPath, + providerUnavailable, rememberLaunchBinaryPath, surfaceProvider, - type BrowserPlatform, + type BrowserHandle, } from './browser-automation'; import { agentBrowserSessionFromParams, isToolParams } from './browser-surface'; import { @@ -89,34 +89,7 @@ function dprMatch(a: number, b: number): boolean { // A stray about:blank the close+reopen of a relaunch can surface is never the // page the pane shows. function isShownUrl(url: string | null | undefined): url is string { - if (typeof url !== 'string') return false; - const trimmed = url.trim(); - return trimmed !== '' && trimmed !== 'about:blank'; -} - -function parseCdpUrl(stdout: string): string | null { - const trimmed = stdout.trim(); - if (!trimmed) return null; - try { - const parsed = JSON.parse(trimmed) as { data?: { result?: unknown }; result?: unknown; url?: unknown }; - const value = parsed.data?.result ?? parsed.result ?? parsed.url; - if (typeof value === 'string' && value.startsWith('ws://')) return value; - } catch { - // Plain text is the common CLI output. - } - return trimmed.match(/ws:\/\/\S+/)?.[0] ?? null; -} - -/** Best-effort screen rect for positioning a popped-out window over the pane. - * VS Code webviews can't read true screen coords (the host then centers); on - * standalone, window.screenX/Y offset the pane's viewport rect into screen - * space. */ -function paneScreenRect(el: HTMLElement | null | undefined): { x: number; y: number; width: number; height: number } | undefined { - if (!el) return undefined; - const r = el.getBoundingClientRect(); - const sx = typeof window.screenX === 'number' ? window.screenX : 0; - const sy = typeof window.screenY === 'number' ? window.screenY : 0; - return { x: Math.round(sx + r.left), y: Math.round(sy + r.top), width: Math.round(r.width), height: Math.round(r.height) }; + return typeof url === 'string' && !isBlankUrl(url); } /** The DOM-free key shape the controller's keyboard bridge consumes. A @@ -164,15 +137,16 @@ function allowedBinaryPath(candidate: unknown, provider: BrowserAutomationProvid /** * Where the Surface's browser is in its life (docs/specs/dor-browser.md → - * "Agent-Browser Connection" has the transition table). The stream connection + * "Browser Connection" has the transition table). The stream connection * exists exactly in `live`, and so does every daemon command (`driver`). */ type Phase = /** Constructed; no view has started it yet. */ | { k: 'idle' } /** No session yet: opening `url` in a browser whose session binds on - * success — in `session` when the launch names one. */ - | { k: 'launching'; session?: string } + * success — `named` when the launch names one, which a close of the + * Surface meanwhile closes too, through the launch's own binding. */ + | { k: 'launching'; named?: { session: string; browser: BrowserHandle } } /** The session's stream port is being asked of the host (`attach`). */ | { k: 'attaching' } /** Streaming from `port`. `seen`: the stream has reported its browser @@ -188,14 +162,11 @@ type Phase = | { k: 'relaunching' } /** Nothing to show: the browser went away, or `error` kept it from opening. */ | { k: 'ended'; error?: string } - /** Released; `closed` when its session was closed with it, so work still in - * flight closes whatever it brings back up. */ - | { k: 'disposed'; closed: boolean }; + /** Released: nothing here runs again. */ + | { k: 'disposed' }; export type BrowserSurfacePhase = Phase['k']; -type Driver = { platform: BrowserPlatform; session: string; binaryPath: string | undefined }; - /** The live DOM bindings a mounted view lends the controller. `attachView` * wires these; `detach()` returns them. */ export interface AgentBrowserViewSink { @@ -238,11 +209,9 @@ export class AgentBrowserSurfaceController { readonly id: string; readonly provider: BrowserAutomationProvider; private cwd?: string; - /** Rebuilt only when `cwd` changes: the Playwright adapter closes over it, - * and every stream frame reads it several times. */ - private platformCache: BrowserPlatform | null = null; - private get platform(): BrowserPlatform { - return this.platformCache ??= browserPlatform(this.provider, this.cwd); + /** Whether this host can drive the Surface's provider at all. */ + private get hosted(): boolean { + return hostSupportsBrowser(this.provider); } /** Gates the render modes offered; see `ensureStarted`. */ private readonly isTool: boolean; @@ -257,6 +226,7 @@ export class AgentBrowserSurfaceController { } private phase: Phase = { k: 'idle' }; + private handleCache: { session?: string; cwd?: string; binaryPath?: string; handle: BrowserHandle | null } | null = null; /** The presentation this Surface shows: a separate OS window, or in the pane. * Seeded from `renderMode`; changed by a relaunch (optimistically, reverted * if it fails) or, for Playwright, by a native relaunch the host reports. */ @@ -302,6 +272,11 @@ export class AgentBrowserSurfaceController { * (`launchUrl`), and a host that opened it settles it (`openedByHost`). */ private pendingIntent: { url?: string; headed?: boolean } = {}; + /** Requests this Surface sent that can bring its browser up — a launch, a + * relaunch, an attach naming a page — which the host has not answered: a + * close cancels them, however late the transport delivers them (`close`). */ + private bringingUp = new Set<string>(); + // --- sync-to-pane --- private syncEngaged: boolean; private device = { width: 1280, height: 720 }; @@ -325,7 +300,7 @@ export class AgentBrowserSurfaceController { // content-box size cached (the viewport div has no border/padding, so // contentRect matches the gBCR those hot paths used to read). null ⇒ no attached // view — treat as 0×0 / skip, matching the old no-element behavior. The - // correctness-critical reads in issueSyncToPane / paneScreenRect stay live gBCR. + // correctness-critical read in issueSyncToPane stays live gBCR. // The same observer also drives viewport-sync (debounced), so there is one // observer on the pane, not two. private paneSize: { w: number; h: number } | null = null; @@ -377,8 +352,6 @@ export class AgentBrowserSurfaceController { private pendingParams = new Map<string, unknown>(); private pendingTitle: string | null = null; private pendingLaunchFailure: string | null = null; - /** Host work in flight that can bring the session up (`track`). */ - private landings = new Set<Promise<void>>(); // The value this controller last wrote to each field it also takes from // params, until params show it back. Params predating the write — buffered // while detached, then fed by a remounted view before the flush, or a render @@ -406,7 +379,7 @@ export class AgentBrowserSurfaceController { this.latestRestorableUrl = isBrowsableUrl(params.url) ? params.url : undefined; // Headedness is derived from the canonical renderMode; an unset mode (a // direct mount in tests) is not popped out. - this.headed = isPopout(params.renderMode); + this.headed = isHeadedMode(params.renderMode); // A fresh surface auto-engages sync (no persisted flag); a re-attached one // restores whatever was persisted into the layout blob. this.syncEngaged = params.syncEngaged ?? true; @@ -426,12 +399,12 @@ export class AgentBrowserSurfaceController { applyDevice: (name) => { this.lastIssued = null; this.setSyncEngaged(false); - this.runCommand(['set', 'device', name]); + this.drive(`set device ${name}`, (browser) => browser.device(name)); }, applyViewport: (w, h, dpr) => { this.lastIssued = null; this.setSyncEngaged(false); - this.runCommand(['set', 'viewport', String(w), String(h), String(dpr)]); + this.drive(`set viewport ${w} ${h} ${dpr}`, (browser) => browser.viewport(w, h, dpr)); }, openModal: () => openAgentBrowserScreenModal(this.id), setRenderMode: (mode, opts) => this.setRenderMode(mode, opts), @@ -440,9 +413,9 @@ export class AgentBrowserSurfaceController { // Native history nav — issued like tab actions, through the daemon gate. this.chromeActions = { navigate: (url) => this.navigate(url), - back: () => this.runCommand(['back']), - forward: () => this.runCommand(['forward']), - reload: () => this.runCommand(['reload']), + back: () => this.drive('back', (browser) => browser.history('back')), + forward: () => this.drive('forward', (browser) => browser.history('forward')), + reload: () => this.drive('reload', (browser) => browser.history('reload')), }; this.viewSnapshot = this.buildViewSnapshot(); @@ -498,11 +471,22 @@ export class AgentBrowserSurfaceController { */ setRenderMode(mode: RenderMode, opts?: { url?: string }): void { if (!this.renderModes.includes(mode)) return; - if (automationProvider(mode) !== this.provider) this.sink?.requestRenderSwap(mode); - else if (isPopout(mode) !== this.headed) this.relaunch(isPopout(mode), opts?.url); + const { provider, presentation } = parseRenderMode(mode); + const headed = presentation === 'popout'; + if (provider !== this.provider) this.sink?.requestRenderSwap(mode); + else if (headed !== this.headed) this.relaunch(headed, opts?.url); else if (opts?.url) this.navigate(opts.url); } + /** The render mode this Surface shows now. */ + private renderMode(): RenderMode { + return headedRenderMode(this.provider, this.headed); + } + + private get label(): string { + return BROWSER_PROVIDER_GUI[this.provider].label; + } + getDeviceSize(): { width: number; height: number } { return this.device; } @@ -517,7 +501,7 @@ export class AgentBrowserSurfaceController { actions: this.screenActions, chrome: this.chrome, chromeActions: this.chromeActions, - hostCapable: !!this.platform.agentBrowserCommand, + hostCapable: this.hosted, renderModes: this.renderModes, }); this.lastPublishedScreen = null; @@ -652,10 +636,7 @@ export class AgentBrowserSurfaceController { if (this.phase.k === 'disposed') return; // Mirror every field first, then rebind once: binding per field would bind // a new session with the cwd or binary of the old. - if (params.cwd !== undefined && params.cwd !== this.cwd) { - this.cwd = params.cwd; - this.platformCache = null; - } + if (params.cwd !== undefined) this.cwd = params.cwd; // First, so neither a stream this rebinds nor a port handed over next // (`handOverBrowserPort`) inherits a `set viewport` meant for the old mode. if (params.renderMode && this.echoed('renderMode', params.renderMode)) this.followParamsHeadedness(params.renderMode); @@ -701,15 +682,15 @@ export class AgentBrowserSurfaceController { } /** - * Playwright's native `open` can change headedness outside the Display modal, - * and the Wall records the host-reported mode in params - * (`ensureAgentBrowserSurface`). Everything else that arrives here is this - * controller's own popOut/popIn write coming back, so agent-browser ignores - * it, and so does a relaunch in flight. + * A provider's native launch (Playwright's `open --headed`) can change + * headedness outside the Display modal; the host reports it, and the Wall + * records it in params (`ensureBrowserSurface`). This controller's own + * popOut/popIn write never reaches here (`echoed`), and a relaunch in flight + * owns the mode. */ private followParamsHeadedness(renderMode: RenderMode): void { - if (this.provider !== 'playwright' || this.phase.k === 'relaunching') return; - const headed = isPopout(renderMode); + if (this.phase.k === 'relaunching') return; + const headed = isHeadedMode(renderMode); if (headed === this.headed) return; // The last status came from the old browser; auto-revert waits for the // new stream's own before it treats a disconnect as the window closing. @@ -765,7 +746,7 @@ export class AgentBrowserSurfaceController { const { url, headed } = this.pendingIntent; this.pendingIntent = {}; if (headed !== undefined && headed !== this.headed) this.relaunch(headed, url); - else if (url) this.runCommand(['open', url]); + else if (url) this.drive(`open ${url}`, (browser) => browser.navigate(url)); } /** (Re)bind the current params: no session launches one, a session without a @@ -802,43 +783,41 @@ export class AgentBrowserSurfaceController { * Wall's `whenBrowserLaunched` hears the outcome. */ private launch(): void { - const platform = this.platform; const url = this.launchUrl(); const session = this.launchSession; - const phase: Phase = { k: 'launching', ...(session ? { session } : {}) }; - this.setPhase(phase); const headed = this.headed; // No creation site has to remember the binary a `dor ab` surface resolved. const binaryPath = this.binaryPath ?? launchBinaryPath(this.provider); - // A named session's close still in flight — a failed swap reopening the - // previous provider's — would land after this open and shut it. - const opening = session ? closeLanded(this.provider, this.cwd, session) : Promise.resolve(); - // Call through the adapter instance — detaching the method drops `this`. + const browser = browserHandle(this.provider, { session, cwd: this.cwd, binaryPath }); + const phase: Phase = { k: 'launching', ...(session && browser ? { named: { session, browser } } : {}) }; + this.setPhase(phase); // A launch that cannot start settles as late as one that fails, so whoever - // created this Surface is always listening by then. - const opened: Promise<AgentBrowserOpenResult> = !platform.agentBrowserOpen - ? Promise.resolve({ ok: false, error: `${PROVIDER_LABEL[this.provider]} is unavailable on this host` }) + // created this Surface is always listening by then. A named one is sent + // only once every close of its session this webview sent has been + // answered — a failed swap reopening the previous provider's, a Tool + // re-run's — so no transport can deliver it first; released meanwhile, it + // opens nothing for a closed Surface. + const closing = session === undefined ? undefined : closeInFlight(this.provider, session); + const opened: Promise<BrowserResult> = !browser + ? Promise.resolve({ ok: false, error: providerUnavailable(this.provider) }) : !url ? Promise.resolve({ ok: false, error: 'no page to open' }) - // Released while it waited, it opens nothing for a closed Surface. - : opening.then(() => this.phase === phase - ? platform.agentBrowserOpen!(url, { headed, ...(session ? { session } : {}) }, binaryPath) - : { ok: false }); - this.track(opened - .catch((err: unknown): AgentBrowserOpenResult => ({ ok: false, error: messageOf(err) })) + : !closing + ? this.bringUp((requestId) => browser.launch(url, headed, requestId)) + : closing.then(() => this.phase === phase ? this.bringUp((requestId) => browser.launch(url, headed, requestId)) : { ok: false }); + opened .then((res) => { if (this.phase !== phase) { - // The browser that came up belongs to nobody: close it — a session - // the host minted, or any once the Surface closed. A named one left - // otherwise is whoever holds that session next (a Workspace - // transfer's destination opens the same one), or this Surface's own. - const closed = this.phase.k === 'disposed' && this.phase.closed; - return res.session && (closed || !session) - ? closeSessionOn(this.provider, res.cwd ?? this.cwd, res.session, res.binaryPath) - : undefined; + // The browser that came up belongs to nobody: close a session the + // host minted, which only this answer names. A named one a close of + // this Surface already closed after it (`close`); one left otherwise + // is whoever holds that session next (a Workspace transfer's + // destination opens the same one). + if (res.session && !session) void closeSessionOn(this.provider, res.cwd ?? this.cwd, res.session, res.binaryPath); + return; } if (!res.ok || !res.session) { - const error = res.error ?? `Could not open ${PROVIDER_LABEL[this.provider]}`; + const error = res.error ?? `Could not open ${this.label}`; this.setPhase({ k: 'ended', error }); settleLaunch(this.id, error); this.reportLaunchFailure(error); @@ -846,10 +825,7 @@ export class AgentBrowserSurfaceController { } rememberLaunchBinaryPath(this.provider, res.binaryPath); this.session = res.session; - if (res.cwd !== undefined && res.cwd !== this.cwd) { - this.cwd = res.cwd; - this.platformCache = null; - } + if (res.cwd !== undefined) this.cwd = res.cwd; this.binaryPath = allowedBinaryPath(res.binaryPath, this.provider) ?? allowedBinaryPath(binaryPath, this.provider); this.writeParams({ session: res.session, @@ -865,8 +841,7 @@ export class AgentBrowserSurfaceController { if (res.wsPort) this.goLive(res.wsPort); else this.attach(false); settleLaunch(this.id, null); - return undefined; - })); + }); } /** Tell the Wall this Surface's first launch failed: it applies the @@ -900,49 +875,37 @@ export class AgentBrowserSurfaceController { private attach(relaunch: boolean): void { const session = this.session; if (!session) { this.launch(); return; } - const platform = this.platform; - if (!platform.agentBrowserAttach) { + const browser = this.handle(); + if (!browser) { this.setPhase({ k: 'ended' }); return; } const phase: Phase = { k: 'attaching' }; this.setPhase(phase); const url = relaunch ? this.launchUrl() : undefined; - // Call through the adapter instance — pulling the method into a bare - // variable would detach `this` and break its internal `requestResponse`. - const attached = platform.agentBrowserAttach(session, { url, headed: this.headed }, this.binaryPath) - .catch((err: unknown): AgentBrowserAttachResult => ({ ok: false, error: messageOf(err) })) + // A browser this relaunches for a Surface closed meanwhile is closed by + // the host: it runs the close after this attach, or cancels it (`close`). + (url ? this.bringUp((requestId) => browser.attach({ url, headed: this.headed, requestId })) : browser.attach({ headed: this.headed })) .then((res) => { - if (this.phase !== phase) return url ? this.closeIfClosedMeanwhile(session) : undefined; + if (this.phase !== phase) return; // Only a gone daemon is relaunched at the page; a live one was only // found, so a navigation pending to it still has to run. if (res.relaunched) this.openedByHost(url); if (res.ok && res.wsPort) this.goLive(res.wsPort); else this.setPhase({ k: 'ended', error: relaunch ? res.error : undefined }); - return undefined; }); - // Only one naming a page can bring the browser back up. - if (url) this.track(attached); - } - - /** The Surface was closed while `session` had work in flight that can bring - * its daemon back up — a relaunch, or an attach that relaunches — so close - * it again once that work lands. */ - private closeIfClosedMeanwhile(session: string): Promise<void> | undefined { - return this.phase.k === 'disposed' && this.phase.closed ? this.closeSession(session) : undefined; - } - - /** Host work that can bring the session's browser up — a launch, relaunch - * or relaunching attach — held until it has landed, and closed again what - * it brought back for a Surface closed meanwhile (`close`). */ - private track(work: Promise<unknown>): void { - const landed = work.then(() => {}, () => {}); - this.landings.add(landed); - void landed.then(() => this.landings.delete(landed)); } - private closeSession(session: string): Promise<void> { - return closeSessionOn(this.provider, this.cwd, session, this.binaryPath); + /** Send a request that can bring the browser up under a fresh id, held in + * `bringingUp` until the host answers it. */ + private bringUp(send: (requestId: string) => Promise<BrowserResult>): Promise<BrowserResult> { + const requestId = crypto.randomUUID(); + this.bringingUp.add(requestId); + // A handle answers failures rather than rejecting. + return send(requestId).then((result) => { + this.bringingUp.delete(requestId); + return result; + }); } // --- parking --- @@ -1003,11 +966,9 @@ export class AgentBrowserSurfaceController { // reconnect always re-creates it — a disposed loop would silently drop every // frame pulse. const screenshotLoop = createScreenshotLoop({ - capture: (opts) => { - const driver = this.driver(); - return driver?.platform.agentBrowserScreenshot?.(driver.session, opts, driver.binaryPath) ?? null; - }, - isCapable: () => !!this.driver()?.platform.agentBrowserScreenshot, + capture: (opts) => this.driver()?.screenshot(opts) ?? null, + // Checked per stream frame: the gate, without building a handle. + isCapable: () => this.phase.k === 'live' && !!this.session && this.hosted, draw: this.drawBitmap, // A re-attach bumps drawGeneration so a fresh (blank) canvas repaints even // when the capture bytes are identical to the last displayed frame. @@ -1019,10 +980,13 @@ export class AgentBrowserSurfaceController { const connection = createAgentBrowserConnection({ session, streamPort, - binaryPath: this.binaryPath, - getStreamUrl: async (port) => (await this.platform.getAgentBrowserStreamUrl?.(port)) ?? undefined, - runCommand: (_session, args) => this.command(args) - ?? Promise.resolve({ exitCode: 1, stdout: '', stderr: `${PROVIDER_LABEL[this.provider]} commands unavailable` }), + getStreamUrl: async (port) => { + const answer = await this.handle()?.streamUrl(port); + if (answer && !answer.ok) throw new Error(answer.error ?? `${this.label} stream unavailable`); + return answer?.url; + }, + selectTab: (tabId) => this.driver()?.tab('select', tabId) + ?? Promise.resolve({ ok: false, error: `${this.label} commands unavailable` }), canSelectTabs: () => !this.headed, wantFrameData: () => this.wantsProvisionalFrame(), log: abDebugLog, @@ -1154,7 +1118,7 @@ export class AgentBrowserSurfaceController { } private wantsProvisionalFrame(): boolean { - const forInput = !this.hasFrame || !this.platform.agentBrowserScreenshot || performance.now() <= this.provisionalUntil; + const forInput = !this.hasFrame || !this.hosted || performance.now() <= this.provisionalUntil; this.paintingForOverdue = !forInput; return forInput || !!this.screenshotLoop?.captureOverdue(); } @@ -1167,7 +1131,7 @@ export class AgentBrowserSurfaceController { if (this.provider === 'playwright') return; // The host stream also observes headed navigation. const phase = this.phase; // `get cdp-url` is a daemon command, so it waits for `live` like every other. - const desired = phase.k === 'live' && this.headed && !!this.platform.agentBrowserCommand; + const desired = phase.k === 'live' && this.headed && this.hosted; const key = desired ? `${this.session}:${phase.port}` : null; if (key === this.cdpKey) return; this.cdpTeardown?.(); @@ -1216,14 +1180,9 @@ export class AgentBrowserSurfaceController { }; const connect = async () => { - let cdpUrl: string | null = null; - try { - const result = await this.command(['get', 'cdp-url']); - if (result?.exitCode === 0) cdpUrl = parseCdpUrl(result.stdout); - else if (result) abDebugLog(`[ab-panel] cdp-url failed ${JSON.stringify({ stderr: result.stderr, stdout: result.stdout })}`); - } catch (err) { - abDebugLog(`[ab-panel] cdp-url error ${String(err)}`); - } + const result = await this.driver()?.cdpUrl(); + const cdpUrl = result?.ok ? result.url ?? null : null; + if (result && !cdpUrl) abDebugLog(`[ab-panel] cdp-url failed ${JSON.stringify({ error: result.error })}`); if (disposed || !cdpUrl) return; abDebugLog(`[ab-panel] connecting cdp ${JSON.stringify({ cdpUrl })}`); ws = new WebSocket(cdpUrl); @@ -1402,7 +1361,7 @@ export class AgentBrowserSurfaceController { // DPR can't be read back from frames, so report the density we'd sync to. const viewport = { w: device.width, h: device.height, dpr: displayDpr }; const state: ScreenState = dimsMatch(viewport, paneCss) ? 'SYNCED' : 'SCALED'; - const renderMode = automationMode(this.provider, this.headed); + const renderMode = this.renderMode(); return { state, viewport, paneCss, displayDpr, syncEngaged: this.syncEngaged, renderMode }; } @@ -1437,10 +1396,10 @@ export class AgentBrowserSurfaceController { // never force its viewport to the (now-stub) pane size. Sync resumes when it // pops back in — the new port's reclaim re-issues against the fresh session. if (this.headed) return; - // Hosts without agentBrowserCommand (e.g. the web demo) can't drive the - // viewport; stay silent rather than warn on every resize — the surface just - // reads SCALED. - if (!this.platform.agentBrowserCommand) return; + // A host that cannot drive the provider (e.g. the web demo) can't size + // the viewport; stay silent rather than warn on every resize — the surface + // just reads SCALED. + if (!this.hosted) return; const el = this.sink?.viewport; if (!el) return; const rect = el.getBoundingClientRect(); @@ -1452,7 +1411,7 @@ export class AgentBrowserSurfaceController { if (prev && prev.w === w && prev.h === h && dprMatch(prev.dpr, dpr)) return; this.lastIssued = { w, h, dpr }; this.syncConfirmed = false; - this.runCommand(['set', 'viewport', String(w), String(h), String(dpr)]); + this.drive(`set viewport ${w} ${h} ${dpr}`, (browser) => browser.viewport(w, h, dpr)); } // Last-writer-wins: drop sync when an external `dor ab set …` takes the @@ -1496,9 +1455,8 @@ export class AgentBrowserSurfaceController { */ private relaunch(headed: boolean, url?: string): void { const session = this.session; - const platform = this.platform; const k = this.phase.k; - const capable = headed ? !!platform.agentBrowserPopOut : !!platform.agentBrowserPopIn; + const capable = this.hosted; if (!capable || !session || (k !== 'live' && k !== 'parked' && k !== 'ended')) { // Before the browser is bound, the request waits for it; one arriving // mid-relaunch is dropped — one relaunch at a time. @@ -1518,29 +1476,25 @@ export class AgentBrowserSurfaceController { const phase: Phase = { k: 'relaunching' }; this.setPhase(phase); this.setHeaded(headed); - this.writeParams({ renderMode: automationMode(this.provider, headed) }); + this.writeParams({ renderMode: this.renderMode() }); abDebugLog(`[ab-panel] ${headed ? 'popOut' : 'popIn'} -> ${JSON.stringify({ session, url: target })}`); - // Call through the adapter instance — detaching the method drops `this`. - const relaunched = headed - ? platform.agentBrowserPopOut!(session, { rect: paneScreenRect(this.sink?.viewport), url: target }, this.binaryPath) - : platform.agentBrowserPopIn!(session, { url: target }, this.binaryPath); - this.track(relaunched.catch((err: unknown) => ({ ok: false, wsPort: undefined, error: messageOf(err) })).then((res) => { + this.bringUp((requestId) => this.handle()!.launch(target, headed, requestId)).then((res) => { abDebugLog(`[ab-panel] relaunch result ${JSON.stringify(res)}`); - if (this.phase !== phase) return this.closeIfClosedMeanwhile(session); + // Closed meanwhile, the host closes what this brought up after it. + if (this.phase !== phase) return; if (res.ok && res.wsPort) { this.openedByHost(target); this.goLive(res.wsPort); - return undefined; + return; } // Failed: back in the pane at the page it was on, relaunching headless // there if no daemon came up. if (headed) { this.setHeaded(false); - this.writeParams({ renderMode: automationMode(this.provider, false) }); + this.writeParams({ renderMode: this.renderMode() }); } this.attach(true); - return undefined; - })); + }); } /** @@ -1561,37 +1515,38 @@ export class AgentBrowserSurfaceController { // --- the daemon gate --- - /** The daemon to drive, only while `live` — and after an unpark, once its + /** The browser to drive, only while `live` — and after an unpark, once its * stream opens: every command, edit and capture takes it from here, since * mid-launch, mid-attach or mid-relaunch, or for a daemon gone while hidden, * a CLI command starts a competing daemon at about:blank - * (docs/specs/dor-browser.md → "Agent-Browser Connection"). Call through - * `platform` — pulling a method into a bare variable would detach `this` - * and break an adapter's internal `requestResponse`. */ - private driver(): Driver | null { + * (docs/specs/dor-browser.md → "Browser Connection"). */ + private driver(): BrowserHandle | null { if (this.phase.k !== 'live' || this.phase.resumed || !this.session) return null; - return { platform: this.platform, session: this.session, binaryPath: this.binaryPath }; - } - - /** One daemon command; null when the gate refuses it or the host cannot run - * one. */ - private command(args: string[]): Promise<AgentBrowserCommandResult> | null { - const driver = this.driver(); - if (!driver) abDebugLog(`[ab-panel] ${args.join(' ')} dropped in ${this.phase.k}`); - return driver?.platform.agentBrowserCommand?.(driver.session, args, driver.binaryPath) ?? null; - } - - private runCommand(args: string[]): void { - if (this.driver() && !this.platform.agentBrowserCommand) { - console.warn(`[${this.provider}] this host cannot run ${PROVIDER_LABEL[this.provider]} commands; tab actions are unavailable`); + return this.handle(); + } + + /** The bound session's browser, whatever the phase: for what the gate does + * not cover — a launch, attach or relaunch, the stream URL, a close. + * Rebuilt only when the binding changes: captures take it per frame. */ + private handle(): BrowserHandle | null { + const { session, cwd, binaryPath } = this; + const cached = this.handleCache; + if (cached && cached.session === session && cached.cwd === cwd && cached.binaryPath === binaryPath) return cached.handle; + const handle = browserHandle(this.provider, { session, cwd, binaryPath }); + this.handleCache = { session, cwd, binaryPath, handle }; + return handle; + } + + /** One browser operation through the gate, warned about when it fails; + * dropped outside `live`. */ + private drive(label: string, act: (browser: BrowserHandle) => Promise<BrowserResult>): void { + const browser = this.driver(); + if (!browser) { + abDebugLog(`[ab-panel] ${label} dropped in ${this.phase.k}`); return; } - this.command(args)?.then((result) => { - if (result.exitCode !== 0) { - console.warn(`[${this.provider}] ${args.join(' ')} failed:`, result.stderr || result.stdout || `exit ${result.exitCode}`); - } - }).catch((error) => { - console.warn(`[${this.provider}] ${args.join(' ')} failed:`, error); + void act(browser).then((result) => { + if (!result.ok) console.warn(`[${this.provider}] ${label} failed:`, result.error ?? 'no reason given'); }); } @@ -1602,7 +1557,7 @@ export class AgentBrowserSurfaceController { private navigate(url: string): void { if (!url) return; if (this.driver()) { - this.runCommand(['open', url]); + this.drive(`open ${url}`, (browser) => browser.navigate(url)); return; } if (this.phase.k === 'disposed') return; @@ -1626,11 +1581,11 @@ export class AgentBrowserSurfaceController { } selectTab(tab: StreamTab): void { - if (!tab.active) this.runCommand(['tab', tab.tabId]); + if (!tab.active) this.drive(`tab ${tab.tabId}`, (browser) => browser.tab('select', tab.tabId)); } closeTab(tab: StreamTab): void { - this.runCommand(['tab', 'close', tab.tabId]); + this.drive(`tab close ${tab.tabId}`, (browser) => browser.tab('close', tab.tabId)); } private sendKey(e: KeyLike, eventType: 'keyDown' | 'keyUp'): void { @@ -1658,23 +1613,9 @@ export class AgentBrowserSurfaceController { // cmd/ctrl-V types the LOCAL clipboard into the page. Plain key forwarding // would trigger paste of the embedded Chromium's own (empty) clipboard, so - // bridge by replaying the text: agent-browser's stream takes only key events, - // so as per-character keyDown events; the Playwright host inserts it whole. + // bridge by replaying the text as the provider's viewer takes it. private insertText(text: string): void { - if (this.provider === 'playwright') { - for (const message of playwrightTextInputs(text)) this.send(message); - return; - } - for (const ch of text) { - if (ch === '\r') continue; - if (ch === '\n') { - this.send({ type: 'input_keyboard', eventType: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13, modifiers: 0 }); - this.send({ type: 'input_keyboard', eventType: 'keyUp', key: 'Enter', code: 'Enter', text: '', windowsVirtualKeyCode: 13, modifiers: 0 }); - } else { - this.send({ type: 'input_keyboard', eventType: 'keyDown', key: ch, code: '', text: ch, windowsVirtualKeyCode: 0, modifiers: 0 }); - this.send({ type: 'input_keyboard', eventType: 'keyUp', key: ch, code: '', text: '', windowsVirtualKeyCode: 0, modifiers: 0 }); - } - } + for (const message of BROWSER_PROVIDER_GUI[this.provider].pasteMessages(text)) this.send(message); } handleKeyDownLike(e: KeyLike): void { @@ -1688,19 +1629,15 @@ export class AgentBrowserSurfaceController { // Native editing chords (select-all/copy/cut) don't fire over the stream // input path (CDP commands field is dropped), on any platform — Cmd on // macOS, Ctrl elsewhere. Route the intent through the host's purpose-built - // edit channel instead — a daemon command, so gated like the rest. Every - // shipped host implements `agentBrowserEdit`; the fall-through covers a - // host that does not (the fake adapter), so the page still gets the chord - // for its own JS shortcuts. + // edit channel instead — a daemon command, so gated like the rest. A host + // that cannot drive the provider (the fake adapter) falls through, so the + // page still gets the chord for its own JS shortcuts. if (mod && !e.altKey && !e.shiftKey) { const op = EDIT_OPS[e.key.toLowerCase() as keyof typeof EDIT_OPS]; - if (op && this.platform.agentBrowserEdit && this.session) { - const driver = this.driver(); - if (!driver?.platform.agentBrowserEdit) return; + if (op && this.hosted && this.session) { + if (!this.driver()) return; this.openProvisionalWindow(); - driver.platform.agentBrowserEdit(driver.session, op, driver.binaryPath).then((r) => { - if (!r.ok && r.error) console.warn(`[${this.provider}] ${op} failed:`, r.error); - }).catch((err) => console.warn(`[${this.provider}] ${op} failed:`, err)); + this.drive(op, (browser) => browser.edit(op)); return; } } @@ -1709,23 +1646,24 @@ export class AgentBrowserSurfaceController { // --- teardown --- - /** Close this Surface's browser session and release the controller. Work in - * flight that could bring the session back up closes it again when it lands. + /** Close this Surface's browser session — the one it is bound to, or the + * one its launch names — and release the controller. Sent at once: the host + * runs it after the Surface's launch, relaunch or attach still running, + * closing what that brings up, and cancels one it has not received yet + * (docs/specs/dor-browser.md → "Browser Host"); a launch naming the session + * waits for its answer (`closeInFlight`). * Returns the session closed, if any, and when the host answered. */ close(): { session?: string; done: Promise<void> } { const phase = this.phase; if (phase.k === 'disposed') return { done: Promise.resolve() }; const session = this.session; - // A launch still opening a named session brings it up after this close. - const named = session ?? (phase.k === 'launching' ? phase.session : undefined); - const landing = this.landings.size ? Promise.all(this.landings) : null; - this.release(true); - const closing = session ? this.closeSession(session) : Promise.resolve(); - // Work in flight closes the session again once it lands; until then a - // launch into the same name waits for it too (`closeLanded`). - const done = named && landing - ? trackClose(this.provider, this.cwd, named, Promise.all([closing, landing]).then(() => {})) - : closing; + // Its own requests still unanswered may reach the host after this close; + // a Surface has one or two at a time, so the newest fit any bound. + const cancels = [...this.bringingUp].slice(-BROWSER_CLOSE_MAX_CANCELS); + this.release(); + const done = session ? closeSessionOn(this.provider, this.cwd, session, this.binaryPath, cancels) + : phase.k === 'launching' && phase.named ? trackClose(this.provider, phase.named.session, phase.named.browser.close(cancels)) + : Promise.resolve(); return { session, done }; } @@ -1739,19 +1677,20 @@ export class AgentBrowserSurfaceController { * next (a Workspace transfer's destination). */ dispose(): void { if (this.phase.k === 'disposed') return; - this.release(false); + this.release(); } - private release(closed: boolean): void { - // A launch in flight lands on a released controller, which closes what it - // brings up; whoever awaited one hears that the Surface is gone. + private release(): void { + // A launch in flight lands on a released controller, which closes a + // session the host minted for it; whoever awaited one hears that the + // Surface is gone. settleLaunch(this.id, null); if (this.parkTimer) { clearTimeout(this.parkTimer); this.parkTimer = undefined; } this.teardownPaneSizeObserver(); this.paneSize = null; // Leaving `live` drops the connection, its screenshot loop and the CDP // observer. - this.setPhase({ k: 'disposed', closed }); + this.setPhase({ k: 'disposed' }); this.dprQuery?.removeEventListener('change', this.onDprChange); this.dprQuery = null; this.registration?.dispose(); @@ -1805,34 +1744,39 @@ export function disposeAgentBrowserSurfaceController(id: string): void { else settleLaunch(id, null); } -// Closes the host has not answered yet, per session, so a launch into the same -// named session waits them out (`closeLanded`). -const closesInFlight = new Map<string, Promise<void>>(); -const closeKey = (provider: BrowserAutomationProvider, cwd: string | undefined, session: string) => JSON.stringify([provider, cwd ?? '', session]); - /** * The one way a session is closed; resolves once the host answered. A close - * starts no daemon, so it needs no drive gate. `binaryPath` is checked, not - * merely typed: it may come off the persisted session blob, and names a program - * the host will spawn (`lib/src/lib/agent-browser-binary.ts`). + * starts no daemon, so it needs no drive gate, and the host serializes it with + * the browser's launches. `binaryPath` is checked, not merely typed: it may come + * off the persisted session blob, and names a program the host will spawn + * (`lib/src/lib/agent-browser-binary.ts`). */ -function closeSessionOn(provider: BrowserAutomationProvider, cwd: string | undefined, session: string, binaryPath: unknown): Promise<void> { - return trackClose(provider, cwd, session, browserPlatform(provider, cwd) - .agentBrowserCommand?.(session, ['close'], allowedBinaryPath(binaryPath, provider)) - .then(() => {}, () => {}) ?? Promise.resolve()); +function closeSessionOn(provider: BrowserAutomationProvider, cwd: string | undefined, session: string, binaryPath: unknown, cancels?: readonly string[]): Promise<void> { + const handle = browserHandle(provider, { session, cwd, binaryPath }); + return handle ? trackClose(provider, session, handle.close(cancels)) : Promise.resolve(); } -/** Record `closing` as `session`'s close in flight until it settles. */ -function trackClose(provider: BrowserAutomationProvider, cwd: string | undefined, session: string, closing: Promise<void>): Promise<void> { - const key = closeKey(provider, cwd, session); - const tracked = closing.finally(() => { if (closesInFlight.get(key) === tracked) closesInFlight.delete(key); }); +// Closes of each session this webview has sent and the host has not answered, +// which a launch naming the session waits out (`closeInFlight`): the host +// serializes a browser's launches and closes in arrival order, and not every +// transport delivers requests in the order sent. +const closesInFlight = new Map<string, Promise<void>>(); +const closeKey = (provider: BrowserAutomationProvider, session: string) => `${provider}\0${session}`; + +/** Record `closing` as a close of `session` in flight until the host answers. */ +function trackClose(provider: BrowserAutomationProvider, session: string, closing: Promise<unknown>): Promise<void> { + const key = closeKey(provider, session); + const earlier = closesInFlight.get(key); + const tracked: Promise<void> = Promise.all([earlier, closing]).then(() => {}, () => {}) + .finally(() => { if (closesInFlight.get(key) === tracked) closesInFlight.delete(key); }); closesInFlight.set(key, tracked); return tracked; } -/** Once every close of `session` issued so far has been answered. */ -function closeLanded(provider: BrowserAutomationProvider, cwd: string | undefined, session: string): Promise<void> { - return closesInFlight.get(closeKey(provider, cwd, session)) ?? Promise.resolve(); +/** What settles once every close of `session` this webview has sent has + * been answered; undefined when none is in flight. */ +function closeInFlight(provider: BrowserAutomationProvider, session: string): Promise<void> | undefined { + return closesInFlight.get(closeKey(provider, session)); } /** Hand `id`'s controller a port a `dor` command just learned, with the @@ -1858,7 +1802,7 @@ export function requestBrowserRenderMode(id: string, params: AgentBrowserSurface function closeBrowserSessionFromParams(params: unknown): Promise<void> { const session = agentBrowserSessionFromParams(params); const { renderMode, cwd, binaryPath } = params as { renderMode?: unknown; cwd?: string; binaryPath?: unknown }; - const provider = automationProvider(renderMode); + const { provider } = parseRenderMode(renderMode); return session && provider ? closeSessionOn(provider, cwd, session, binaryPath) : Promise.resolve(); } @@ -1866,7 +1810,7 @@ function closeBrowserSessionFromParams(params: unknown): Promise<void> { * A kill or a swap away from an automated renderer: surface lifetime and browser * lifetime are bound (docs/specs/dor-browser.md → "Placement And Lifetime"), so * close its session and release its controller. The controller closes what it - * holds, and closes again after a launch or relaunch still in flight lands; + * holds or its launch names, after any of that session's work still in flight; * `params` covers a session no controller holds. Resolves once the host * answered every close. No-op for other surface types. */ @@ -1900,7 +1844,9 @@ function settleLaunch(id: string, error: string | null): void { } /** For tests: controllers now outlive panel unmount, so a suite reusing a - * surface id must release them between cases. */ + * surface id must release them between cases — and forget closes a case left + * unanswered, which the next case's named launch would wait on. */ export function disposeAllAgentBrowserSurfaceControllers(): void { for (const id of [...registry.keys()]) disposeAgentBrowserSurfaceController(id); + closesInFlight.clear(); } diff --git a/lib/src/components/wall/browser-automation.test.ts b/lib/src/components/wall/browser-automation.test.ts index fee468b07..76e299cab 100644 --- a/lib/src/components/wall/browser-automation.test.ts +++ b/lib/src/components/wall/browser-automation.test.ts @@ -1,68 +1,72 @@ import { afterEach, describe, expect, it } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; import type { PlatformAdapter } from '../../lib/platform/types'; -import { automationMode, automationProvider, forgetLaunchBinaryPaths, isPopout, isScreencast, launchBinaryPath, offeredRenderModes, rememberLaunchBinaryPath } from './browser-automation'; +import { BROWSER_PROVIDER_IDS, renderModeFor } from 'dor-lib-common/browser-providers'; +import { forgetLaunchBinaryPaths, launchBinaryPath, offeredRenderModes, rememberLaunchBinaryPath } from './browser-automation'; +import { browserDisplayMode } from './agent-browser-screen'; +import { BROWSER_DISPLAY_LABEL } from './BrowserDisplayIcon'; import { resolveRenderMode } from './browser-surface'; +import { installBrowserHost } from './wall-test-utils'; describe('automation render modes', () => { - it('maps each provider and headedness to a mode and back', () => { - for (const provider of ['agent-browser', 'playwright'] as const) { - for (const headed of [false, true]) { - const mode = automationMode(provider, headed); - expect(automationProvider(mode)).toBe(provider); - expect(isPopout(mode)).toBe(headed); - expect(isScreencast(mode)).toBe(!headed); + it('resolves every provider presentation, and anything else as the embed', () => { + for (const provider of BROWSER_PROVIDER_IDS) { + for (const presentation of ['screencast', 'popout'] as const) { + const mode = renderModeFor(provider, presentation); expect(resolveRenderMode({ renderMode: mode })).toBe(mode); } } - }); - - it('has no provider for iframe, an unset mode, or a prototype key', () => { for (const mode of ['iframe', undefined, 'constructor', 'toString']) { - expect(automationProvider(mode)).toBeNull(); - expect(isPopout(mode)).toBe(false); - expect(isScreencast(mode)).toBe(false); expect(resolveRenderMode({ renderMode: mode })).toBe('iframe'); } }); + + it('derives each display mode and its label from the provider registry', () => { + const display = (renderMode: Parameters<typeof browserDisplayMode>[0]['renderMode'], syncEngaged: boolean) => browserDisplayMode({ renderMode, syncEngaged }); + expect(display('ab-screencast', true)).toBe('ab-resize'); + expect(display('ab-screencast', false)).toBe('ab-fixed'); + expect(display('pw-popout', true)).toBe('pw-popout'); + expect(display('iframe', true)).toBe('iframe'); + expect(BROWSER_DISPLAY_LABEL['pw-resize']).toBe('Playwright resizes with pane'); + expect(BROWSER_DISPLAY_LABEL['ab-popout']).toBe('agent-browser popout'); + expect(BROWSER_DISPLAY_LABEL.iframe).toBe('iframe embed'); + }); }); describe('launchBinaryPath', () => { afterEach(forgetLaunchBinaryPaths); - it('hands GUI launches only the agent-browser binary path', () => { + it('hands each provider\'s GUI launches the binary path its own command resolved', () => { rememberLaunchBinaryPath('playwright', '/opt/bin/playwright-cli'); expect(launchBinaryPath('agent-browser')).toBeUndefined(); + expect(launchBinaryPath('playwright')).toBe('/opt/bin/playwright-cli'); rememberLaunchBinaryPath('agent-browser', '/opt/bin/agent-browser'); expect(launchBinaryPath('agent-browser')).toBe('/opt/bin/agent-browser'); - expect(launchBinaryPath('playwright')).toBeUndefined(); + expect(launchBinaryPath('playwright')).toBe('/opt/bin/playwright-cli'); }); }); describe('offeredRenderModes', () => { afterEach(() => setPlatform(new FakePtyAdapter())); - function host(capabilities: Partial<PlatformAdapter>): void { - setPlatform(Object.assign(new FakePtyAdapter(), capabilities)); - } - const ok = async () => ({ ok: true }); - - it('offers a provider only where the host can launch it, and its popout only where it can pop out', () => { - host({ agentBrowserOpen: ok, agentBrowserPopOut: ok }); + it('offers a provider\'s screencast and popout only where the host drives it', () => { + installBrowserHost({}, ['agent-browser']); expect(offeredRenderModes(false, null)).toEqual(['ab-screencast', 'ab-popout', 'iframe']); - host({ agentBrowserOpen: ok, playwright: ok }); - // Playwright's host wires every operation behind its one entry point. - expect(offeredRenderModes(false, null)).toEqual(['ab-screencast', 'pw-screencast', 'pw-popout', 'iframe']); + installBrowserHost({}, ['agent-browser', 'playwright']); + expect(offeredRenderModes(false, null)).toEqual(['ab-screencast', 'ab-popout', 'pw-screencast', 'pw-popout', 'iframe']); }); - it('keeps the running provider, which relaunches in place rather than launching', () => { - host({ agentBrowserPopOut: ok }); - expect(offeredRenderModes(false, 'agent-browser')).toEqual(['ab-screencast', 'ab-popout', 'iframe']); + it('keeps the running provider\'s screencast on a host that cannot drive it', () => { + installBrowserHost({}, ['playwright']); + expect(offeredRenderModes(false, 'agent-browser')).toEqual(['ab-screencast', 'pw-screencast', 'pw-popout', 'iframe']); + // A host with no browser request at all offers no automated renderer but that one. + setPlatform(Object.assign(new FakePtyAdapter(), { browserProviders: ['agent-browser'] } satisfies Partial<PlatformAdapter>)); + expect(offeredRenderModes(false, 'agent-browser')).toEqual(['ab-screencast', 'iframe']); expect(offeredRenderModes(false, null)).toEqual(['iframe']); }); it('offers a Tool only its declarable renders', () => { - host({ agentBrowserOpen: ok, agentBrowserPopOut: ok, playwright: ok }); + installBrowserHost(); expect(offeredRenderModes(true, null)).toEqual(['ab-screencast', 'iframe']); expect(offeredRenderModes(true, 'agent-browser')).toEqual(['ab-screencast', 'iframe']); }); diff --git a/lib/src/components/wall/browser-automation.ts b/lib/src/components/wall/browser-automation.ts index 1938906ed..c81f679ca 100644 --- a/lib/src/components/wall/browser-automation.ts +++ b/lib/src/components/wall/browser-automation.ts @@ -1,142 +1,209 @@ -/** Provider adaptation beneath the shared automated-browser viewer. */ +/** + * The GUI half of the browser-provider registry (docs/specs/dor-browser.md → + * "Providers") — what `dor` and the hosts need too (CLI, render modes, + * binaries) lives in `dor-lib-common/src/browser-providers.ts` — and the + * webview's handle on the host's one browser request. + */ +import { + BROWSER_PROVIDER_IDS, + BROWSER_PROVIDERS, + parseRenderMode, + renderModeFor, + type BrowserAutomationProvider, +} from 'dor-lib-common/browser-providers'; import { getPlatform } from '../../lib/platform'; -import type { PlatformAdapter } from '../../lib/platform/types'; -import type { BrowserAutomationProvider, PlaywrightRequest } from '../../lib/platform/browser-automation'; +import { + playwrightTextInputs, + type BrowserEditOp, + type BrowserOp, + type BrowserRequest, + type BrowserRequestBinding, + type BrowserResult, +} from '../../lib/platform/browser-automation'; +import { isAllowedBinaryFor } from '../../lib/agent-browser-binary'; +import { messageOf } from '../../lib/errors'; import { isToolRender } from '../../lib/platform/tool-types'; +import { keyPairTextInputs } from './agent-browser-input'; import type { RenderMode } from './agent-browser-screen'; -/** The automated render modes: which provider drives each, and whether its - * browser runs headed (popped out). Every other mode — `iframe`, or an - * unknown persisted string — is not automated. */ -const AUTOMATION_MODES: Record<Exclude<RenderMode, 'iframe'>, { provider: BrowserAutomationProvider; headed: boolean }> = { - 'ab-screencast': { provider: 'agent-browser', headed: false }, - 'ab-popout': { provider: 'agent-browser', headed: true }, - 'pw-screencast': { provider: 'playwright', headed: false }, - 'pw-popout': { provider: 'playwright', headed: true }, +interface BrowserProviderGui { + /** The provider's name in user-facing text. */ + label: string; + /** The `dor` command that drives it. */ + cli: string; + /** The device presets its `set device` accepts, as the Display modal lists + * them — the CLI's own registry, no custom descriptors. */ + devices: readonly string[]; + /** What to run from a terminal to size the viewport on a host that cannot. */ + viewportHint: string; + /** A paste as the stream messages the provider's viewer takes. */ + pasteMessages(text: string): Record<string, unknown>[]; +} + +function gui(provider: BrowserAutomationProvider, fields: { devices: readonly string[]; viewportVerb: string; pasteMessages(text: string): Record<string, unknown>[] }): BrowserProviderGui { + const cli = `dor ${BROWSER_PROVIDERS[provider].alias}`; + return { label: BROWSER_PROVIDERS[provider].label, cli, devices: fields.devices, viewportHint: `${cli} ${fields.viewportVerb} …`, pasteMessages: fields.pasteMessages }; +} + +export const BROWSER_PROVIDER_GUI: Record<BrowserAutomationProvider, BrowserProviderGui> = { + 'agent-browser': gui('agent-browser', { + // Touch and the mobile UA come only bundled inside `set device` (verified + // against 0.27.0). + devices: ['iPhone 15', 'iPhone 16', 'iPhone 16 Pro', 'iPhone 17', 'iPad', 'iPad Pro', 'Pixel 9', 'Galaxy S25'], + viewportVerb: 'set', + pasteMessages: keyPairTextInputs, + }), + playwright: gui('playwright', { + devices: ['iPhone 15', 'iPhone 16', 'iPhone 16 Pro', 'iPhone 17', 'iPad (gen 11)', 'iPad Pro 11', 'Pixel 9', 'Galaxy S24'], + viewportVerb: 'resize', + // The host inserts each message whole, never a key pair per character. + pasteMessages: playwrightTextInputs, + }), }; -export type AutomationRenderMode = keyof typeof AUTOMATION_MODES; +/** Why a GUI entry point cannot open `provider`'s browser here. */ +export function providerUnavailable(provider: BrowserAutomationProvider): string { + return `${BROWSER_PROVIDERS[provider].label} is unavailable on this host`; +} -/** Whether `mode` is one of the automated render modes. */ -export function isAutomationMode(mode: unknown): mode is AutomationRenderMode { - // Own keys only: a persisted `renderMode` of `constructor` must not match the prototype. - return typeof mode === 'string' && Object.prototype.hasOwnProperty.call(AUTOMATION_MODES, mode); +/** The render mode showing `provider`'s browser headed or in the pane. */ +export function headedRenderMode(provider: BrowserAutomationProvider, headed: boolean): RenderMode { + return renderModeFor(provider, headed ? 'popout' : 'screencast'); } -/** The provider driving `mode`, or null when it is not automated. */ -export function automationProvider(mode: unknown): BrowserAutomationProvider | null { - return isAutomationMode(mode) ? AUTOMATION_MODES[mode].provider : null; +/** Whether `mode` shows its browser as its own headed window. */ +export function isHeadedMode(mode: unknown): boolean { + return parseRenderMode(mode).presentation === 'popout'; } /** The provider an automated browser Surface drives; an unset mode (a direct * mount in tests) is agent-browser. */ export function surfaceProvider(mode: unknown): BrowserAutomationProvider { - return automationProvider(mode) ?? 'agent-browser'; -} - -export function isPopout(mode: unknown): boolean { - return isAutomationMode(mode) && AUTOMATION_MODES[mode].headed; + return parseRenderMode(mode).provider ?? 'agent-browser'; } -export function isScreencast(mode: unknown): boolean { - return isAutomationMode(mode) && !AUTOMATION_MODES[mode].headed; -} - -/** The `dor` command that drives `provider`'s browsers. */ -export function automationCli(provider: BrowserAutomationProvider): 'dor ab' | 'dor pw' { - return provider === 'playwright' ? 'dor pw' : 'dor ab'; +/** Whether this host can drive `provider`'s browsers. */ +export function hostSupportsBrowser(provider: BrowserAutomationProvider): boolean { + const platform = getPlatform(); + return !!platform.browser && !!platform.browserProviders?.includes(provider); } -export function automationMode(provider: BrowserAutomationProvider, headed: boolean): AutomationRenderMode { - if (provider === 'playwright') return headed ? 'pw-popout' : 'pw-screencast'; - return headed ? 'ab-popout' : 'ab-screencast'; +/** The providers this host can launch a browser with. */ +export function hostBrowserProviders(): BrowserAutomationProvider[] { + return BROWSER_PROVIDER_IDS.filter(hostSupportsBrowser); } -/** Each provider's name in user-facing text. */ -export const PROVIDER_LABEL: Record<BrowserAutomationProvider, string> = { - 'agent-browser': 'agent-browser', - playwright: 'Playwright', -}; - -/** Both providers, in the order the GUI lists them. */ -export const AUTOMATION_PROVIDERS = Object.keys(PROVIDER_LABEL) as BrowserAutomationProvider[]; - /** * The render modes a browser Surface can take on this host — what its Display * modal offers (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). - * A provider needs a host that can launch it, unless it is the one `current` - * already runs, which relaunches in place; its popout needs one that can also - * pop out. `iframe` is always available. A Tool takes only its declarable - * renders (docs/specs/dor-tool.md → Declaring tools). + * A provider needs a host that can drive it, unless it is the one `current` + * already runs, which keeps its screencast. `iframe` is always available. A + * Tool takes only its declarable renders (docs/specs/dor-tool.md → Declaring + * tools). */ export function offeredRenderModes(isTool: boolean, current: BrowserAutomationProvider | null): RenderMode[] { const modes: RenderMode[] = []; - for (const provider of AUTOMATION_PROVIDERS) { - const platform = browserPlatform(provider); - if (provider !== current && !platform.agentBrowserOpen) continue; - modes.push(automationMode(provider, false)); - if (platform.agentBrowserPopOut) modes.push(automationMode(provider, true)); + for (const provider of BROWSER_PROVIDER_IDS) { + const hosted = hostSupportsBrowser(provider); + if (!hosted && provider !== current) continue; + modes.push(renderModeFor(provider, 'screencast')); + if (hosted) modes.push(renderModeFor(provider, 'popout')); } modes.push('iframe'); return isTool ? modes.filter(isToolRender) : modes; } -export type BrowserPlatform = Pick<PlatformAdapter, - | 'agentBrowserCommand' | 'agentBrowserEdit' | 'agentBrowserScreenshot' - | 'agentBrowserAttach' | 'getAgentBrowserStreamUrl' | 'agentBrowserOpen' - | 'agentBrowserPopOut' | 'agentBrowserPopIn' ->; +/** + * One provider's browser, as the webview drives it: typed operations on the + * host's one `browser` request (docs/specs/dor-browser.md → "Browser Host"). + * A request that cannot reach the host answers `{ ok: false }` + * rather than rejecting. + */ +export interface BrowserHandle { + readonly provider: BrowserAutomationProvider; + launch(url: string | undefined, headed: boolean, requestId?: string): Promise<BrowserResult>; + attach(opts?: { url?: string; headed?: boolean; requestId?: string }): Promise<BrowserResult>; + streamUrl(port: number): Promise<BrowserResult>; + screenshot(opts: { format?: 'jpeg' | 'png'; quality?: number }): Promise<BrowserResult>; + edit(edit: BrowserEditOp): Promise<BrowserResult>; + navigate(url: string): Promise<BrowserResult>; + history(dir: 'back' | 'forward' | 'reload'): Promise<BrowserResult>; + tab(action: 'select' | 'close', tabId: string): Promise<BrowserResult>; + viewport(width: number, height: number, dpr: number): Promise<BrowserResult>; + device(name: string): Promise<BrowserResult>; + cdpUrl(): Promise<BrowserResult>; + /** `cancels`: the closing Surface's own requests still unanswered. */ + close(cancels?: readonly string[]): Promise<BrowserResult>; +} -export function browserPlatform(provider: BrowserAutomationProvider, cwd?: string): BrowserPlatform { +/** + * `provider`'s browser bound to `binding`, or null where this host cannot + * drive it. `binaryPath` names a program the host will spawn and may come off + * the persisted session blob, so a path the provider's gate refuses is dropped + * here, before it is ever sent (`lib/src/lib/agent-browser-binary.ts`); the host + * then resolves its own. + */ +export function browserHandle(provider: BrowserAutomationProvider, binding: Omit<BrowserRequestBinding, 'binaryPath'> & { binaryPath?: unknown }): BrowserHandle | null { + if (!hostSupportsBrowser(provider)) return null; const platform = getPlatform(); - if (provider === 'agent-browser') return platform; - const invoke = platform.playwright; - if (!invoke) return {}; - const call = (request: PlaywrightRequest) => invoke.call(platform, { cwd, ...request }); + const sent: BrowserRequestBinding = { + ...(binding.session !== undefined ? { session: binding.session } : {}), + ...(binding.cwd !== undefined ? { cwd: binding.cwd } : {}), + ...(isAllowedBinaryFor(provider, binding.binaryPath) ? { binaryPath: binding.binaryPath } : {}), + }; + const send = (op: BrowserOp): Promise<BrowserResult> => { + let answer: Promise<BrowserResult>; + try { + answer = platform.browser!({ provider, binding: sent, ...op } as BrowserRequest); + } catch (error) { + answer = Promise.reject(error); + } + return answer.then( + (result) => result ?? { ok: false, error: 'no answer from the browser host' }, + (error: unknown) => ({ ok: false, error: messageOf(error) }), + ); + }; return { - agentBrowserCommand: async (session: string, args: string[], binaryPath?: string) => { - const r = await call({ op: 'command', session, args, binaryPath }); - return { exitCode: r.exitCode ?? (r.ok ? 0 : 1), stdout: r.stdout ?? '', stderr: r.stderr ?? r.error ?? '' }; - }, - agentBrowserEdit: (session: string, edit: 'selectAll' | 'copy' | 'cut', binaryPath?: string) => call({ op: 'edit', session, edit, binaryPath }), - agentBrowserScreenshot: async (session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string) => { - const r = await call({ op: 'screenshot', session, ...opts, binaryPath }); - // JSON transports materialize Uint8Array as an ordinary array. - if (r.bytes && !(r.bytes instanceof Uint8Array)) r.bytes = new Uint8Array(r.bytes); - return r; - }, - agentBrowserAttach: (session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string) => call({ op: 'attach', session, ...opts, binaryPath }), - getAgentBrowserStreamUrl: async (port: number) => { - const r = await call({ op: 'streamUrl', port }); - if (!r.ok || !r.url) throw new Error(r.error ?? 'Playwright stream unavailable'); - return r.url; + provider, + launch: (url, headed, requestId) => send({ op: 'launch', ...(url !== undefined ? { url } : {}), headed, ...(requestId !== undefined ? { requestId } : {}) }), + attach: (opts = {}) => send({ op: 'attach', ...opts }), + streamUrl: (port) => send({ op: 'streamUrl', port }), + screenshot: async (opts) => { + const result = await send({ op: 'screenshot', ...opts }); + // JSON transports materialize a Uint8Array as an ordinary array. + if (result.bytes && !(result.bytes instanceof Uint8Array)) result.bytes = new Uint8Array(result.bytes); + return result; }, - agentBrowserOpen: (url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string) => call({ op: 'open', url, ...opts, binaryPath }), - agentBrowserPopOut: (session: string, opts: { url?: string }, binaryPath?: string) => call({ op: 'popOut', session, ...opts, binaryPath }), - agentBrowserPopIn: (session: string, opts: { url?: string }, binaryPath?: string) => call({ op: 'popIn', session, ...opts, binaryPath }), + edit: (edit) => send({ op: 'edit', edit }), + navigate: (url) => send({ op: 'navigate', url }), + history: (dir) => send({ op: 'history', dir }), + tab: (action, tabId) => send({ op: 'tab', action, tabId }), + viewport: (width, height, dpr) => send({ op: 'viewport', width, height, dpr }), + device: (name) => send({ op: 'device', name }), + cdpUrl: () => send({ op: 'cdpUrl' }), + close: (cancels = []) => send({ op: 'close', ...(cancels.length ? { cancels: [...cancels] } : {}) }), }; } -// The binary path a `dor ab` surface last resolved on a terminal's PATH, one per -// webview (its Walls share one host). -let lastAgentBrowserBinaryPath: string | undefined; +// The binary path each provider's `dor` command last resolved on a terminal's +// PATH, one per webview (its Walls share one host). +const lastBinaryPaths = new Map<BrowserAutomationProvider, string>(); /** - * The binary path a GUI launch of `provider` passes: the one a `dor ab` - * surface last resolved on a terminal's PATH, since the webview/host PATH may - * not find the binary itself. Agent-browser only: the Playwright host resolves - * its own installation on every launch. + * The binary path a GUI launch of `provider` passes: the one its `dor` command + * last resolved on a terminal's PATH, since the webview/host PATH may not find + * the binary itself. */ export function launchBinaryPath(provider: BrowserAutomationProvider): string | undefined { - return provider === 'agent-browser' ? lastAgentBrowserBinaryPath : undefined; + return lastBinaryPaths.get(provider); } /** Record the binary path a `provider` session resolved or launched with. */ export function rememberLaunchBinaryPath(provider: BrowserAutomationProvider, binaryPath: string | undefined): void { - if (binaryPath && provider === 'agent-browser') lastAgentBrowserBinaryPath = binaryPath; + if (binaryPath) lastBinaryPaths.set(provider, binaryPath); } /** For tests: the memo outlives the Wall that filled it. */ export function forgetLaunchBinaryPaths(): void { - lastAgentBrowserBinaryPath = undefined; + lastBinaryPaths.clear(); } diff --git a/lib/src/components/wall/browser-binding-reservations.test.ts b/lib/src/components/wall/browser-binding-reservations.test.ts index 78fbadc77..9dd273c67 100644 --- a/lib/src/components/wall/browser-binding-reservations.test.ts +++ b/lib/src/components/wall/browser-binding-reservations.test.ts @@ -1,45 +1,69 @@ import { test, expect, vi } from 'vitest'; +import { sessionForKey } from 'dor-lib-common/browser-providers'; import { BrowserBindingReservations } from './browser-binding-reservations'; + test('concurrent first commands share cwd; a failed launch reservation expires', () => { vi.useFakeTimers(); try { const registry = new BrowserBindingReservations(); const first = { cwd: '/project-a' }; const second = { cwd: '/project-b' }; - const binding = registry.resolve('app', first); - expect(binding).toMatchObject({ cwd: first.cwd }); - expect(registry.resolve('app', second)).toEqual(binding); + const binding = registry.resolve('playwright', 'app', 'ws', first); + expect(binding).toEqual({ session: sessionForKey('app', 'ws'), cwd: first.cwd }); + expect(registry.resolve('playwright', 'app', 'ws', second)).toEqual(binding); vi.advanceTimersByTime(120000); - const replacement = registry.resolve('app', second); - expect(replacement).toMatchObject({ cwd: second.cwd }); - expect(replacement?.session).not.toBe(binding?.session); - registry.delete('app'); - expect(registry.resolve('app', first)?.cwd).toBe(first.cwd); + // The session is the key's own; only the pinned directory was released. + expect(registry.resolve('playwright', 'app', 'ws', second)).toEqual({ session: binding.session, cwd: second.cwd }); + registry.delete('playwright', 'app'); + expect(registry.resolve('playwright', 'app', 'ws', first).cwd).toBe(first.cwd); } finally { vi.useRealTimers(); } }); -test('the same key in separate workspaces has independent native sessions', () => { - const first = new BrowserBindingReservations(); - const second = new BrowserBindingReservations(); +test('a key is namespaced by its Workspace and its provider, and pins only an allowed executable', () => { + const registry = new BrowserBindingReservations(); const proposed = { cwd: '/project', binaryPath: '/tools/playwright-cli' }; - const binding = first.resolve('app', proposed)!; - expect(binding.session).not.toBe(second.resolve('app', proposed)?.session); - expect(first.resolve('app', { ...proposed, binaryPath: '/other/playwright-cli' })).toEqual(binding); + const binding = registry.resolve('playwright', 'app', 'ws-a', proposed); + expect(binding.session).not.toBe(new BrowserBindingReservations().resolve('playwright', 'app', 'ws-b', proposed).session); + expect(registry.resolve('playwright', 'app', 'ws-a', { ...proposed, binaryPath: '/other/playwright-cli' })).toEqual(binding); expect(binding.binaryPath).toBe(proposed.binaryPath); + // agent-browser's `app` is another browser, with its own reservation. + expect(registry.resolve('agent-browser', 'app', 'ws-a', { cwd: '/elsewhere', binaryPath: '/tools/playwright-cli' })) + .toEqual({ session: binding.session, cwd: '/elsewhere' }); +}); + +test('a command that cannot bind a Surface reserves nothing', () => { + const registry = new BrowserBindingReservations(); + expect(registry.resolve('agent-browser', 'app', 'ws')).toEqual({ session: sessionForKey('app', 'ws') }); + expect(registry.resolve('agent-browser', 'app', 'ws', { cwd: '/project' }).cwd).toBe('/project'); }); -test('a key whose command succeeded keeps its session with no Surface to bind', () => { +test('a key whose command succeeded keeps its binding with no Surface to bind', () => { // `dor pw --key app open --browser=firefox`: the native browser exists, but no - // viewer can attach, so nothing but the reservation holds the key's session. + // viewer can attach, so nothing but the reservation holds the key's cwd. vi.useFakeTimers(); try { const registry = new BrowserBindingReservations(); - const binding = registry.resolve('app', { cwd: '/project' }); - registry.confirm('app'); + const binding = registry.resolve('playwright', 'app', 'ws', { cwd: '/project' }); + registry.confirm('playwright', 'app'); vi.advanceTimersByTime(10 * 60_000); - expect(registry.resolve('app', { cwd: '/elsewhere' })).toEqual(binding); + expect(registry.resolve('playwright', 'app', 'ws', { cwd: '/elsewhere' })).toEqual(binding); // A key with nothing reserved has nothing to confirm. - registry.confirm('other'); - expect(registry.resolve('other', { cwd: '/elsewhere' })?.cwd).toBe('/elsewhere'); + registry.confirm('playwright', 'other'); + expect(registry.resolve('playwright', 'other', 'ws', { cwd: '/elsewhere' }).cwd).toBe('/elsewhere'); } finally { vi.useRealTimers(); } }); + +test('a key never mints a session a Surface in the Window, or another key\'s reservation, holds', () => { + const registry = new BrowserBindingReservations(); + const base = sessionForKey('app', 'ws'); + // The pane first bound to `app` left for another Workspace, still bound. + const held = new Set([base]); + const binding = registry.resolve('playwright', 'app', 'ws', { cwd: '/project' }, (session) => held.has(session)); + expect(binding.session).toBe(`${base}.2`); + // Deterministic: the key resolves to it again, and its reservation keeps the + // name from the next key that would number onto it. + expect(registry.resolve('playwright', 'app', 'ws', { cwd: '/project' }, (session) => held.has(session))).toEqual(binding); + expect(registry.resolve('playwright', 'app.2', 'ws', { cwd: '/project' }, (session) => held.has(session)).session).toBe(`${base}.2.2`); + // Another provider's session of that name is another browser. + expect(registry.resolve('agent-browser', 'app', 'ws', { cwd: '/project' }).session).toBe(base); +}); diff --git a/lib/src/components/wall/browser-binding-reservations.ts b/lib/src/components/wall/browser-binding-reservations.ts index 5e2939ed7..e53f011b8 100644 --- a/lib/src/components/wall/browser-binding-reservations.ts +++ b/lib/src/components/wall/browser-binding-reservations.ts @@ -1,41 +1,62 @@ -import { isAllowedPlaywrightBinary } from '../../lib/agent-browser-binary'; -import type { BrowserBinding } from 'dor/commands/types'; +import { sessionForKey, type BrowserAutomationProvider, type BrowserBinding } from 'dor-lib-common/browser-providers'; +import { isAllowedBinaryFor } from '../../lib/agent-browser-binary'; -/** Pin concurrent first commands to one native project until the Surface is - * bound. A reservation whose command never succeeds expires, so a failed first - * launch does not pin its key; one whose command succeeded but has no Surface - * (a browser no viewer can attach to) is kept, since its session exists. */ +/** + * The binding of a managed `--key` no Surface holds yet + * (docs/specs/dor-browser.md → "Managed identity"). Its session is minted + * deterministically, `sessionForKey(key, scope)`, numbered past any session a + * Surface in the Window already holds — one bound to that key before it left + * this Workspace, say — or another key's reservation; what a reservation pins is + * the caller's cwd and executable, so a key's concurrent first commands run in + * one directory with one executable until its Surface is bound. A reservation + * whose command never succeeds expires, so a failed first launch does not pin + * its key; one whose command succeeded but has no Surface (a browser no viewer + * can attach to) is kept, since its session exists. + */ export class BrowserBindingReservations { - private pending = new Map<string, { binding: BrowserBinding; expires: number }>(); + private pending = new Map<string, { provider: BrowserAutomationProvider; binding: BrowserBinding; expires: number }>(); - /** The binding reserved for `key`, else a new one from the caller's - * `proposed` cwd and executable; null when `proposed` carries no cwd. */ - resolve(key: string, proposed: unknown): BrowserBinding | null { + /** `provider`'s binding for `key` in `scope`: the one reserved, else a new + * one — reserved with the caller's `proposed` cwd and executable when the + * command may bind a Surface, bare otherwise. `taken` answers whether a + * Surface holds a session. */ + resolve(provider: BrowserAutomationProvider, key: string, scope: string, proposed?: unknown, taken: (session: string) => boolean = () => false): BrowserBinding { const now = Date.now(); for (const [pendingKey, entry] of this.pending) { if (entry.expires <= now) this.pending.delete(pendingKey); } - const existing = this.pending.get(key); + const existing = this.pending.get(reservationKey(provider, key)); if (existing) return existing.binding; + // Another key's reservation holds its session too; another provider's + // session of the same name is another browser. + const reserved = new Set([...this.pending.values()].filter((entry) => entry.provider === provider).map((entry) => entry.binding.session)); + const base = sessionForKey(key, scope); + let session = base; + for (let n = 2; taken(session) || reserved.has(session); n++) session = `${base}.${n}`; const p = proposed as { cwd?: unknown; binaryPath?: unknown } | undefined; - if (typeof p?.cwd !== 'string') return null; - // The session is minted here, never proposed: a key belongs to this Wall, - // so its native name must not collide with another Wall's key. + if (typeof p?.cwd !== 'string') return { session }; const binding: BrowserBinding = { - session: `dormouse.pw.${crypto.randomUUID()}`, + session, cwd: p.cwd, - ...(isAllowedPlaywrightBinary(p.binaryPath) ? { binaryPath: p.binaryPath } : {}), + ...(isAllowedBinaryFor(provider, p.binaryPath) ? { binaryPath: p.binaryPath } : {}), }; if (this.pending.size >= 1024) this.pending.delete(this.pending.keys().next().value!); - this.pending.set(key, { binding, expires: now + 120_000 }); + this.pending.set(reservationKey(provider, key), { provider, binding, expires: now + 120_000 }); return binding; } /** The key's command succeeded: keep its binding until a Surface takes it. */ - confirm(key: string) { - const entry = this.pending.get(key); + confirm(provider: BrowserAutomationProvider, key: string) { + const entry = this.pending.get(reservationKey(provider, key)); if (entry) entry.expires = Infinity; } - delete(key: string) { this.pending.delete(key); } + delete(provider: BrowserAutomationProvider, key: string) { + this.pending.delete(reservationKey(provider, key)); + } +} + +// The same key driven by two providers is two browsers. +function reservationKey(provider: BrowserAutomationProvider, key: string): string { + return `${provider}\0${key}`; } diff --git a/lib/src/components/wall/browser-surface.ts b/lib/src/components/wall/browser-surface.ts index f7df22a88..b19407994 100644 --- a/lib/src/components/wall/browser-surface.ts +++ b/lib/src/components/wall/browser-surface.ts @@ -9,10 +9,9 @@ import { type BrowserDisplayMode, type RenderMode, } from './agent-browser-screen'; -import { isAutomationMode } from './browser-automation'; import type { BrowserBinding, SurfaceKind } from 'dor/commands/types'; import { isToolKeyScope, type ToolKeyScope } from '../../lib/platform/tool-types'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { parseRenderMode, renderModeFor, sessionForKey } from 'dor-lib-common/browser-providers'; type BrowserParamsLike = { surfaceType?: unknown; @@ -36,13 +35,12 @@ function asParams(params: unknown): BrowserParamsLike { /** Resolve the canonical render mode; defaults to `iframe` when unset. */ export function resolveRenderMode(params: unknown): RenderMode { - const { renderMode } = asParams(params); - return isAutomationMode(renderMode) ? renderMode : 'iframe'; + return parseRenderMode(asParams(params).renderMode).mode; } -/** Whether params describe either automated browser provider. */ +/** Whether params describe an automated browser, of either provider. */ export function isAgentBrowserParams(params: unknown): boolean { - return isAutomationMode(asParams(params).renderMode); + return parseRenderMode(asParams(params).renderMode).provider !== null; } /** Whether params describe a `tool` Surface — one Session with a terminal and, @@ -203,7 +201,7 @@ export function surfaceKindFromParams(params: unknown): SurfaceKind { /** The automation session a browser surface is bound to — the join key * of the session↔surface registry — or null when the surface is not * automated, or its launch has not yet named one (`docs/specs/dor-browser.md` - * → "Agent-Browser Connection"). */ + * → "Browser Connection"). */ export function agentBrowserSessionFromParams(params: unknown): string | null { if (!isAgentBrowserParams(params)) return null; const session = asParams(params).session; @@ -225,7 +223,7 @@ export function browserBindingFromParams(params: unknown): BrowserBinding | null /** * What the Wall does when a Surface's first launch fails - * (docs/specs/dor-browser.md → "Agent-Browser Connection"), stored by whoever + * (docs/specs/dor-browser.md → "Browser Connection"), stored by whoever * created it so a pane restored mid-launch still gets it: close the pane, fall * a Tool back to its embed, or restore the renderer a swap replaced. */ @@ -244,7 +242,7 @@ export function launchFallbackFromParams(params: unknown): LaunchFallback | null export function toolBrowserLaunchParams(leafId: string, params: Record<string, unknown>, url: string): Record<string, unknown> { return { url, - renderMode: 'ab-screencast', + renderMode: renderModeFor('agent-browser', 'screencast'), session: undefined, launchSession: typeof params.session === 'string' ? params.session : sessionForKey(`tool.${leafId}`), launchFallback: 'embed' satisfies LaunchFallback, diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index e9e0c74b9..6cd2f90b7 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,7 +1,6 @@ import { recordToolDirty } from '../../lib/tool-dirty-store'; import { createSerialQueue } from '../../host/remote/serial-queue'; import { useCallback, useRef, type MutableRefObject } from 'react'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; import { getPlatform, PLATFORM_STRING } from '../../lib/platform'; import { currentWindowRef, getActiveWorkspaceId } from '../../lib/workspace-store'; import type { WorkspaceId } from '../../lib/session-types'; @@ -45,8 +44,11 @@ import { } from './tool-takeover'; import { attachSurfacePorts } from './surface-ports'; import { browserSurfaceUrl, hostPathDisplay, iframeRefusal } from './browser-url'; -import { automationCli, automationMode, automationProvider, browserPlatform, rememberLaunchBinaryPath } from './browser-automation'; +import { isBrowserProvider, isTcpPort, parseRenderMode, renderModeFor } from 'dor-lib-common/browser-providers'; +import type { BrowserResult } from '../../lib/platform/browser-automation'; +import { BROWSER_PROVIDER_GUI, browserHandle, headedRenderMode, providerUnavailable, rememberLaunchBinaryPath } from './browser-automation'; import { BrowserBindingReservations } from './browser-binding-reservations'; +import { listWallHandles } from './wall-handles'; import { agentBrowserSessionFromParams, browserBindingFromParams, @@ -120,23 +122,22 @@ export type DorControlRequest = Omit<DorControlRequestPayload, 'params'> & { signal?: AbortSignal; }; -/** Outcome of {@link EnsureAgentBrowserSurface}: the fields the caller maps onto +/** Outcome of {@link EnsureBrowserSurface}: the fields the caller maps onto * its response, or a failure message. `minimized` is the surface's current * minimized state (the reused surface's, or the requested value for a fresh one). */ -type EnsureAgentBrowserSurfaceResult = +type EnsureBrowserSurfaceResult = | { ok: true; status: 'created' | 'existing' | 'replaced'; surfaceId: string; surfaceRef: string; minimized: boolean } | { ok: false; message: string }; /** Reuse-or-create an automated browser surface for the session a `dor ab` / * `dor pw` command just drove — the surface half of the control plane. */ -type EnsureAgentBrowserSurface = (args: { - provider?: BrowserAutomationProvider; +type EnsureBrowserSurface = (args: { + provider: BrowserAutomationProvider; headed?: boolean; - nativeIdentity?: string; + nativeIdentity: string; cwd?: string; key?: string; session: string; - url?: string; wsPort?: number; binaryPath?: string; /** Resolved lazily, only when a fresh surface must be created: the reuse path @@ -144,7 +145,7 @@ type EnsureAgentBrowserSurface = (args: { * terminal refreshing an existing surface). */ reference: () => ParseResult<DorSurface>; minimized?: boolean; -}) => EnsureAgentBrowserSurfaceResult; +}) => EnsureBrowserSurfaceResult; /** * What a `dor` Surface target names, in the one grammar @@ -358,8 +359,8 @@ const CREATING_CONTROL_METHODS = new Set<string>([ SURFACE_CONTROL_METHODS.split, SURFACE_CONTROL_METHODS.ensure, SURFACE_CONTROL_METHODS.iframe, - SURFACE_CONTROL_METHODS.agentBrowser, SURFACE_CONTROL_METHODS.browser, + SURFACE_CONTROL_METHODS.agentBrowser, ]); const ENSURE_CANCELLED = 'ensure was cancelled'; @@ -596,9 +597,9 @@ export function useDorControl({ * The Window's own ref rides beside it, so `dor list` says which Window * answered too (`currentWindowRef`). */ workspaceRef: () => string; - /** This Wall's Workspace id, which namespaces the managed `dor ab --key` - * sessions it answers for; `undefined` on a bare Wall, whose keys keep the - * unscoped names (docs/specs/dor-browser.md → Managed identity). */ + /** This Wall's Workspace id, which namespaces the managed browser `--key` + * sessions it answers for; `undefined` on a bare Wall, which mints a scope + * of its own (docs/specs/dor-browser.md → Managed identity). */ workspaceScope: () => WorkspaceId | undefined; }): { /** The live surface (visible pane or minimized door) whose params match, or @@ -686,11 +687,11 @@ export function useDorControl({ provider: BrowserAutomationProvider, detail: DorControlRequest, ): string | null => { - const rendering = automationProvider(target.renderMode); + const rendering = parseRenderMode(target.renderMode).provider; if (rendering !== provider) { // Name the command that does work on it, so the caller's next try lands. const remedy = rendering - ? `drive it with ${automationCli(rendering)} --surface ${target.ref}` + ? `drive it with ${BROWSER_PROVIDER_GUI[rendering].cli} --surface ${target.ref}` : `an iframe cannot be driven; open its page with dor ab open ${browserUrlFromParams(lath.getMeta(target.id)?.params) ?? '<url>'}`; detail.respond({ ok: false, @@ -704,7 +705,7 @@ export function useDorControl({ const session = agentBrowserSessionFromParams(lath.getMeta(target.id)?.params); if (!session) { // A pane whose launch has not yet named its session - // (docs/specs/dor-browser.md → "Agent-Browser Connection"). + // (docs/specs/dor-browser.md → "Browser Connection"). detail.respond({ ok: false, error: `surface '${target.ref}' has no ${provider} session yet` }); return null; } @@ -737,31 +738,30 @@ export function useDorControl({ return null; }, [lath, isTargetable]); - /** The agent-browser session ↔ surface registry: the surface bound to - * `session`, or null if none exists. */ - const findAgentBrowserSurface = useCallback((session: string) => findSurfaceByParams( - (params) => automationProvider((params as { renderMode?: unknown } | undefined)?.renderMode) === 'agent-browser' - && agentBrowserSessionFromParams(params) === session, - ), [findSurfaceByParams]); - - /** The Playwright Surface a binding names: by managed key; else by the host's - * native identity, which a raw `--session` shares across one project's - * subdirectories; else by session and cwd. */ - const findPlaywrightSurface = useCallback((binding: { - key?: string; - session: string; - nativeIdentity?: string; - cwd?: string; - }) => findSurfaceByParams((params) => { - const p = params as { renderMode?: unknown; key?: unknown; session?: unknown; cwd?: unknown; nativeIdentity?: unknown } | undefined; - if (automationProvider(p?.renderMode) !== 'playwright') return false; - if (binding.key) return p?.key === binding.key; - if (binding.nativeIdentity && p?.nativeIdentity) return p.nativeIdentity === binding.nativeIdentity; - return p?.session === binding.session && p?.cwd === binding.cwd; + /** The automated browser Surface of `provider` that `key` names, else the + * one bound to `nativeIdentity` — which a raw `--session` shares across one + * Playwright project's subdirectories. A Surface saved before native + * identities is found by its agent-browser session, the identity it had. */ + const findBrowserSurface = useCallback((provider: BrowserAutomationProvider, match: { key?: string; nativeIdentity?: string }) => findSurfaceByParams((params) => { + const p = params as { renderMode?: unknown; key?: unknown; nativeIdentity?: unknown } | undefined; + if (parseRenderMode(p?.renderMode).provider !== provider) return false; + if (match.key !== undefined) return p?.key === match.key; + const identity = typeof p?.nativeIdentity === 'string' ? p.nativeIdentity : provider === 'agent-browser' ? agentBrowserSessionFromParams(params) : null; + return identity !== null && identity === match.nativeIdentity; }), [findSurfaceByParams]); + // A bare Wall has no Workspace id, but its managed keys must not share names + // with another bare Wall's (every VS Code webview is one): it mints a scope + // for its own life. A Surface it restores keeps the session it was bound to. + const bareWallScope = useRef<string | null>(null); + const browserKeyScope = useCallback((): string => { + const scope = workspaceScope(); + if (scope) return scope; + return bareWallScope.current ??= `w${crypto.randomUUID().slice(0, 8)}`; + }, [workspaceScope]); + // Fold a params patch onto a surface, pane or door alike — the store holds both, - // so there is one write path. Shared by `ensureAgentBrowserSurface`'s reuse arm and + // so there is one write path. Shared by `ensureBrowserSurface`'s reuse arm and // the context's port launches in Wall.tsx. A no-op on an empty patch. const updateSurfaceParams = useCallback((id: string, patch: Record<string, unknown>) => { if (Object.keys(patch).length === 0) return; @@ -770,24 +770,25 @@ export function useDorControl({ const browserReservations = useRef(new BrowserBindingReservations()); - const ensureAgentBrowserSurface = useCallback<EnsureAgentBrowserSurface>(({ + const ensureBrowserSurface = useCallback<EnsureBrowserSurface>(({ key, - provider = 'agent-browser', + provider, headed, nativeIdentity, cwd, session, - url, wsPort, binaryPath, reference, minimized = false, }) => { - // Remember the resolved binary so an embed→screencast swap can spawn one. + // Remember the resolved binary so a GUI launch can spawn one. rememberLaunchBinaryPath(provider, binaryPath); const refreshedParams = { - ...(nativeIdentity ? { nativeIdentity } : {}), - ...(provider === 'playwright' && headed !== undefined ? { renderMode: automationMode(provider, headed) } : {}), + nativeIdentity, + // The host reports headedness a native launch changed; the controller + // follows it (`followParamsHeadedness`). + ...(headed !== undefined ? { renderMode: headedRenderMode(provider, headed) } : {}), ...(binaryPath !== undefined ? { binaryPath } : {}), }; // The stream port is no param: the command just learned it, so the @@ -796,9 +797,7 @@ export function useDorControl({ if (wsPort !== undefined) handOverBrowserPort(id, lath.getMeta(id)?.params ?? {}, wsPort); }; - const existing = provider === 'agent-browser' - ? findAgentBrowserSurface(session) - : findPlaywrightSurface({ key, session, nativeIdentity, cwd }); + const existing = findBrowserSurface(provider, key !== undefined ? { key } : { nativeIdentity }); if (existing) { // Reuse: hand over the stream port (OS-assigned, churns across session // restarts) so the pane reconnects to the live stream, and refresh the @@ -821,11 +820,10 @@ export function useDorControl({ minimized, params: { surfaceType: 'browser', - renderMode: automationMode(provider, false), + renderMode: renderModeFor(provider, 'screencast'), ...(cwd ? { cwd } : {}), session, ...(key !== undefined ? { key } : {}), - ...(url !== undefined ? { url } : {}), ...refreshedParams, }, reference: target.value, @@ -842,7 +840,7 @@ export function useDorControl({ surfaceRef: result.value.ref, minimized, }; - }, [createContentSurface, findAgentBrowserSurface, findPlaywrightSurface, updateSurfaceParams, surfaceRefForId, lath]); + }, [createContentSurface, findBrowserSurface, updateSurfaceParams, surfaceRefForId, lath]); // The request handler itself. The window listener that picks WHICH Wall runs it @@ -1609,33 +1607,46 @@ export function useDorControl({ return; } - if (detail.method === SURFACE_CONTROL_METHODS.resolveBrowser) { - const provider = params.provider === 'playwright' ? 'playwright' : 'agent-browser'; + // The provider a browser request names: agent-browser for the pre-merge + // spellings an older `dor ab` sends, else its `provider`. + const requestedProvider = (legacy: boolean): BrowserAutomationProvider | null => { + if (legacy) return 'agent-browser'; + if (isBrowserProvider(params.provider)) return params.provider; + detail.respond({ ok: false, error: `unknown browser provider '${String(params.provider)}'` }); + return null; + }; + + // `surface.resolveAgentBrowser` answers the same, as the bare session the + // older `dor ab` read. + if (detail.method === SURFACE_CONTROL_METHODS.resolveBrowser || detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser) { + const legacy = detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser; + const provider = requestedProvider(legacy); + if (!provider) return; // `--surface`: that Surface's binding, once it is this provider's and named. - if (params.surface) { + const key = stringParam(params.key); + if (key === undefined) { const target = requireBrowserSurface(params.surface, detail); if (!target || !requireAutomationSession(target, provider, detail)) return; - detail.respond({ ok: true, result: { binding: browserBindingFromParams(lath.getMeta(target.id)?.params) } }); + const binding = browserBindingFromParams(lath.getMeta(target.id)?.params)!; + detail.respond({ ok: true, result: legacy ? { surfaceId: target.id, surfaceRef: target.ref, session: binding.session } : { binding } }); return; } - // `--key`: the Surface bound to that key, else the reservation that pins - // a Playwright key's concurrent first commands to one binding. - const found = findSurfaceByParams((raw) => { - const p = raw as { key?: unknown; renderMode?: unknown } | undefined; - return p?.key === params.key && automationProvider(p?.renderMode) === provider; - }); - let binding = found ? browserBindingFromParams(lath.getMeta(found.id)?.params) : null; - if (!binding && provider === 'playwright' && typeof params.key === 'string') { - binding = browserReservations.current.resolve(params.key, params.proposed); - } - detail.respond({ ok: true, result: { binding } }); + // `--key`: the Surface in this Wall bound to that key, else the key's + // own session in this Workspace, its first commands pinned to one cwd + // and executable (docs/specs/dor-browser.md → Managed identity). + const found = findBrowserSurface(provider, { key }); + const binding = (found ? browserBindingFromParams(lath.getMeta(found.id)?.params) : null) + ?? browserReservations.current.resolve(provider, key, browserKeyScope(), params.proposed, + // Window-wide: a Surface bound to this key's session may have left + // for another Workspace, still holding it. + (session) => listWallHandles().some((wall) => wall.browserSessions(provider).includes(session))); + detail.respond({ ok: true, result: legacy ? { session: binding.session } : { binding } }); return; } - if (detail.method === SURFACE_CONTROL_METHODS.agentBrowser || detail.method === SURFACE_CONTROL_METHODS.browser) { - const provider = detail.method === SURFACE_CONTROL_METHODS.browser && params.provider === 'playwright' - ? 'playwright' - : 'agent-browser'; + if (detail.method === SURFACE_CONTROL_METHODS.browser || detail.method === SURFACE_CONTROL_METHODS.agentBrowser) { + const provider = requestedProvider(detail.method === SURFACE_CONTROL_METHODS.agentBrowser); + if (!provider) return; const session = stringParam(params.session); if (!session) { detail.respond({ ok: false, error: 'session is required' }); @@ -1656,42 +1667,44 @@ export function useDorControl({ const binaryPath = isAllowedBinaryFor(provider, requestedBinaryPath) ? requestedBinaryPath : undefined; - // `dor ab` reads the stream port itself; the Playwright host serves the - // stream, so it is asked here, along with the display mode and native - // identity of the session `dor pw` just drove. + const cwd = stringParam(params.cwd); const key = stringParam(params.key); - let launch: { wsPort?: number; minimized: boolean; cwd?: string; headed?: boolean; nativeIdentity?: string }; - if (provider === 'playwright') { - // `dor pw` asks only after its native command succeeded, so the key's - // session exists even when no viewer can attach below (non-Chromium). - if (key) browserReservations.current.confirm(key); - const cwd = stringParam(params.cwd); - const platform = browserPlatform(provider, cwd); - if (!platform.agentBrowserAttach) { - detail.respond({ ok: false, error: 'Playwright is unavailable on this host' }); - return; - } - // No page named: a session the command left closed is not relaunched. - const status = await platform.agentBrowserAttach(session, {}, binaryPath); - if (!status.ok) { - detail.respond({ ok: false, error: status.error ?? 'Playwright connection failed' }); - return; - } - // The Workspace may have begun closing while the host connected. - if (isClosingWorkspace()) { - detail.respond({ ok: false, error: 'this workspace is closing' }); - return; - } - launch = { wsPort: status.wsPort, minimized: false, cwd, headed: status.headed, nativeIdentity: status.nativeIdentity }; - } else { - launch = { wsPort: numberParam(params.wsPort), minimized: booleanParam(params.minimized) }; + // The command asks only after it succeeded, so the key's session exists + // even when no viewer can attach below (a non-Chromium Playwright). + if (key) browserReservations.current.confirm(provider, key); + const browser = browserHandle(provider, { session, cwd, binaryPath }); + if (!browser) { + detail.respond({ ok: false, error: providerUnavailable(provider) }); + return; + } + // The host reports where the session the command just drove streams, + // its headedness when it can tell, and its native identity. No page + // named: a session the command left closed is not relaunched. The port + // `dor ab` read itself, under its own socket directory and CLI, is + // streamed from as it is; the host's state files may not describe that + // daemon at all (docs/specs/dor-browser.md → "agent-browser"). + const callerPort = provider === 'agent-browser' && isTcpPort(params.wsPort) ? params.wsPort : undefined; + const status: BrowserResult = callerPort === undefined ? await browser.attach() : { ok: true, wsPort: callerPort }; + if (!status.ok) { + detail.respond({ ok: false, error: status.error ?? `${BROWSER_PROVIDER_GUI[provider].label} connection failed` }); + return; + } + // The Workspace may have begun closing while the host answered. + if (isClosingWorkspace()) { + detail.respond({ ok: false, error: 'this workspace is closing' }); + return; } - const result = ensureAgentBrowserSurface({ + const result = ensureBrowserSurface({ provider, key, session, + cwd, binaryPath, - ...launch, + wsPort: status.wsPort, + headed: status.headed, + // agent-browser's is its session; every host answer names one. + nativeIdentity: status.nativeIdentity ?? session, + minimized: booleanParam(params.minimized), reference: () => resolveVisibleSurface(stringParam(params.surface), detail.surfaceId), }); if (!result.ok) { @@ -1699,7 +1712,7 @@ export function useDorControl({ return; } // Bound: later commands for the key resolve to this Surface. - if (provider === 'playwright' && key) browserReservations.current.delete(key); + if (key) browserReservations.current.delete(provider, key); detail.respond({ ok: true, result: { @@ -1753,32 +1766,8 @@ export function useDorControl({ return; } - if (detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser) { - // A managed `--key` names no Surface: it names this Workspace's browser of - // that name, so the answer is the key namespaced under the Workspace that - // will hold it (docs/specs/dor-browser.md → Managed identity). Answered - // whether or not a Surface holds that session yet — `surface.agentBrowser` - // is what creates or reuses one. - const keyParam = stringParam(params.key); - if (keyParam) { - detail.respond({ ok: true, result: { session: sessionForKey(keyParam, workspaceScope()) } }); - return; - } - // Resolve a browser Surface handle to the agent-browser session bound to - // it, for `dor ab --surface <handle> <verb...>`. - const target = requireBrowserSurface(params.surface, detail); - if (!target) return; - const session = requireAutomationSession(target, 'agent-browser', detail); - if (!session) return; - detail.respond({ - ok: true, - result: { surfaceId: target.id, surfaceRef: target.ref, session }, - }); - return; - } - detail.respond({ ok: false, error: unsupportedControlMethodMessage(detail.method) }); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, findSurfaceByParams, revealSurface, isClosingWorkspace, requireAutomationSession, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, workspaceScope]); + }, [browserKeyScope, buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureBrowserSurface, findBrowserSurface, findSurfaceIdRunningCommand, findSurfaceByParams, revealSurface, isClosingWorkspace, requireAutomationSession, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, workspaceScope]); return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/use-tool-serving.ts b/lib/src/components/wall/use-tool-serving.ts index 8b68edddd..1cabbd4e9 100644 --- a/lib/src/components/wall/use-tool-serving.ts +++ b/lib/src/components/wall/use-tool-serving.ts @@ -223,7 +223,7 @@ export function useToolServing({ // session it had, which a changed destination just navigates — and // binds the session once it is up; until then the pane shows the // destination and Workspace transfer waits (docs/specs/dor-browser.md - // -> "Agent-Browser Connection"). + // -> "Browser Connection"). const url = new URL(announcedPath, entry.url).href; lath.store.updateParams(leaf.id, { ...(leaf.params.toolRender === 'ab-screencast' diff --git a/lib/src/components/wall/wall-handles.ts b/lib/src/components/wall/wall-handles.ts index b2788f1a6..40f00ab90 100644 --- a/lib/src/components/wall/wall-handles.ts +++ b/lib/src/components/wall/wall-handles.ts @@ -1,3 +1,4 @@ +import type { BrowserAutomationProvider } from 'dor-lib-common/browser-providers'; import type { WorkspaceId } from '../../lib/session-types'; import type { SaveOptions } from '../../lib/session-save'; import type { PreparedWorkspaceTransfer } from './workspace-transfer'; @@ -23,6 +24,10 @@ export interface WallHandle { * Surfaces are not among them — their session lives in the host and * reconnects. */ iframeSurfaceRefs(): string[]; + /** The sessions of `provider` that member browser Surfaces are bound to or + * launching, which a `--key` elsewhere in the Window must not mint again + * (docs/specs/dor-browser.md → "Managed identity"). */ + browserSessions(provider: BrowserAutomationProvider): string[]; /** Any member terminal Session the user has typed into (the close confirmation * gate, alongside `runningCount`). */ hasTouchedSurfaces(): boolean; @@ -92,6 +97,7 @@ export function stubWallHandle(workspaceId: WorkspaceId, overrides: Partial<Wall surfaceIds: () => [], ownsSurface: () => false, iframeSurfaceRefs: () => [], + browserSessions: () => [], hasTouchedSurfaces: () => false, runningCount: () => 0, enterSelectedPane: () => {}, diff --git a/lib/src/components/wall/wall-test-utils.ts b/lib/src/components/wall/wall-test-utils.ts index 9fc861dd8..5a5fa2a6c 100644 --- a/lib/src/components/wall/wall-test-utils.ts +++ b/lib/src/components/wall/wall-test-utils.ts @@ -1,6 +1,9 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { vi } from 'vitest'; +import { BROWSER_PROVIDER_IDS, type BrowserAutomationProvider } from 'dor-lib-common/browser-providers'; +import { FakePtyAdapter, setPlatform } from '../../lib/platform'; +import type { BrowserOp, BrowserRequest, BrowserResult } from '../../lib/platform/browser-automation'; import type { WallActions } from './wall-context'; import { registerAgentBrowserScreen, @@ -198,3 +201,36 @@ export function registerStubScreen( renderModes: init.renderModes ?? ['ab-screencast', 'ab-popout', 'pw-screencast', 'pw-popout', 'iframe'], }); } + +/** How a fake browser host answers one kind of request. */ +export type BrowserAnswers = { + [K in BrowserOp['op']]?: (request: Extract<BrowserRequest, { op: K }>) => BrowserResult | Promise<BrowserResult>; +}; + +/** + * Install a platform whose host drives `providers`, answering each typed + * browser request from `answers` by operation — `{ ok: true }` where none is + * given. `answers` stays live, so a test may swap one mid-flight; `requests` + * reads back every request of one kind, in order, without the ids a Surface + * mints for its launches and attaches and a close's `cancels` of them — fresh + * UUIDs no test can predict (`browser`'s calls keep them). + */ +function withoutIds(request: BrowserRequest): BrowserRequest { + const { requestId: _id, cancels: _cancels, ...rest } = request as BrowserRequest & { requestId?: string; cancels?: string[] }; + return rest as BrowserRequest; +} + +export function installBrowserHost( + answers: BrowserAnswers = {}, + providers: readonly BrowserAutomationProvider[] = BROWSER_PROVIDER_IDS, +) { + const browser = vi.fn(async (request: BrowserRequest): Promise<BrowserResult> => { + const answer = answers[request.op] as ((r: BrowserRequest) => BrowserResult | Promise<BrowserResult>) | undefined; + return (await answer?.(request)) ?? { ok: true }; + }); + const platform = Object.assign(new FakePtyAdapter(), { browserProviders: providers, browser }); + setPlatform(platform); + const requests = <K extends BrowserOp['op']>(op: K): Extract<BrowserRequest, { op: K }>[] => + browser.mock.calls.map(([request]) => withoutIds(request)).filter((request): request is Extract<BrowserRequest, { op: K }> => request.op === op); + return { platform, browser, answers, requests }; +} diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index 93080061d..a1eaf0674 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -1,9 +1,12 @@ -import { existsSync, mkdtempSync, promises as fsp, statSync, writeFileSync } from 'fs'; +import { spawn } from 'child_process'; +import { existsSync, mkdtempSync, promises as fsp, statSync, utimesSync, writeFileSync } from 'fs'; import { createServer, type Server } from 'net'; import { tmpdir } from 'os'; import { dirname, join } from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createAgentBrowserHost } from './agent-browser-host'; +import type { BrowserOp, BrowserRequestBinding } from '../lib/platform/browser-automation'; +import { createAgentBrowserProvider } from './agent-browser-host'; +import { createBrowserHost } from './browser-host'; type SpawnResult = { stdout?: string; stderr?: string; code?: number }; @@ -82,13 +85,29 @@ function writeState(session: string, ext: 'pid' | 'stream', value: number): void // The host spawns through dor-lib-common's spawnAndCapture; mock just that // boundary (not its internal cross-spawn — spawnAndCapture's own behavior is -// covered by dor-lib-common's tests), keeping the package's other real exports -// (e.g. parseStreamPort). +// covered by dor-lib-common's tests), keeping the package's other real exports. vi.mock('dor-lib-common', async (importOriginal) => ({ ...(await importOriginal<typeof import('dor-lib-common')>()), spawnAndCapture: spawnMock, })); +type Host = ReturnType<typeof createBrowserHost>; + +/** The shared browser host, driving agent-browser only. */ +function makeHost(writeClipboardText = vi.fn()): Host { + return createBrowserHost({ writeClipboardText, providers: { 'agent-browser': () => createAgentBrowserProvider() } }); +} + +/** One agent-browser request through `host`, bound to `binding`. */ +function ab(host: Host, op: BrowserOp, binding: BrowserRequestBinding = {}) { + return host.request({ provider: 'agent-browser', binding, ...op }); +} + +/** The same, answering a screenshot with its file — the sidecar's transport. */ +function abFile(host: Host, op: BrowserOp, binding: BrowserRequestBinding = {}) { + return host.requestFile({ provider: 'agent-browser', binding, ...op }); +} + function enqueueSpawnResults(results: SpawnResult[]) { const queue = [...results]; spawnMock.mockImplementation((binary: string, args: string[]) => { @@ -124,10 +143,12 @@ describe('agent-browser host relaunch', () => { {}, // tab close blank-tab ]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const result = await host.popOut('dormouse.1.default', { url: 'https://example.com/' }, '/usr/local/bin/agent-browser'); + const host = makeHost(); + const result = await ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: 'dormouse.1.default', binaryPath: '/usr/local/bin/agent-browser' }); - expect(result).toEqual({ ok: true, wsPort: 61218 }); + expect(result).toEqual({ + ok: true, wsPort: 61218, headed: true, session: 'dormouse.1.default', nativeIdentity: 'dormouse.1.default', binaryPath: '/usr/local/bin/agent-browser', + }); await vi.waitFor(() => { expect(spawnMock).toHaveBeenCalledWith( '/usr/local/bin/agent-browser', @@ -151,8 +172,8 @@ describe('agent-browser host relaunch', () => { ? { stdout: JSON.stringify({ tabs: [{ tabId: 'blank', url: 'about:blank', active: false }, { tabId: 'real', url: 'https://example.com/', active: true }] }) } : {}), }); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const popOut = host.popOut(session, { url: 'https://example.com/' }); + const host = makeHost(); + const popOut = ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: session }); // While the stale files are all there is, the launch waits. await new Promise((resolve) => setTimeout(resolve, 350)); @@ -162,7 +183,7 @@ describe('agent-browser host relaunch', () => { try { writeState(session, 'pid', DEAD_PID + 1); writeState(session, 'stream', port); - expect(await popOut).toEqual({ ok: true, wsPort: port }); + expect(await popOut).toEqual({ ok: true, wsPort: port, headed: true, session, nativeIdentity: session }); // `open` has not returned, so no daemon command (the blank-tab sweep) has // been queued behind it. expect(calls.some((args) => args.includes('tab'))).toBe(false); @@ -200,18 +221,18 @@ describe('agent-browser host relaunch', () => { const stale = await closedPort(); writeState(session, 'pid', DEAD_PID); writeState(session, 'stream', stale); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const popOut = host.popOut(session, { url: 'https://example.com/' }); + const host = makeHost(); + const popOut = ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: session }); await vi.waitFor(() => expect(calls.some((args) => args.includes('--headed'))).toBe(true)); const { port, server } = await listen(); try { writeState(session, 'pid', DEAD_PID + 1); writeState(session, 'stream', port); - expect(await popOut).toEqual({ ok: true, wsPort: port }); + expect(await popOut).toEqual({ ok: true, wsPort: port, headed: true, session, nativeIdentity: session }); // The second relaunch invalidates the first one's post-open tail before // its close queues behind that still-pending `open` command. - void host.popIn(session, { url: 'https://example.com/' }); + void ab(host, { op: 'launch', url: 'https://example.com/', headed: false }, { session: session }); await vi.waitFor(() => expect(closeCount).toBe(2)); firstOpened.resolve({ code: 1, stderr: 'Operation timed out' }); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -242,18 +263,18 @@ describe('agent-browser host relaunch', () => { const stale = await closedPort(); writeState(session, 'pid', DEAD_PID); writeState(session, 'stream', stale); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const popOut = host.popOut(session, { url: 'https://example.com/' }); + const host = makeHost(); + const popOut = ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: session }); await vi.waitFor(() => expect(calls.some((args) => args.includes('--headed'))).toBe(true)); const { port, server } = await listen(); try { writeState(session, 'pid', DEAD_PID + 1); writeState(session, 'stream', port); - expect(await popOut).toEqual({ ok: true, wsPort: port }); + expect(await popOut).toEqual({ ok: true, wsPort: port, headed: true, session, nativeIdentity: session }); // Pane kill/render-swap enters command('close') and invalidates the // relaunch tail synchronously, before the close queues behind open. - void host.command(session, ['close']); + void ab(host, { op: 'close' }, { session: session }); await vi.waitFor(() => expect(closeCount).toBe(2)); opened.resolve({ code: 1, stderr: 'Operation timed out' }); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -289,16 +310,16 @@ describe('agent-browser host relaunch', () => { const stale = await closedPort(); writeState(session, 'pid', DEAD_PID); writeState(session, 'stream', stale); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const popOut = host.popOut(session, { url: 'https://example.com/' }); + const host = makeHost(); + const popOut = ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: session }); await vi.waitFor(() => expect(calls.some((args) => args.includes('--headed'))).toBe(true)); const { port, server } = await listen(); try { writeState(session, 'pid', DEAD_PID + 1); writeState(session, 'stream', port); - expect(await popOut).toEqual({ ok: true, wsPort: port }); + expect(await popOut).toEqual({ ok: true, wsPort: port, headed: true, session, nativeIdentity: session }); - await host.closePoppedOut(); + await host.close(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(closeCount).toBe(2); @@ -309,7 +330,7 @@ describe('agent-browser host relaunch', () => { }); it('open() treats a timed-out page load as a live launch, and a launch with no daemon as a failure', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); let session = ''; // Timed out, daemon up (pid file present): the browser is on the page. mockSpawnByCommand({ @@ -320,7 +341,9 @@ describe('agent-browser host relaunch', () => { }, stream: () => ({ stdout: JSON.stringify({ port: 61219 }) }), }); - expect(await host.open('https://slow.example/', {})).toEqual({ ok: true, session: expect.stringMatching(/^dormouse\.1\.gui-/), wsPort: 61219 }); + expect(await ab(host, { op: 'launch', url: 'https://slow.example/', headed: false })).toEqual({ + ok: true, session: expect.stringMatching(/^dormouse\.1\.gui-/), nativeIdentity: session, wsPort: 61219, headed: false, + }); // Failed with no daemon at all: fail, and close so nothing half-launched // outlives the swap. @@ -328,21 +351,21 @@ describe('agent-browser host relaunch', () => { open: () => ({ code: 1, stderr: 'boom' }), close: () => ({}), }); - expect(await host.open('https://slow.example/', {})).toEqual({ ok: false, error: 'boom' }); + expect(await ab(host, { op: 'launch', url: 'https://slow.example/', headed: false })).toEqual({ ok: false, error: 'boom' }); expect(calls.some((args) => args[2] === 'close')).toBe(true); expect(calls.some((args) => args[2] === 'stream')).toBe(false); }); it('reports a zero-exit launch that publishes no stream port without claiming it exited unsuccessfully', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); mockSpawnByCommand({ open: () => ({}), stream: () => ({ stdout: '{}' }), close: () => ({}), }); - expect(await host.open('https://example.com/', {})).toEqual({ + expect(await ab(host, { op: 'launch', url: 'https://example.com/', headed: false })).toEqual({ ok: false, - error: 'open published no stream port', + error: 'agent-browser published no stream port', }); mockSpawnByCommand({ @@ -350,13 +373,118 @@ describe('agent-browser host relaunch', () => { '--headed open': () => ({}), stream: () => ({ stdout: '{}' }), }); - expect(await host.popOut('dormouse.1.default', { url: 'https://example.com/' })).toEqual({ + expect(await ab(host, { op: 'launch', url: 'https://example.com/', headed: true }, { session: 'dormouse.1.default' })).toEqual({ ok: false, - error: 'popOut open published no stream port', + error: 'agent-browser published no stream port', }); }); }); +describe('agent-browser host daemon stop', () => { + useTempSocketDir('dormouse-ab-host-stop-'); + const session = 'dormouse.1.tool.t'; + const page = 'http://localhost:6006/'; + + it('navigates a live headless daemon a named launch reopens, stopping nothing', async () => { + const { port, server } = await listen(); + writeState(session, 'pid', process.pid); + writeState(session, 'stream', port); + const calls = mockSpawnByCommand({ open: () => ({}) }); + const realKill = process.kill.bind(process); + const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => (signal === 0 ? realKill(pid, 0) : true)); + try { + // A Tool re-announced: the same session, headless, another page. + expect(await ab(makeHost(), { op: 'launch', url: page, headed: false }, { session })).toMatchObject({ ok: true, wsPort: port, headed: false }); + await vi.waitFor(() => expect(calls).toEqual([['--session', session, 'open', page]])); + expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); + } finally { + kill.mockRestore(); + await closeServer(server); + } + }); + + it.each([ + ['names a live process from before this boot', true, true], + ['names a live process with no stream that accepts', false, false], + ])('never signals a pid file that %s', async (_name, streaming, preBoot) => { + const { port, server } = await listen(); + writeState(session, 'pid', process.pid); + writeState(session, 'stream', streaming ? port : await closedPort()); + if (preBoot) { + for (const ext of ['pid', 'stream']) utimesSync(join(process.env.AGENT_BROWSER_SOCKET_DIR!, `${session}.${ext}`), 0, 0); + } + const calls = mockSpawnByCommand({ close: () => ({}), '--headed open': () => ({}), stream: () => ({ stdout: JSON.stringify({ port }) }), tab: () => ({ stdout: JSON.stringify({ tabs: [] }) }) }); + // The pid named is this test's own: record a signal rather than send it. + const realKill = process.kill.bind(process); + const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => (signal === 0 ? realKill(pid, 0) : true)); + try { + // A pop-out: the other mode, so a relaunch that stops what runs first. + const host = makeHost(); + await ab(host, { op: 'launch', url: page, headed: true }, { session }); + // `close` starts no daemon; only the signal needs proof. + expect(calls.filter((args) => args.includes('close'))).toEqual([['--session', session, 'close']]); + expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); + await host.close(); + } finally { + kill.mockRestore(); + await closeServer(server); + } + }); + + it('bounds the CLI a close runs, which a hung daemon would otherwise hold forever', async () => { + enqueueSpawnResults([{}]); + await ab(makeHost(), { op: 'close' }, { session }); + expect(spawnMock).toHaveBeenCalledExactlyOnceWith('agent-browser', ['--session', session, 'close'], { timeoutMs: 10_000 }); + }); + + it('terminates a daemon its state files prove live before relaunching it in the other mode', async () => { + const { port, server } = await listen(); + const daemon = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + await new Promise((resolve) => daemon.once('spawn', resolve)); + const exited = new Promise((resolve) => daemon.once('exit', resolve)); + writeState(session, 'pid', daemon.pid!); + writeState(session, 'stream', port); + mockSpawnByCommand({ close: () => ({}), '--headed open': () => ({}), stream: () => ({ stdout: JSON.stringify({ port }) }), tab: () => ({ stdout: JSON.stringify({ tabs: [] }) }) }); + const kill = vi.spyOn(process, 'kill'); + try { + const host = makeHost(); + await ab(host, { op: 'launch', url: page, headed: true }, { session }); + expect(kill).toHaveBeenCalledWith(daemon.pid, 'SIGTERM'); + await exited; + // The port read that ends the launch is bounded by the launch's deadline. + const status = spawnMock.mock.calls.find(([, args]) => (args as string[]).includes('stream'))!; + expect(status[2]).toEqual({ timeoutMs: expect.any(Number) }); + expect(status[2].timeoutMs).toBeGreaterThan(0); + expect(status[2].timeoutMs).toBeLessThanOrEqual(40_000); + await host.close(); + } finally { + kill.mockRestore(); + daemon.kill('SIGKILL'); + await closeServer(server); + } + }); +}); + +describe('agent-browser host launch directory', () => { + useTempSocketDir('dormouse-ab-cwd-test-'); + + // agent-browser reads `./agent-browser.json` from its working directory, so + // a GUI launch or relaunch must run where the `dor ab` that made the pane ran. + it('opens in the binding\'s project directory, and in the host\'s once that is gone', async () => { + const project = mkdtempSync(join(tmpdir(), 'dormouse-ab-project-')); + const host = makeHost(); + const openCalls = () => spawnMock.mock.calls.filter(([, args]) => (args as string[]).includes('open')); + mockSpawnByCommand({ open: () => ({ code: 1, stderr: 'boom' }), close: () => ({}) }); + await ab(host, { op: 'launch', url: 'http://localhost:5173/', headed: false }, { cwd: project }); + expect(openCalls()[0][2]).toEqual({ cwd: project }); + + await fsp.rm(project, { recursive: true }); + spawnMock.mockClear(); + await ab(host, { op: 'launch', url: 'http://localhost:5173/', headed: false }, { cwd: project }); + expect(openCalls()[0]).toHaveLength(2); + }); +}); + describe('agent-browser host attach', () => { const session = 'dormouse.1.default'; useTempSocketDir('dormouse-ab-attach-test-'); @@ -367,8 +495,8 @@ describe('agent-browser host attach', () => { // This test process stands in for the live daemon. writeState(session, 'pid', process.pid); writeState(session, 'stream', port); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - expect(await host.attach(session, { url: 'https://example.com/' })).toEqual({ ok: true, wsPort: port }); + const host = makeHost(); + expect(await ab(host, { op: 'attach', url: 'https://example.com/' }, { session })).toEqual({ ok: true, wsPort: port, session, nativeIdentity: session }); expect(spawnMock).not.toHaveBeenCalled(); } finally { await closeServer(server); @@ -376,13 +504,13 @@ describe('agent-browser host attach', () => { }); it('never relaunches a daemon that is up but not streaming, nor a gone one it has no page for', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); writeState(session, 'pid', process.pid); writeState(session, 'stream', await closedPort()); - expect((await host.attach(session, { url: 'https://example.com/' })).ok).toBe(false); + expect((await ab(host, { op: 'attach', url: 'https://example.com/' }, { session })).ok).toBe(false); writeState(session, 'pid', DEAD_PID); - expect((await host.attach(session, {})).ok).toBe(false); + expect((await ab(host, { op: 'attach' }, { session })).ok).toBe(false); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -393,51 +521,65 @@ describe('agent-browser host attach', () => { const opened = deferred<SpawnResult>(); const calls = mockSpawnByCommand({ '--headed open': () => { - writeState(session, 'pid', DEAD_PID + 1); + // This test process stands in for the relaunched daemon. + writeState(session, 'pid', process.pid); writeState(session, 'stream', port); return opened.promise; }, + tab: () => ({ stdout: JSON.stringify({ tabs: [{ tabId: 'real', url: 'https://example.com/', active: true }] }) }), close: () => ({}), }); try { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); // Two panes restoring one session relaunch it once. const [first, second] = await Promise.all([ - host.attach(session, { url: 'https://example.com/', headed: true }), - host.attach(session, { url: 'https://example.com/', headed: true }), + ab(host, { op: 'attach', url: 'https://example.com/', headed: true }, { session }), + ab(host, { op: 'attach', url: 'https://example.com/', headed: true }, { session }), ]); // Opened at the page, so the caller has no navigation left to run. - expect(first).toEqual({ ok: true, wsPort: port, relaunched: true }); - expect(second).toEqual(first); - expect(calls).toEqual([['--session', session, '--headed', 'open', 'https://example.com/']]); - // A cold start leaves no stray blank tab to sweep once `open` returns. + // Opened at the page, so the caller has no navigation left to run; the + // second found the browser the first brought up, at the first's page. + expect(first).toEqual({ ok: true, wsPort: port, relaunched: true, session, nativeIdentity: session }); + expect(second).toEqual({ ok: true, wsPort: port, session, nativeIdentity: session }); + expect(calls).toEqual([ + ['--session', session, 'close'], + ['--session', session, '--headed', 'open', 'https://example.com/'], + ]); + // Once `open` returns, the sweep finds no stray blank tab to close. opened.resolve({}); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(calls).toHaveLength(1); + await vi.waitFor(() => expect(calls).toContainEqual(['--session', session, 'tab', 'list', '--json'])); + expect(calls.filter((args) => args.includes('close'))).toEqual([['--session', session, 'close']]); - await host.closePoppedOut(); + await host.close(); expect(calls).toContainEqual(['--session', session, 'close']); } finally { await closeServer(server); } }); - it('opens a page in a caller-named session, and refuses a malformed one', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + it('relaunches a caller-named session at the page, and refuses a malformed one', async () => { + const host = makeHost(); const { port, server } = await listen(); const calls = mockSpawnByCommand({ + close: () => ({}), open: () => { writeState('dormouse.1.tool.a', 'pid', DEAD_PID); writeState('dormouse.1.tool.a', 'stream', port); return {}; }, stream: () => ({ stdout: JSON.stringify({ port }) }), + tab: () => ({ stdout: JSON.stringify({ tabs: [] }) }), }); try { - expect(await host.open('http://localhost:5173/', { session: 'dormouse.1.tool.a' })).toEqual({ ok: true, session: 'dormouse.1.tool.a', wsPort: port }); - expect(calls[0]).toEqual(['--session', 'dormouse.1.tool.a', 'open', 'http://localhost:5173/']); + const binding = { session: 'dormouse.1.tool.a' }; + expect(await ab(host, { op: 'launch', url: 'http://localhost:5173/', headed: false }, binding)).toEqual({ + ok: true, session: 'dormouse.1.tool.a', nativeIdentity: 'dormouse.1.tool.a', wsPort: port, headed: false, + }); + // Whatever held the session is closed first, so the launch lands headless. + expect(calls[0]).toEqual(['--session', 'dormouse.1.tool.a', 'close']); + expect(calls).toContainEqual(['--session', 'dormouse.1.tool.a', 'open', 'http://localhost:5173/']); const spawned = calls.length; - expect(await host.open('http://localhost:5173/', { session: '../evil' })).toEqual({ ok: false, error: 'a valid session name is required' }); + expect(await ab(host, { op: 'launch', url: 'http://localhost:5173/', headed: false }, { session: '../evil' })).toEqual({ ok: false, error: 'a valid session name is required' }); expect(calls).toHaveLength(spawned); } finally { await closeServer(server); @@ -450,24 +592,36 @@ describe('agent-browser host screenshot transport', () => { // makes vitest register it as a teardown hook and call it — a phantom spawn. beforeEach(() => { spawnMock.mockReset(); }); - it('screenshotToFile returns the path + mime without reading the bytes', async () => { - // Only the CLI spawn happens — no file is written by the mock. - enqueueSpawnResults([{}]); // screenshot exits 0 - - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const result = await host.screenshotToFile('shotfile', { format: 'jpeg', quality: 85 }, '/usr/local/bin/agent-browser'); - - expect(result.ok).toBe(true); - const shotPath = result.ok ? result.path : ''; - expect(result).toEqual({ ok: true, path: shotPath, mime: 'image/jpeg' }); - // The capture never touched the filesystem: the path points at a file that - // does not exist (the mock spawned nothing that would create it). - expect(existsSync(shotPath)).toBe(false); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledWith('/usr/local/bin/agent-browser', [ - '--session', 'shotfile', 'screenshot', shotPath, - '--screenshot-format', 'jpeg', '--screenshot-quality', '85', - ], { timeoutMs: 30_000 }); + /** agent-browser's `screenshot <path>`, writing `frames` in turn. */ + function captureFrames(...frames: number[][]): string[][] { + const queue = [...frames]; + const calls: string[][] = []; + spawnMock.mockImplementation(async (_binary: string, args: string[]) => { + calls.push(args); + if (args.includes('screenshot')) writeFileSync(args[args.indexOf('screenshot') + 1], Uint8Array.from(queue.shift() ?? [0])); + return spawnResult({}); + }); + return calls; + } + const read = async (file: string) => Array.from(await fsp.readFile(file)); + const filesIn = async (dir: string) => (await fsp.readdir(dir).catch(() => [] as string[])).sort(); + + it('hands the file transport a fresh file per capture, which a later capture never rewrites', async () => { + const calls = captureFrames([1, 1], [2, 2]); + const host = makeHost(); + const binding = { session: 'shotfile', binaryPath: '/usr/local/bin/agent-browser' }; + const first = await abFile(host, { op: 'screenshot', format: 'jpeg', quality: 85 }, binding); + expect(first).toEqual({ ok: true, path: expect.any(String), mime: 'image/jpeg' }); + expect(calls[0]).toEqual(['--session', 'shotfile', 'screenshot', expect.any(String), '--screenshot-format', 'jpeg', '--screenshot-quality', '85']); + + // The next frame is taken while the reader has yet to read the first. + const second = await abFile(host, { op: 'screenshot', format: 'jpeg' }, binding); + expect(second.path).not.toBe(first.path); + expect(await read(first.path!)).toEqual([1, 1]); + expect(await read(second.path!)).toEqual([2, 2]); + // Only the files handed out remain: each capture's own file went once read. + expect(await filesIn(dirname(first.path!))).toEqual([first.path!, second.path!].map((file) => file.slice(dirname(file).length + 1)).sort()); + await host.close(); }); // The frame is a picture of the user's authenticated browser, written by an @@ -475,109 +629,101 @@ describe('agent-browser host screenshot transport', () => { // os.tmpdir() let any other local account read every frame, or pre-create the // name as a symlink and have agent-browser clobber the target. it('captures into a private, unguessable directory rather than a derivable tmp path', async () => { - enqueueSpawnResults([{}, {}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - - const first = await host.screenshotToFile('dormouse.1.default', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); - const second = await host.screenshotToFile('dormouse.1.default', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); - if (!first.ok || !second.ok) throw new Error('expected both captures to resolve a path'); - - // Nothing about the path is derivable from the session name. - expect(first.path).not.toContain('dormouse.1.default'); - expect(first.path).not.toBe(join(tmpdir(), 'dormouse-ab-shot-dormouse.1.default.jpg')); - // Still reused per session, so one file per frame does not accumulate. - expect(second.path).toBe(first.path); - - const dir = dirname(first.path); - expect(dir).not.toBe(tmpdir()); - expect(statSync(dir).mode & 0o777).toBe(0o700); + const calls = captureFrames([1]); + const host = makeHost(); + const shot = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'dormouse.1.default', binaryPath: '/usr/local/bin/agent-browser' }); + if (!shot.ok) throw new Error('expected a path'); + for (const file of [shot.path!, calls[0][3]]) { + // Nothing about the path is derivable from the session name. + expect(file).not.toContain('dormouse.1.default'); + expect(dirname(file)).not.toBe(tmpdir()); + expect(statSync(dirname(file)).mode & 0o777).toBe(0o700); + } + await host.close(); }); - it('screenshot() still reads the file and returns the raw bytes', async () => { - const payload = Uint8Array.from([0xff, 0xd8, 0xff, 0x01, 0x02, 0x03]); - // Stand in for agent-browser writing the frame: the host chooses the path, - // so learn it from a capture first, then write there. - enqueueSpawnResults([{}, {}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const located = await host.screenshotToFile('shotbytes', { format: 'jpeg', quality: 85 }, '/usr/local/bin/agent-browser'); - if (!located.ok) throw new Error('expected a path'); - writeFileSync(located.path, payload); - - const result = await host.screenshot('shotbytes', { format: 'jpeg', quality: 85 }, '/usr/local/bin/agent-browser'); - - expect(result.ok).toBe(true); + it('answers the bytes transport with the frame, leaving no file', async () => { + captureFrames([0xff, 0xd8, 0xff, 0x01]); + const host = makeHost(); + const result = await ab(host, { op: 'screenshot', format: 'jpeg', quality: 85 }, { session: 'shotbytes', binaryPath: '/usr/local/bin/agent-browser' }); expect(result.mime).toBe('image/jpeg'); - expect(Array.from(result.bytes ?? [])).toEqual(Array.from(payload)); - await host.closePoppedOut(); // drops the capture directory + expect(Array.from(result.bytes ?? [])).toEqual([0xff, 0xd8, 0xff, 0x01]); + const probe = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'shotbytes' }); + expect(await filesIn(dirname(probe.path!))).toEqual([probe.path!.slice(dirname(probe.path!).length + 1)]); + await host.close(); + }); + + it('deletes frames never read when their browser closes or relaunches, and any older than every reader\'s wait', async () => { + captureFrames([1], [2], [3], [4]); + const host = makeHost(); + const unread = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'left' }); + await ab(host, { op: 'close' }, { session: 'left' }); + await vi.waitFor(() => expect(existsSync(unread.path!)).toBe(false)); + + // A reader that gave up never comes for its frame. + const stale = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'kept' }); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 40_001); + try { + const next = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'kept' }); + await vi.waitFor(() => expect(existsSync(stale.path!)).toBe(false)); + expect(existsSync(next.path!)).toBe(true); + } finally { + clock.mockRestore(); + } + await host.close(); }); it('drops the capture directory on shutdown', async () => { - enqueueSpawnResults([{}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const shot = await host.screenshotToFile('shutdown-sess', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); + captureFrames([1, 2, 3]); + const host = makeHost(); + const shot = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'shutdown-sess', binaryPath: '/usr/local/bin/agent-browser' }); if (!shot.ok) throw new Error('expected a path'); - writeFileSync(shot.path, Uint8Array.from([1, 2, 3])); // stand in for the capture - const dir = dirname(shot.path); + const dir = dirname(shot.path!); - await host.closePoppedOut(); + await host.close(); // A frame of the user's authenticated browser must not outlive the process // that took it, waiting on whenever the OS gets round to reaping tmp. expect(existsSync(dir)).toBe(false); }); - it('removes the frame once its bytes have been read', async () => { - enqueueSpawnResults([{}, {}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - const located = await host.screenshotToFile('read-sess', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); - if (!located.ok) throw new Error('expected a path'); - writeFileSync(located.path, Uint8Array.from([0xff, 0xd8])); - - await host.screenshot('read-sess', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); - - // `screenshot()` owns the file's whole life — the bytes went to the webview. - expect(existsSync(located.path)).toBe(false); - await host.closePoppedOut(); - }); - - // `oneCapture` in agent-browser-host.ts says why. it('joins a capture already in flight for the session instead of spawning another', async () => { const release = deferred<SpawnResult>(); - let file = ''; spawnMock.mockImplementation(async (_binary: string, args: string[]) => { - file = args[3]; const result = await release.promise; - writeFileSync(file, Uint8Array.from([0xff, 0xd8, 0x01])); + writeFileSync(args[3], Uint8Array.from([0xff, 0xd8, 0x01])); return spawnResult(result); }); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); const paths = [ - host.screenshotToFile('queued', { format: 'jpeg' }), - host.screenshotToFile('queued', { format: 'jpeg' }), + abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'queued' }), + abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'queued' }), ]; const bytes = [ - host.screenshot('queued-bytes', { format: 'jpeg' }), - host.screenshot('queued-bytes', { format: 'jpeg' }), + ab(host, { op: 'screenshot', format: 'jpeg' }, { session: 'queued-bytes' }), + ab(host, { op: 'screenshot', format: 'jpeg' }, { session: 'queued-bytes' }), ]; await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)); // one per session await new Promise((resolve) => setTimeout(resolve, 20)); expect(spawnMock).toHaveBeenCalledTimes(2); release.resolve({}); + // Each file caller gets its own copy, which its reader deletes. const [first, second] = await Promise.all(paths); - expect(second).toEqual(first); - // Both callers get the frame from the one read, which removed the file. + expect(second.path).not.toBe(first.path); + for (const result of [first, second]) expect(await read(result.path!)).toEqual([0xff, 0xd8, 0x01]); for (const result of await Promise.all(bytes)) { expect(Array.from(result.bytes ?? [])).toEqual([0xff, 0xd8, 0x01]); } // Once it has answered, the next request captures afresh. spawnMock.mockReset(); - enqueueSpawnResults([{}]); - await host.screenshotToFile('queued', { format: 'jpeg' }); + captureFrames([1]); + await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'queued' }); expect(spawnMock).toHaveBeenCalledTimes(1); - await host.closePoppedOut(); + await host.close(); }); it('joins no capture from before a close or relaunch, and bounds every capture', async () => { @@ -590,9 +736,9 @@ describe('agent-browser host screenshot transport', () => { } return spawnResult({ code: args.includes('open') ? 1 : 0 }); }); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); const capture = async () => { - void host.screenshotToFile('wedged', { format: 'jpeg' }); + void abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'wedged' }); await vi.waitFor(() => expect(shots.length).toBeGreaterThan(0)); await new Promise((resolve) => setTimeout(resolve, 20)); }; @@ -602,12 +748,12 @@ describe('agent-browser host screenshot transport', () => { expect(shots).toHaveLength(1); // A close ends the session those captures were for. - await host.command('wedged', ['close']); + await ab(host, { op: 'close' }, { session: 'wedged' }); await capture(); expect(shots).toHaveLength(2); // So does a relaunch, which reuses the session name. - await host.popIn('wedged', { url: 'https://example.com/' }); + await ab(host, { op: 'launch', url: 'https://example.com/', headed: false }, { session: 'wedged' }); await capture(); expect(shots).toHaveLength(3); @@ -618,25 +764,25 @@ describe('agent-browser host screenshot transport', () => { for (const call of spawnMock.mock.calls.filter((c) => (c[1] as string[]).includes('screenshot'))) { expect(call[2]).toEqual({ timeoutMs: 30_000 }); } - await host.closePoppedOut(); + await host.close(); }); it('answers a capture-directory failure as a result, and retries the next time', async () => { // `??=` on the mkdtemp promise would memoize a rejection, so one transient // EACCES/ENOSPC on tmpdir would disable screenshots for the whole process. const mkdtemp = vi.spyOn(fsp, 'mkdtemp').mockRejectedValueOnce(new Error('ENOSPC: no space left on device')); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); - const failed = await host.screenshotToFile('retry-sess', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); + const failed = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'retry-sess', binaryPath: '/usr/local/bin/agent-browser' }); expect(failed.ok).toBe(false); expect(failed.ok === false && failed.error).toContain('ENOSPC'); expect(spawnMock).not.toHaveBeenCalled(); // never spawned without a path mkdtemp.mockRestore(); - enqueueSpawnResults([{}]); - const recovered = await host.screenshotToFile('retry-sess', { format: 'jpeg' }, '/usr/local/bin/agent-browser'); + captureFrames([1]); + const recovered = await abFile(host, { op: 'screenshot', format: 'jpeg' }, { session: 'retry-sess', binaryPath: '/usr/local/bin/agent-browser' }); expect(recovered.ok).toBe(true); - await host.closePoppedOut(); + await host.close(); }); // `binaryPath` crosses from the webview realm and off the persisted session @@ -645,9 +791,9 @@ describe('agent-browser host screenshot transport', () => { // open / popOut too — the entry points the subcommand allowlist never saw. it('refuses a caller-supplied binary path that is not an agent-browser', async () => { enqueueSpawnResults([{}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); - await host.command('sess', ['reload'], '/usr/bin/curl'); + await ab(host, { op: 'history', dir: 'reload' }, { session: 'sess', binaryPath: '/usr/bin/curl' }); expect(spawnMock).toHaveBeenCalledTimes(1); // Fell through to the host's own candidate rather than spawning curl. @@ -658,104 +804,108 @@ describe('agent-browser host screenshot transport', () => { for (const candidate of ['/opt/homebrew/bin/agent-browser', 'C:\\tools\\agent-browser.cmd']) { spawnMock.mockReset(); enqueueSpawnResults([{}]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - await host.command('sess', ['reload'], candidate); + const host = makeHost(); + await ab(host, { op: 'history', dir: 'reload' }, { session: 'sess', binaryPath: candidate }); expect(spawnMock.mock.calls[0][0]).toBe(candidate); } }); }); -// `parseWebviewCommand` in browser-host-shared.ts says why every token is checked. -describe('agent-browser host webview argv', () => { +// `parseBrowserRequest` in browser-host.ts says why every field is checked. +describe('agent-browser host requests', () => { useTempSocketDir('dormouse-ab-argv-test-'); - - it('runs exactly the argv shapes the webview sends', async () => { - const shapes = [ - ['open', 'https://example.com/path?q=1'], - ['open', 'http://localhost:5173/'], - ['back'], - ['forward'], - ['reload'], - ['close'], - ['get', 'cdp-url'], - ['tab', 't2'], - ['tab', 'close', 't2'], - ['tab', 'list', '--json'], - ['set', 'viewport', '1280', '720', '2'], - ['set', 'viewport', '801', '599', '1.100000023841858'], - ['set', 'device', 'iPhone 16 Pro'], - ['set', 'device', 'iPad (gen 11)'], + const session = { session: 'dormouse.1.gui-abc' }; + + it('renders each operation to exactly one fixed argv', async () => { + const shapes: [BrowserOp, string[]][] = [ + [{ op: 'navigate', url: 'https://example.com/path?q=1' }, ['open', 'https://example.com/path?q=1']], + [{ op: 'navigate', url: 'http://localhost:5173/' }, ['open', 'http://localhost:5173/']], + [{ op: 'history', dir: 'back' }, ['back']], + [{ op: 'history', dir: 'forward' }, ['forward']], + [{ op: 'history', dir: 'reload' }, ['reload']], + [{ op: 'close' }, ['close']], + [{ op: 'tab', action: 'select', tabId: 't2' }, ['tab', 't2']], + [{ op: 'tab', action: 'close', tabId: 't2' }, ['tab', 'close', 't2']], + [{ op: 'viewport', width: 1280, height: 720, dpr: 2 }, ['set', 'viewport', '1280', '720', '2']], + [{ op: 'viewport', width: 801, height: 599, dpr: 1.100000023841858 }, ['set', 'viewport', '801', '599', '1.100000023841858']], + [{ op: 'device', name: 'iPhone 16 Pro' }, ['set', 'device', 'iPhone 16 Pro']], + [{ op: 'device', name: 'iPad (gen 11)' }, ['set', 'device', 'iPad (gen 11)']], ]; - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - for (const args of shapes) { + const host = makeHost(); + for (const [op, argv] of shapes) { spawnMock.mockReset(); enqueueSpawnResults([{}]); - // The one shape with a flag of its own is the host's: the webview asks for `tab list`. - const sent = args[2] === '--json' ? ['tab', 'list'] : args; - expect(await host.command('dormouse.1.gui-abc', sent)).toEqual({ exitCode: 0, stdout: '', stderr: '' }); - expect(spawnMock.mock.calls[0][1]).toEqual(['--session', 'dormouse.1.gui-abc', ...args]); + expect(await ab(host, op, session)).toEqual({ ok: true }); + expect(spawnMock.mock.calls[0][1]).toEqual(['--session', 'dormouse.1.gui-abc', ...argv]); } + // The CDP endpoint is read from what the CLI prints. + spawnMock.mockReset(); + enqueueSpawnResults([{ stdout: 'ws://127.0.0.1:9222/devtools/browser/abc\n' }]); + expect(await ab(host, { op: 'cdpUrl' }, session)).toEqual({ ok: true, url: 'ws://127.0.0.1:9222/devtools/browser/abc' }); + expect(spawnMock.mock.calls[0][1]).toEqual(['--session', 'dormouse.1.gui-abc', 'get', 'cdp-url']); }); - it('refuses any other argv, including an allowlisted verb carrying options', async () => { + it('refuses any other request, including a field carrying options', async () => { const refused: unknown[] = [ - ['open', 'https://example.com/', '--executable-path', '/tmp/evil'], - ['open', '--executable-path=/tmp/evil'], - ['open', ' https://example.com/'], - ['open', 'file:///etc/passwd'], - ['open', 'javascript:alert(1)'], - ['set', 'viewport', '100', '100', '11'], - ['close', '--all'], - ['back', '--profile', '/tmp/p'], - ['get', 'cdp-url', '--init-script', '/tmp/x.js'], - ['get', 'text', 'body'], - ['tab', 'new', 'https://example.com/'], - ['tab', '--extension', '/tmp/ext'], - ['tab', 'close', '--args=--disable-web-security'], - ['set', 'viewport', '100', '100', '--proxy=http://evil'], - ['set', 'viewport', '100', '100'], - ['set', 'device', '--state=/tmp/s.json'], - ['set', 'headers', '{"x":"y"}'], - ['screenshot', '/Users/someone/.zshrc'], - ['eval', 'document.cookie'], - ['constructor'], - [], - ['open', 42], - 'open https://example.com/', + { op: 'navigate', url: '--executable-path=/tmp/evil' }, + { op: 'navigate', url: ' https://example.com/' }, + { op: 'navigate', url: 'file:///etc/passwd' }, + { op: 'navigate', url: 'javascript:alert(1)' }, + { op: 'viewport', width: 100, height: 100, dpr: 11 }, + { op: 'viewport', width: 100, height: 100 }, + { op: 'viewport', width: '100', height: 100, dpr: 1 }, + { op: 'history', dir: '--profile' }, + { op: 'tab', action: 'select', tabId: 'new' }, + { op: 'tab', action: 'select', tabId: '--extension' }, + { op: 'tab', action: 'close', tabId: '--args=--disable-web-security' }, + { op: 'tab', action: 'list', tabId: 't2' }, + { op: 'device', name: '--state=/tmp/s.json' }, + { op: 'screenshotTo', path: '/Users/someone/.zshrc' }, + { op: 'eval', script: 'document.cookie' }, + { op: 'constructor' }, + {}, ]; - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - for (const args of refused) { - const result = await host.command('dormouse.1.gui-abc', args as string[]); - expect(result.exitCode, JSON.stringify(args)).toBe(1); - expect(result.stderr).toMatch(/is not allowed from the webview/); + const host = makeHost(); + for (const op of refused) { + const result = await host.request({ provider: 'agent-browser', binding: session, ...(op as object) }); + expect(result.ok, JSON.stringify(op)).toBe(false); + } + for (const raw of [null, 'navigate https://example.com/', { provider: 'lynx', binding: session, op: 'close' }]) { + expect((await host.request(raw)).ok).toBe(false); } expect(spawnMock).not.toHaveBeenCalled(); }); - it('refuses an option-shaped or path-shaped session on every entry point', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + it('refuses an option-shaped or path-shaped session on every operation', async () => { + const host = makeHost(); + const ops: BrowserOp[] = [ + { op: 'close' }, + { op: 'edit', edit: 'copy' }, + { op: 'screenshot' }, + { op: 'attach', url: 'https://example.com/' }, + { op: 'launch', url: 'https://example.com/', headed: true }, + { op: 'launch', url: 'https://example.com/', headed: false }, + ]; for (const session of ['--executable-path', '-x', '../../tmp/evil', 'a/b', 'a\\b', 'a\nb', '']) { - expect((await host.command(session, ['close'])).exitCode).toBe(1); - expect((await host.edit(session, 'copy')).ok).toBe(false); - expect((await host.screenshotToFile(session, {})).ok).toBe(false); - expect((await host.attach(session, { url: 'https://example.com/' })).ok).toBe(false); - expect((await host.popOut(session, { url: 'https://example.com/' })).ok).toBe(false); - expect((await host.popIn(session, { url: 'https://example.com/' })).ok).toBe(false); + for (const op of ops) { + expect(await ab(host, op, { session })).toEqual({ ok: false, error: 'a valid session name is required' }); + expect((await abFile(host, op, { session })).ok).toBe(false); + } } expect(spawnMock).not.toHaveBeenCalled(); }); - it('refuses a launch URL that is not http(s), and relaunches at about:blank instead', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - expect(await host.open('--executable-path=/tmp/evil', {})).toEqual({ ok: false, error: 'an http(s) url is required' }); - expect(await host.open('file:///etc/passwd', {})).toEqual({ ok: false, error: 'an http(s) url is required' }); + it('refuses a new session\'s URL that is not http(s), and relaunches a named one at about:blank instead', async () => { + const host = makeHost(); + expect(await ab(host, { op: 'launch', url: '--executable-path=/tmp/evil', headed: false })).toEqual({ ok: false, error: 'Browser navigation requires an http(s) URL' }); + expect(await ab(host, { op: 'launch', url: 'file:///etc/passwd', headed: false })).toEqual({ ok: false, error: 'Browser navigation requires an http(s) URL' }); expect(spawnMock).not.toHaveBeenCalled(); const calls = mockSpawnByCommand({ close: () => ({}), '--headed open': () => ({ code: 1, stderr: 'boom' }), }); - await host.popOut('dormouse.1.default', { url: '--executable-path=/tmp/evil' }); + await ab(host, { op: 'launch', url: '--executable-path=/tmp/evil', headed: true }, { session: 'dormouse.1.default' }); expect(calls).toContainEqual(['--session', 'dormouse.1.default', '--headed', 'open', 'about:blank']); expect(calls.flat()).not.toContain('--executable-path=/tmp/evil'); }); @@ -771,10 +921,10 @@ describe('agent-browser host edit ops', () => { // reached the daemon's `eval`, which is exactly what `EDIT_SCRIPTS`'s own // comment says cannot happen. it('refuses an edit op that is only an inherited property of the script table', async () => { - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); for (const op of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { - expect(await host.edit('sess', op as never)).toEqual({ + expect(await ab(host, { op: 'edit', edit: op as never }, { session: 'sess' })).toEqual({ ok: false, error: `unknown edit op '${op}'`, }); @@ -786,9 +936,9 @@ describe('agent-browser host edit ops', () => { for (const op of ['selectAll', 'copy', 'cut'] as const) { spawnMock.mockReset(); enqueueSpawnResults([{ stdout: JSON.stringify({ success: true, data: { result: 'x' } }) }]); - const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const host = makeHost(); - expect((await host.edit('sess', op)).ok).toBe(true); + expect((await ab(host, { op: 'edit', edit: op }, { session: 'sess' })).ok).toBe(true); // `eval` with the table's script, not a stringified prototype member. const args = spawnMock.mock.calls[0][1] as string[]; expect(args.slice(0, 3)).toEqual(['--session', 'sess', 'eval']); @@ -797,3 +947,4 @@ describe('agent-browser host edit ops', () => { } }); }); + diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index c3e8ddfa2..81f924643 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -1,41 +1,17 @@ /** - * Host-agnostic agent-browser support (docs/specs/dor-browser.md → - * "Agent-Browser Host Capabilities"). The single source of truth for both hosts: + * The agent-browser provider beneath the shared browser host + * (`browser-host.ts`; docs/specs/dor-browser.md → "Browser Host"): + * imported by the VS Code extension host, bundled for the + * standalone sidecar. What is genuinely agent-browser's lives here — its + * per-session daemon and the state files it leaves beside its socket, the pid + * kill a headed/headless relaunch needs, and each operation's one fixed argv. + * The host owns everything the two providers share. * - * - VS Code: the extension host imports this directly - * (`vscode-ext/src/agent-browser-host.ts`). - * - Standalone: bundled to `standalone/sidecar/agent-browser-host.cjs` and run - * by the Node sidecar, fronted by thin Rust forwarders — exactly how the - * iframe proxy (`iframe-proxy.ts`) is shared. - * - * Everything here is plain Node (child_process / fs / crypto), so the *same* - * code runs on both hosts. Only two genuinely host-specific bits are injected: - * writing the OS clipboard (for the macOS editing chords) and logging. - * - * Narrow capabilities, all on behalf of the webview: - * - * 1. `command` — runs the user's agent-browser binary against a session for tab - * actions, navigation, and teardown. Only what `parseWebviewCommand` - * accepts runs; not a general exec channel. - * 2. `edit` — host-owned `eval` for the macOS editing chords - * (select-all/copy/cut) the stream input path can't dispatch; copy/cut land - * on the OS clipboard. - * 3. `screenshot` — captures one device-resolution frame and returns the bytes. - * 4. `attach` — reports a session's live stream port from its state files, - * never spawning a daemon; relaunches a gone one at the page the pane had. - * 5. `open` — opens a url in a new managed session (or a caller-named one), - * backing every GUI launch (docs/specs/dor-browser.md → "Agent-Browser - * Connection"). - * 6. `popOut` / `popIn` — relaunch a session headed/headless at its live active - * url (Chrome's mode is fixed at launch, so this is a close + relaunch). - * 7. `closePoppedOut` — close every still-headed window **and drop the capture - * directory**, called from each host's shutdown so quitting never orphans a - * real Chrome window or leaves a frame of the user's browser in tmp. - * - * The VS Code stream relay is NOT here: it works around the `vscode-webview://` + * Plain Node (child_process / fs), so the same code runs on both hosts. The + * VS Code stream relay is NOT here: it works around the `vscode-webview://` * origin the agent-browser stream server rejects, which is a VS-Code-only * concern (the standalone webview's `tauri://localhost` origin is accepted, so - * it connects directly). It stays in the VS Code host. + * it connects directly). It stays in the VS Code host, injected as `streamUrl`. */ import * as net from 'net'; import * as os from 'os'; @@ -46,127 +22,98 @@ import { promises as fs } from 'fs'; // GUI host needs it even for the absolute `binaryPath` dor ab resolved. // See docs/specs/dor-cli.md → "Spawning External Binaries". import { - spawnAndCapture, + BROWSER_PROVIDERS, + isDirectory, parseStreamPort, + spawnAndCapture, streamStatusArgs, AGENT_BROWSER_BIN_ENV, + AGENT_BROWSER_SOCKET_DIR_ENV, DEFAULT_AGENT_BROWSER_BIN, } from 'dor-lib-common'; -import { randomBytes } from 'crypto'; import { isAllowedAgentBrowserBinary } from '../lib/agent-browser-binary'; -import { type AgentBrowserTab, parseAgentBrowserTabs } from '../lib/agent-browser-tab'; -import type { - AgentBrowserAttachResult, - AgentBrowserCommandResult, - AgentBrowserEditOp, - AgentBrowserEditResult, - AgentBrowserOpenResult, - AgentBrowserPopResult, - AgentBrowserScreenshotResult, -} from '../lib/platform/types'; -import { isBrowsableUrl } from '../lib/platform/browser-automation'; -import { privateCaptureDir } from './private-capture-dir'; -import { - captureFormat, - editScript, - generateGuiSession, - isAgentBrowserSession, - jpegQuality, - parseWebviewCommand, - type WebviewCommand, -} from './browser-host-shared'; - -/** The agent-browser argv for a parsed webview command — rebuilt here, so no - * webview token reaches the CLI as it came. */ -function webviewArgv(command: WebviewCommand): string[] { - switch (command.kind) { - case 'open': return ['open', command.url]; - case 'cdp-url': return ['get', 'cdp-url']; - case 'tab-list': return ['tab', 'list', '--json']; - case 'tab-select': return ['tab', command.tab]; - case 'tab-close': return ['tab', 'close', command.tab]; - case 'viewport': return ['set', 'viewport', String(command.width), String(command.height), String(command.dpr)]; - case 'device': return ['set', 'device', command.name]; - default: return [command.kind]; +import { parseAgentBrowserTabs } from '../lib/agent-browser-tab'; +import type { BrowserResult } from '../lib/platform/browser-automation'; +import type { BrowserAct, BrowserProvider, LiveBrowser, ProviderBinding } from './browser-host'; + +const SESSION_ARGS = BROWSER_PROVIDERS['agent-browser'].sessionArgs; + +// `tab`'s own verbs, which a tab id rendered after `tab` would run instead. +const TAB_VERBS = new Set(['new', 'close', 'list']); + +/** The agent-browser argv for an operation — rebuilt from its validated + * fields, so no caller token reaches the CLI as it came; null for a tab id + * that would read as one of `tab`'s verbs. */ +function actArgv(act: BrowserAct): string[] | null { + switch (act.op) { + case 'navigate': return ['open', act.url]; + case 'history': return [act.dir]; + case 'tab': + if (TAB_VERBS.has(act.tabId)) return null; + return act.action === 'select' ? ['tab', act.tabId] : ['tab', 'close', act.tabId]; + case 'viewport': return ['set', 'viewport', String(act.width), String(act.height), String(act.dpr)]; + case 'device': return ['set', 'device', act.name]; + case 'cdpUrl': return ['get', 'cdp-url']; } } +/** The browser-level CDP WebSocket `get cdp-url` printed, plain or JSON. */ +function parseCdpUrl(stdout: string): string | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as { data?: { result?: unknown }; result?: unknown; url?: unknown }; + const value = parsed.data?.result ?? parsed.result ?? parsed.url; + if (typeof value === 'string' && value.startsWith('ws://')) return value; + } catch { + // Plain text is the common CLI output. + } + return trimmed.match(/ws:\/\/\S+/)?.[0] ?? null; +} + +/** One CLI run's outcome. */ +type CliResult = { exitCode: number; stdout: string; stderr: string }; + +function cliError(result: CliResult): string { + return result.stderr.trim() || result.stdout.trim() || `agent-browser exited ${result.exitCode}`; +} + // A capture can queue behind a page-loading `open` for the CLI's whole 25s -// action timeout; past this it is wedged, and killed so it cannot pin -// `joinInFlight`. Every adapter has stopped waiting by then anyway. +// action timeout; past this it is wedged, and killed so it cannot pin the +// host's capture join. Every adapter has stopped waiting by then anyway. const CAPTURE_TIMEOUT_MS = 30_000; const STREAM_PORT_READ_ATTEMPTS = 4; const STREAM_PORT_READ_DELAY_MS = 150; -// How often a launch re-reads the daemon's state files while `open` is still -// waiting on the page (docs/specs/dor-browser.md → "Pop-Out"). -const LAUNCH_POLL_MS = 100; const PORT_PROBE_TIMEOUT_MS = 500; const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); -export interface AgentBrowserHostDeps { - /** Write text to the OS clipboard (copy/cut land here). VS Code passes - * `vscode.env.clipboard.writeText`; the sidecar shells out (pbcopy/clip/…). */ - writeClipboardText: (text: string) => Promise<void> | void; +export interface AgentBrowserProviderDeps { /** Optional diagnostic logger. */ log?: (message: string) => void; + /** The stream URL for a port; absent, the webview dials it directly. */ + streamUrl?: (port: number) => Promise<string>; } -/** Path-only capture result — the bytes stay on disk for the caller to read - * (the standalone Rust forwarder reads the file itself; see `screenshotToFile`). */ -export type AgentBrowserScreenshotFileResult = - | { ok: true; path: string; mime: string } - | { ok: false; error: string }; - -export interface AgentBrowserHost { - command(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult>; - edit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult>; - screenshot(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult>; - screenshotToFile(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotFileResult>; - attach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise<AgentBrowserAttachResult>; - open(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult>; - popOut(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult>; - popIn(session: string, opts: { url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult>; - closePoppedOut(): Promise<void>; -} - -export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowserHost { +export function createAgentBrowserProvider(deps: AgentBrowserProviderDeps = {}): BrowserProvider<ProviderBinding> { const log = deps.log ?? (() => {}); - // Sessions currently relaunched headed via pop-out, mapped to the binary path - // that spawned them. A headed session is a real OS window, so the host must - // close it on shutdown or it orphans (spec → "Pop-Out" lifecycle: - // "Dormouse/editor quits → headed windows are cleaned up; no orphans"). - // Headless sessions are deliberately NOT tracked — they're left alive to - // reattach across webview reloads (`attach`). - const poppedOutSessions = new Map<string, string | undefined>(); - // A relaunch returns once the daemon is streamable, while its `open` command - // may remain pending until page load. Key the post-open blank-tab sweep so a - // later pop-in/pop-out invalidates every command left behind by the previous - // relaunch before starting its own close -> kill -> reopen gap. - const relaunchGenerations = new Map<string, number>(); - let nextRelaunchGeneration = 0; - function beginRelaunch(session: string): number { - const generation = ++nextRelaunchGeneration; - relaunchGenerations.set(session, generation); - // The relaunched daemon's work must not join its predecessor's. - forgetInFlight(session); - return generation; - } - // The host's PATH is often the GUI login PATH (no nvm/volta shims), so prefer // the absolute path `dor ab` resolved in the user's terminal; fall through on // ENOENT (binary missing) to the next candidate in case it has gone stale. // - // The one gate every entry point shares. `binaryPath` arrives from the webview + // The one gate every spawn shares. `binaryPath` arrives from the webview // realm and from a pane's persisted Lath params, so an unchecked one is // arbitrary local execution in the extension host or the Tauri sidecar — the // exact escape the nonce CSP exists to prevent, and reachable without any user - // interaction on the next launch. The argv check in `command()` does not - // cover it: `attach`, `open` and `popOut` supply their own args and - // take a `binaryPath` of their own. A refused path is dropped, not fatal: the - // host's own candidates still run, so a stale or hostile value degrades to - // "resolve it yourself" rather than to a broken surface. - async function runWithBinaryFallback(args: string[], binaryPath?: string, timeoutMs?: number): Promise<AgentBrowserCommandResult> { + // interaction on the next launch. The request validation does not cover it: + // every operation takes a `binaryPath` of its own. A refused path is dropped, + // not fatal: the host's own candidates still run, so a stale or hostile value + // degrades to "resolve it yourself" rather than to a broken surface. + async function runWithBinaryFallback( + args: string[], + binaryPath?: string, + options: { timeoutMs?: number; cwd?: string } = {}, + ): Promise<CliResult> { const configured = process.env[AGENT_BROWSER_BIN_ENV]; if (binaryPath !== undefined && !isAllowedAgentBrowserBinary(binaryPath, configured)) { log(`[agent-browser] refused a caller-supplied binary path that is not an agent-browser: ${JSON.stringify(binaryPath)}`); @@ -178,9 +125,11 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser DEFAULT_AGENT_BROWSER_BIN, ].filter((c): c is string => !!c))]; + // Only what is set, so an unbounded run in the host's cwd spawns as a bare one. + const given = Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)); let lastError = ''; for (const binary of candidates) { - const result = await (timeoutMs === undefined ? spawnAndCapture(binary, args) : spawnAndCapture(binary, args, { timeoutMs })); + const result = await (Object.keys(given).length ? spawnAndCapture(binary, args, given) : spawnAndCapture(binary, args)); if (result.ok) { return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -196,15 +145,18 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return { exitCode: 1, stdout: '', stderr: `agent-browser binary not found (${lastError})` }; } - // Read a session's stream WebSocket port via `stream status --json` (parsed by - // dor-lib-common's parseStreamPort). Right after `open` / `--headed open` (a - // fresh spawn, a pop-out, or a pop-in relaunch) the daemon may not have - // published the port yet; a single read would then return undefined and leave - // the panel pinned to a stale port — it reads "ended" though the session is - // live. Retry briefly to close that window. - async function readStreamPort(session: string, binaryPath?: string): Promise<number | undefined> { + function run(b: ProviderBinding, args: string[], options?: { timeoutMs?: number; cwd?: string }): Promise<CliResult> { + return runWithBinaryFallback([...SESSION_ARGS(b.session), ...args], b.binaryPath, options); + } + + // Read a session's stream WebSocket port via `stream status --json` — only + // once `open` has returned, since any CLI verb starts a daemon to answer. + // Right after it, the daemon may not have published the port yet; a single + // read would then return undefined and leave the panel pinned to a stale + // port. Retry briefly to close that window. + async function readStreamPort(b: ProviderBinding, deadline: number): Promise<number | undefined> { for (let attempt = 0; attempt < STREAM_PORT_READ_ATTEMPTS; attempt++) { - const result = await runWithBinaryFallback(streamStatusArgs(session), binaryPath); + const result = await runWithBinaryFallback(streamStatusArgs(b.session), b.binaryPath, { timeoutMs: Math.max(0, deadline - Date.now()) }); if (result.exitCode === 0) { const port = parseStreamPort(result.stdout); if (port !== undefined) return port; @@ -214,36 +166,6 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return undefined; } - /** A tab showing something other than the blank page a relaunch can leave. */ - function isRealTab(url: string): boolean { - const trimmed = url.trim(); - return !!trimmed && trimmed !== 'about:blank'; - } - - // Enumerate a session's tabs via `tab list --json`. Envelope mirrors the rest - // of the CLI parsing here: { tabs } or { data: { tabs } }; the record parse is - // shared with the live stream (parseAgentBrowserTabs). Returns [] on any - // failure so callers degrade gracefully. - async function listTabs(session: string, binaryPath?: string): Promise<AgentBrowserTab[]> { - const result = await runWithBinaryFallback(['--session', session, 'tab', 'list', '--json'], binaryPath); - if (result.exitCode !== 0) return []; - try { - const parsed = JSON.parse(result.stdout) as { tabs?: unknown; data?: { tabs?: unknown } }; - return parseAgentBrowserTabs(parsed.data?.tabs ?? parsed.tabs); - } catch { - return []; - } - } - - // Dormouse is the source of truth for the relaunch target: the panel observes - // the live `tabs` stream and tracks the active tab's URL in its params, then - // passes it here. We deliberately do NOT re-query the daemon — right after - // `close` the daemon relaunches at about:blank, so a `get url` / `tab list` - // would race the very transition it's meant to preserve and hand back blank. - function relaunchUrl(requestedUrl: unknown): string { - return isBrowsableUrl(requestedUrl) ? requestedUrl : 'about:blank'; - } - // agent-browser keeps a long-lived per-session daemon whose headed/headless // mode is fixed at *its* launch. `close` only closes the browser, not the // daemon, and there is no CLI verb to stop it — so a `--headed`/headless @@ -254,16 +176,22 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // fresh daemon in the mode we ask for. Best-effort and cross-platform // (process.kill works on win/mac/linux). function agentBrowserStateDir(): string { - return process.env.AGENT_BROWSER_SOCKET_DIR || path.join(os.homedir(), '.agent-browser'); + return process.env[AGENT_BROWSER_SOCKET_DIR_ENV] || path.join(os.homedir(), '.agent-browser'); } // The daemon's state files beside its socket: `<session>.pid` and // `<session>.stream` (the stream server's port, written as the daemon comes // up — ~100ms into a launch, long before the page loads). Neither is cleaned // up when the daemon is killed, so a reader must know which daemon wrote it. + // One written before this boot describes no process now running: its pid + // is whatever process has that number since, so it reads as absent. async function readStateNumber(session: string, ext: 'pid' | 'stream'): Promise<number | undefined> { try { - const value = Number.parseInt((await fs.readFile(path.join(agentBrowserStateDir(), `${session}.${ext}`), 'utf8')).trim(), 10); + const file = path.join(agentBrowserStateDir(), `${session}.${ext}`); + const [stat, text] = await Promise.all([fs.stat(file), fs.readFile(file, 'utf8')]); + // `os.uptime` is whole seconds on some platforms: a second's slack. + if (stat.mtimeMs < Date.now() - os.uptime() * 1000 - 1000) return undefined; + const value = Number.parseInt(text.trim(), 10); return Number.isInteger(value) && value > 0 ? value : undefined; } catch { return undefined; // absent (no daemon yet, custom dir, or an older CLI) @@ -295,470 +223,158 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return port !== undefined && await portAccepts(port) ? port : undefined; } - /** The session's daemon as its state files describe it — a CLI verb would - * start one to answer (docs/specs/dor-browser.md → "Pop-Out"): the pid file's - * pid, whether that process is alive, and its stream port if it accepts. */ - async function daemonState(session: string): Promise<{ pid: number | undefined; alive: boolean; wsPort?: number }> { - const pid = await readStateNumber(session, 'pid'); - if (pid === undefined || !processAlive(pid)) return { pid, alive: false }; - return { pid, alive: true, wsPort: await acceptingStreamPort(session) }; - } - - /** Terminate the session's daemon and wait for it to exit. Returns the pid - * the pid file named (dead or not), so a relaunch can tell the daemon that - * replaces it from the stale state files it leaves behind. */ - async function killDaemon(session: string): Promise<number | undefined> { - const pid = await readStateNumber(session, 'pid'); - if (pid === undefined) return undefined; // no pid file — nothing to kill (already gone, or custom dir) + /** Terminate `session`'s daemon `pid`, proven live by the caller, and wait + * for it to exit. */ + async function killDaemon(session: string, pid: number): Promise<void> { try { process.kill(pid, 'SIGTERM'); } catch { - return pid; // ESRCH: already dead + return; // ESRCH: already dead } // Wait for the process to actually exit (signal 0 throws once it's gone), so // the relaunch doesn't race a daemon that's still shutting down. for (let i = 0; i < 40; i++) { if (!processAlive(pid)) { log(`[ab-relaunch] daemon ${pid} for ${session} exited after ${i * 50}ms`); - return pid; + return; } await delay(50); } log(`[ab-relaunch] daemon ${pid} for ${session} still alive after 2s; SIGKILL`); try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } - return pid; - } - - // `agent-browser open <url>` returns when the page's `load` event fires — up to - // the CLI's action timeout (25s in 0.31.1), after which it exits non-zero with - // the browser live on the page — and every other daemon command queues behind - // it. A transition that awaited it would block for the whole page load and - // then read a slow page as a failed launch. So the launch resolves as soon as - // the *daemon* is up: its pid file names a pid other than the one a relaunch - // just killed, and its stream file names a port that accepts a connection. The - // stream serves status/tabs/frames while `open` is still waiting, so the pane - // shows the page loading. Only once `open` has returned does the exit code - // matter, and then only if no daemon came up at all. - type Launch = { - wsPort: number | undefined; - /** Settles when `open` itself returns — possibly long after the launch. */ - opened: Promise<AgentBrowserCommandResult>; - }; - async function launch(session: string, args: string[], binaryPath: string | undefined, replacedPid?: number): Promise<Launch> { - let settled: AgentBrowserCommandResult | undefined; - const opened = runWithBinaryFallback(args, binaryPath).then((result) => { - settled = result; - return result; - }); - for (;;) { - const pid = await readStateNumber(session, 'pid'); - const daemonUp = pid !== undefined && pid !== replacedPid; - if (settled) { - // A non-zero exit with the daemon up is a page that has not finished - // loading, not a failed launch. Without a pid file (an older CLI) the - // exit code is all there is. - if (settled.exitCode !== 0 && !daemonUp) return { wsPort: undefined, opened }; - return { wsPort: await readStreamPort(session, binaryPath), opened }; - } - if (daemonUp) { - const port = await acceptingStreamPort(session); - if (port !== undefined) return { wsPort: port, opened }; - } - await delay(LAUNCH_POLL_MS); - } } - function logOpened(label: string, opened: Promise<AgentBrowserCommandResult>): void { - void opened.then((result) => { - log(`[ab-relaunch] ${label} exit=${result.exitCode}${result.stderr.trim() ? ` stderr=${result.stderr.trim()}` : ''}`); - }); - } - - function launchFailure(label: string, result: AgentBrowserCommandResult): string { - const stderr = result.stderr.trim(); - if (stderr) return stderr; - return result.exitCode === 0 - ? `${label} published no stream port` - : `${label} exited ${result.exitCode}`; - } - - // After a relaunch, close any stray about:blank tab the close+reopen race can - // leave behind — but only when a real page is open, so we never close the sole - // tab. Best-effort: a failure here must not fail the pop-out/pop-in. - async function closeStrayBlankTabs( - session: string, - current: () => boolean, - binaryPath?: string, - ): Promise<void> { - if (!current()) return; - const tabs = await listTabs(session, binaryPath); - // The list may have queued behind `open`; a newer relaunch can begin while - // it waits. Never issue a tab close into that relaunch's daemon gap. - if (!current()) return; - log(`[ab-relaunch] tabs after open: ${JSON.stringify(tabs)}`); - if (tabs.length < 2 || !tabs.some((t) => isRealTab(t.url))) return; - for (const tab of tabs) { - if (!isRealTab(tab.url)) { - if (!current()) return; - log(`[ab-relaunch] closing stray blank tab ${tab.tabId}`); - await runWithBinaryFallback(['--session', session, 'tab', 'close', tab.tabId], binaryPath); - } - } - } - - // Screenshots of the user's authenticated browser land here, written by an - // external process under the ambient umask — which is why the private - // directory, not the file mode, is the control. - const screenshotDir = privateCaptureDir('dormouse-ab-'); - - /** Drop the whole capture directory. Called on shutdown; safe to repeat. */ - async function removeScreenshotDir(): Promise<void> { - screenshotNames.clear(); - await screenshotDir.remove(); - } - - // Reused per session so we don't litter with one file per frame; `joinInFlight` - // keeps one capture in flight per session, so overwriting is safe. The - // random component is per session, so the name stays stable for reuse while - // being unguessable from the session key alone. - const screenshotNames = new Map<string, string>(); - async function screenshotPath(session: string, ext: string): Promise<string> { - let name = screenshotNames.get(session); - if (name === undefined) { - name = randomBytes(12).toString('hex'); - screenshotNames.set(session, name); - } - return path.join(await screenshotDir.get(), `shot-${name}.${ext}`); - } - - // Work a caller asking meanwhile joins rather than repeats, one per session - // and kind: a capture (surfaces can share a session, and a caller re-asks - // after its adapter's timeout; a second spawn would only queue behind the - // first in the daemon, then race it for the session's one capture file), or - // an attach (two panes restoring one session relaunch it once). Never work - // from before the session's close or relaunch (`forgetInFlight`); the - // capture spawn's `CAPTURE_TIMEOUT_MS` bounds how long one stays joinable. - type InFlight = { session: string; kind: string; promise: Promise<unknown> }; - const inFlight = new Map<string, InFlight>(); - function joinInFlight<T>(session: string, kind: string, work: () => Promise<T>): Promise<T> { - const key = `${kind}\0${session}`; - const pending = inFlight.get(key); - if (pending) return pending.promise as Promise<T>; - const entry: InFlight = { session, kind, promise: Promise.resolve() }; - const promise = work().finally(() => { - if (inFlight.get(key) === entry) inFlight.delete(key); - }); - entry.promise = promise; - inFlight.set(key, entry); - return promise; - } - /** Join none of `session`'s pending work but the `keep` kind, and give its - * next capture a fresh file, so one still running cannot overwrite it. */ - function forgetInFlight(session: string, keep?: string): void { - for (const [key, entry] of inFlight) { - if (entry.session === session && entry.kind !== keep) inFlight.delete(key); - } - screenshotNames.delete(session); - } + return { + pollMs: 100, - async function command(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { - if (!isAgentBrowserSession(session)) { - return { exitCode: 1, stdout: '', stderr: 'a valid session name is required' }; - } - const parsed = parseWebviewCommand(args); - if (!parsed) { - const shown = Array.isArray(args) ? args.map(String).join(' ') : String(args); - return { exitCode: 1, stdout: '', stderr: `agent-browser '${shown}' is not allowed from the webview` }; - } - // An explicit close (kill / render-swap) tears the session down itself, so - // it's no longer ours to clean up on shutdown. It also invalidates a - // post-open sweep left by a fast-returning relaunch: once closed, no later - // daemon command may recreate this otherwise-untracked session. - if (parsed.kind === 'close') { - poppedOutSessions.delete(session); - relaunchGenerations.delete(session); - forgetInFlight(session); - } - return runWithBinaryFallback(['--session', session, ...webviewArgv(parsed)], binaryPath); - } + bind: (binding) => binding, - async function edit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult> { - if (!isAgentBrowserSession(session)) { - return { ok: false, error: 'a valid session name is required' }; - } - const script = editScript(op); - if (!script) { - return { ok: false, error: `unknown edit op '${op}'` }; - } + // One socket directory per host, so the session names the daemon. + identity: (b) => b.session, - const result = await runWithBinaryFallback(['--session', session, 'eval', script, '--json'], binaryPath); - if (result.exitCode !== 0) { - return { ok: false, error: result.stderr.trim() || `eval exited ${result.exitCode}` }; - } + describe: (b) => b, - // eval --json envelope: { success, data: { result }, error }. - let text = ''; - try { - const envelope = JSON.parse(result.stdout) as { success?: boolean; data?: { result?: unknown }; error?: unknown }; - if (envelope.success === false) { - return { ok: false, error: typeof envelope.error === 'string' ? envelope.error : `${op} failed` }; + // The daemon as its state files describe it — a CLI verb would start one + // to answer. One up but not streaming is left alone: relaunching would + // compete with it. + async find(b) { + const pid = await readStateNumber(b.session, 'pid'); + if (pid !== undefined && processAlive(pid)) { + const port = await acceptingStreamPort(b.session); + if (port !== undefined) return { wsPort: port }; + throw new Error(`agent-browser session '${b.session}' is not streaming`); } - if (typeof envelope.data?.result === 'string') text = envelope.data.result; - } catch { - return { ok: false, error: `could not parse eval output for ${op}` }; - } - - if (op === 'selectAll') return { ok: true }; - // Land the grabbed text on the user's real OS clipboard. Skip empty so an - // empty selection doesn't clobber what's already there. - if (text) { + return { gone: `agent-browser session '${b.session}' is not running`, named: pid !== undefined }; + }, + + // Close the browser, then fully stop the daemon so a relaunch isn't ignored + // as "daemon already running" (a no-op without a daemon: `close` starts + // none). Only a pid proven to be the daemon is signalled — named by a pid + // file from this boot, alive, beside a stream port that accepts, checked + // before `close` — since a pid file alone may name any process. Answers + // that pid, live or not, so `probe` tells the replacement from the state + // files the old daemon left. + async stop(b, timeoutMs) { + const pid = await readStateNumber(b.session, 'pid'); + const proven = pid !== undefined && processAlive(pid) && await acceptingStreamPort(b.session) !== undefined; + await run(b, ['close'], { timeoutMs }); + if (proven) await killDaemon(b.session, pid); + return pid; + }, + + // `open` returns when the page's `load` event fires — up to the CLI's + // action timeout (25s in 0.31.1), after which it exits non-zero with the + // browser live on the page — and every other daemon command queues behind + // it. Run in the project directory, for the config a `dor ab` there read. + async open(b, url, headed) { + // The project's directory, so agent-browser reads its + // `./agent-browser.json`, while that directory still exists. + const cwd = b.cwd !== undefined && isDirectory(b.cwd) ? b.cwd : undefined; + const result = await run(b, [...(headed ? ['--headed'] : []), 'open', url ?? 'about:blank'], { cwd }); + log(`[ab-relaunch] open session=${b.session} exit=${result.exitCode}${result.stderr.trim() ? ` stderr=${result.stderr.trim()}` : ''}`); + return { exitCode: result.exitCode, stderr: result.stderr }; + }, + + // Up once the *daemon* is: its pid file names a pid other than the one a + // relaunch just killed, and its stream file a port that accepts. The stream + // serves status/tabs/frames while `open` still waits on the page. Once + // `open` has returned, a non-zero exit with the daemon up is a page still + // loading, not a failed launch — without a pid file (an older CLI) the exit + // code is all there is. + async probe(b, { replaced, opened, deadline }): Promise<LiveBrowser | { failed: string } | undefined> { + const pid = await readStateNumber(b.session, 'pid'); + const daemonUp = pid !== undefined && pid !== replaced; + if (opened) { + if (opened.exitCode !== 0 && !daemonUp) return { failed: opened.stderr.trim() || `agent-browser open exited ${opened.exitCode}` }; + const port = await readStreamPort(b, deadline); + return port !== undefined ? { wsPort: port } : { failed: 'agent-browser published no stream port' }; + } + if (!daemonUp) return undefined; + const port = await acceptingStreamPort(b.session); + return port !== undefined ? { wsPort: port } : undefined; + }, + + async close(b, timeoutMs) { + const result = await run(b, ['close'], { timeoutMs }); + if (result.exitCode !== 0) throw new Error(cliError(result)); + }, + + // Envelope: { tabs } or { data: { tabs } }; the record parse is shared with + // the live stream (parseAgentBrowserTabs). Empty on any failure. + async listTabs(b) { + const result = await run(b, ['tab', 'list', '--json']); + if (result.exitCode !== 0) return []; try { - await deps.writeClipboardText(text); - } catch (err) { - return { ok: false, error: `clipboard write failed: ${err instanceof Error ? err.message : String(err)}` }; + const parsed = JSON.parse(result.stdout) as { tabs?: unknown; data?: { tabs?: unknown } }; + return parseAgentBrowserTabs(parsed.data?.tabs ?? parsed.tabs); + } catch { + return []; } - } - return { ok: true, text }; - } + }, + + async closeTab(b, tabId) { + await run(b, ['tab', 'close', tabId]); + }, + + async act(b, act): Promise<BrowserResult> { + const argv = actArgv(act); + if (!argv) return { ok: false, error: 'invalid tab operation' }; + const result = await run(b, argv); + if (result.exitCode !== 0) return { ok: false, error: cliError(result) }; + if (act.op !== 'cdpUrl') return { ok: true }; + const url = parseCdpUrl(result.stdout); + return url ? { ok: true, url } : { ok: false, error: 'agent-browser printed no CDP endpoint' }; + }, - // Capture one device-resolution frame via the user's agent-browser - // `screenshot` command (which honors the session's viewport/DPR, unlike the - // CSS-resolution screencast). agent-browser writes the frame to a temp file and - // reports its path; this returns that PATH without reading the bytes. - // - // The two hosts read the file differently, and that split is the whole point of - // keeping this path-only: - // - VS Code: `screenshot()` (below) reads the bytes here and structured-clones - // them to the webview. - // - Standalone: the sidecar hands this path to Rust, which reads the file - // itself and returns a raw Response — so the ~100-700KB of image bytes never - // ride the JSON-lines stdio pipe shared with all PTY terminal traffic. - async function screenshotToFile( - session: string, - opts: { format?: 'jpeg' | 'png'; quality?: number }, - binaryPath?: string, - ): Promise<AgentBrowserScreenshotFileResult> { - if (!isAgentBrowserSession(session)) { - return { ok: false, error: 'a valid session name is required' }; - } - const format = captureFormat(opts.format); - return joinInFlight(session, `file:${format}`, async (): Promise<AgentBrowserScreenshotFileResult> => { - const ext = format === 'png' ? 'png' : 'jpg'; - let out: string; + // eval --json envelope: { success, data: { result }, error }. + async evaluate(b, script) { + const result = await run(b, ['eval', script, '--json']); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `eval exited ${result.exitCode}`); + let envelope: { success?: boolean; data?: { result?: unknown }; error?: unknown }; try { - // Every other failure in here answers `{ ok: false, error }`; a tmpdir - // that cannot be created must not escape as a rejection instead. - out = await screenshotPath(session, ext); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log(`[agent-browser] could not create the capture directory: ${message}`); - return { ok: false, error: `could not create a private screenshot directory: ${message}` }; + envelope = JSON.parse(result.stdout); + } catch { + throw new Error('could not parse eval output'); } - const args = ['--session', session, 'screenshot', out, '--screenshot-format', format]; - if (format === 'jpeg') args.push('--screenshot-quality', String(jpegQuality(opts.quality))); - const result = await runWithBinaryFallback(args, binaryPath, CAPTURE_TIMEOUT_MS); + if (envelope.success === false) throw new Error(typeof envelope.error === 'string' ? envelope.error : 'eval failed'); + return envelope.data?.result; + }, + + // agent-browser's `screenshot` honors the session's viewport/DPR, unlike + // the CSS-resolution screencast, and writes the frame where it is told. + async screenshot(b, { format, quality }, file) { + const out = await file(); + const args = ['screenshot', out, '--screenshot-format', format]; + if (format === 'jpeg') args.push('--screenshot-quality', String(quality)); + const result = await run(b, args, { timeoutMs: CAPTURE_TIMEOUT_MS }); if (result.exitCode !== 0) { - log(`[agent-browser] screenshot failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`); - return { ok: false, error: result.stderr.trim() || `screenshot exited ${result.exitCode}` }; + log(`[agent-browser] screenshot failed (exit ${result.exitCode}): ${cliError(result)}`); + throw new Error(result.stderr.trim() || `screenshot exited ${result.exitCode}`); } - return { ok: true, path: out, mime: format === 'png' ? 'image/png' : 'image/jpeg' }; - }); - } + return { path: out }; + }, - // Byte-returning wrapper over screenshotToFile for the VS Code host (structured - // clone to the webview). The standalone sidecar deliberately does NOT use this; - // it forwards the path so Rust reads the file off the stdio hot path. - async function screenshot( - session: string, - opts: { format?: 'jpeg' | 'png'; quality?: number }, - binaryPath?: string, - ): Promise<AgentBrowserScreenshotResult> { - // Joined whole, read and unlink included: a caller joining only the capture - // would read a file the first caller has already removed. - const format = captureFormat(opts.format); - return joinInFlight(session, `bytes:${format}`, async (): Promise<AgentBrowserScreenshotResult> => { - const shot = await screenshotToFile(session, opts, binaryPath); - if (!shot.ok) return { ok: false, error: shot.error }; - try { - const buffer = await fs.readFile(shot.path); - // A Uint8Array view over exactly this file's bytes. - const bytes = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - // The bytes are in memory now and this path owns the file's whole life, - // so the frame does not sit on disk until shutdown. The path-returning - // sibling cannot do this — its caller (Rust) reads the file afterwards — - // so there the next capture overwrites it and shutdown removes the dir. - await fs.unlink(shot.path).catch(() => {}); - return { ok: true, bytes, mime: shot.mime }; - } catch (err) { - log(`[agent-browser] screenshot read failed: ${err instanceof Error ? err.message : String(err)}`); - return { ok: false, error: `could not read screenshot file: ${err instanceof Error ? err.message : String(err)}` }; - } - }); - } - - /** The `open` argv for `session`, tracking a headed one for shutdown before - * its launch — a window whose page never loads is still closed — and - * dropping a headless one. */ - function openArgs(session: string, url: string, headed: boolean, binaryPath: string | undefined): string[] { - if (headed) poppedOutSessions.set(session, binaryPath); - else poppedOutSessions.delete(session); - return ['--session', session, ...(headed ? ['--headed'] : []), 'open', url]; - } - - // A launch into a daemon it did not just kill — a GUI open, or an attach - // relaunching a gone one — sweeps no blank tabs: only a relaunch's - // close+reopen leaves a stray one. Nothing coming up is the one failure, - // and closes whatever did (a no-op without a daemon: `close` starts none). - async function coldLaunch( - label: string, - session: string, - args: string[], - binaryPath: string | undefined, - replacedPid?: number, - ): Promise<{ wsPort: number } | { error: string }> { - const { wsPort, opened } = await launch(session, args, binaryPath, replacedPid); - logOpened(`${label} session=${session}`, opened); - if (wsPort !== undefined) return { wsPort }; - poppedOutSessions.delete(session); - await runWithBinaryFallback(['--session', session, 'close'], binaryPath); - return { error: launchFailure(label, await opened) }; - } - - // The session's live stream port, without starting anything. A daemon that is - // up but not streaming is left alone — relaunching would compete with it. Only - // a daemon that is gone, for a caller naming the page it had, is relaunched - // there: headed when the pane is a pop-out. - async function attach( - session: string, - opts: { url?: string; headed?: boolean }, - binaryPath?: string, - ): Promise<AgentBrowserAttachResult> { - if (!isAgentBrowserSession(session)) return { ok: false, error: 'a valid session name is required' }; - return joinInFlight(session, 'attach', async (): Promise<AgentBrowserAttachResult> => { - const daemon = await daemonState(session); - if (daemon.wsPort !== undefined) return { ok: true, wsPort: daemon.wsPort }; - if (daemon.alive) return { ok: false, error: `agent-browser session '${session}' is not streaming` }; - const url = opts?.url; - if (!isBrowsableUrl(url)) return { ok: false, error: `agent-browser session '${session}' is not running` }; - log(`[ab-relaunch] attach session=${session} is gone -> open ${url}`); - // An earlier relaunch's sweep must not reach this daemon, nor its captures. - relaunchGenerations.delete(session); - forgetInFlight(session, 'attach'); - const launched = await coldLaunch('attach open', session, openArgs(session, url, !!opts.headed, binaryPath), binaryPath, daemon.pid); - return 'wsPort' in launched ? { ok: true, wsPort: launched.wsPort, relaunched: true } : { ok: false, error: launched.error }; - }); - } - - // Open <url> in a new managed session, or in the caller's `session` (a Tool's - // own, or the one a failed swap restores) — every GUI launch - // (docs/specs/dor-browser.md → "Agent-Browser Connection"). A live daemon for - // that session just navigates. With `headed`, the process launches headed in - // one shot so embed→popout doesn't open a headless browser only to tear it down. - async function open(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult> { - if (!isBrowsableUrl(url)) return { ok: false, error: 'an http(s) url is required' }; - if (opts?.session !== undefined && !isAgentBrowserSession(opts.session)) { - return { ok: false, error: 'a valid session name is required' }; - } - const session = opts?.session ?? generateGuiSession(); - const launched = await coldLaunch('open', session, openArgs(session, url, !!opts?.headed, binaryPath), binaryPath); - if ('error' in launched) return { ok: false, error: launched.error }; - return { ok: true, session, wsPort: launched.wsPort, ...(binaryPath ? { binaryPath } : {}) }; - } - - // Pop-out is a relaunch, not a live toggle: Chrome's headed/headless choice is - // fixed at launch (spec → "Pop-Out"). Close the headless session, then - // reopen it headed at the active URL. (v1 preserves the active tab URL only; - // multi-tab + profile/cookie restore are tracked follow-ups. Window - // positioning over opts.rect is deferred — neither host acts on it yet, so the - // window opens where Chrome places it.) - async function popOut( - session: string, - opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, - binaryPath?: string, - ): Promise<AgentBrowserPopResult> { - if (!isAgentBrowserSession(session)) return { ok: false, error: 'a valid session name is required' }; - const generation = beginRelaunch(session); - const url = relaunchUrl(opts?.url); - log(`[ab-relaunch] popOut session=${session} requestedUrl=${JSON.stringify(opts?.url)} -> open ${url}`); - // Close the browser, then fully stop the daemon so the headed relaunch isn't - // ignored as "daemon already running" (which would leave it headless). - await runWithBinaryFallback(['--session', session, 'close'], binaryPath); - const replacedPid = await killDaemon(session); - return relaunch('popOut', session, openArgs(session, url, true, binaryPath), binaryPath, replacedPid, generation); - } - - // Shared tail of pop-out/pop-in: launch, and once `open` itself returns — - // possibly well after the pane is already streaming — sweep the stray blank - // tab the close+reopen can leave (a daemon command, so it must not run while - // `open` still holds the queue). A launch that never published a port is the - // one failure: the exit code alone is not. - async function relaunch( - label: string, - session: string, - args: string[], - binaryPath: string | undefined, - replacedPid: number | undefined, - generation: number, - ): Promise<AgentBrowserPopResult> { - const { wsPort, opened } = await launch(session, args, binaryPath, replacedPid); - logOpened(`${label} open`, opened); - if (wsPort === undefined) { - if (relaunchGenerations.get(session) === generation) relaunchGenerations.delete(session); - const failed = await opened; - return { ok: false, error: launchFailure(`${label} open`, failed) }; - } - const current = () => relaunchGenerations.get(session) === generation; - void opened - .then(() => closeStrayBlankTabs(session, current, binaryPath)) - .catch(() => undefined) - .finally(() => { - if (current()) relaunchGenerations.delete(session); - }); - log(`[ab-relaunch] ${label} returning wsPort=${wsPort}`); - return { ok: true, wsPort }; - } - - // The reverse: close the headed session and relaunch it headless at the active - // URL, resuming the screencast. - async function popIn( - session: string, - opts: { url?: string }, - binaryPath?: string, - ): Promise<AgentBrowserPopResult> { - if (!isAgentBrowserSession(session)) return { ok: false, error: 'a valid session name is required' }; - const generation = beginRelaunch(session); - const url = relaunchUrl(opts?.url); - log(`[ab-relaunch] popIn session=${session} requestedUrl=${JSON.stringify(opts?.url)} -> open ${url}`); - // Reverse of pop-out: the daemon is headed, so a plain `open` would reattach - // to it and stay headed. Stop the daemon so the relaunch comes up headless. - await runWithBinaryFallback(['--session', session, 'close'], binaryPath); - const replacedPid = await killDaemon(session); - return relaunch('popIn', session, openArgs(session, url, false, binaryPath), binaryPath, replacedPid, generation); - } - - // Close every still-popped-out session's headed window. Called from each - // host's shutdown (VS Code `deactivate()`, the sidecar's `shutdown()`) so - // quitting doesn't orphan real Chrome windows. On a reload, a popped-out - // surface then auto-reverts to a headless screencast when it reactivates - // (spec → "The headed window ends → auto-revert"), which is preferable to - // leaving a detached headed Chrome behind. - async function closePoppedOut(): Promise<void> { - const entries = [...poppedOutSessions.entries()]; - poppedOutSessions.clear(); - // Shutdown owns every session now, including a headless pop-in that has - // already left poppedOutSessions. Invalidate all post-open tails before a - // close can release their pending `open` commands and let them query again. - relaunchGenerations.clear(); - await Promise.all([ - ...entries.map(([session, binaryPath]) => - runWithBinaryFallback(['--session', session, 'close'], binaryPath).catch(() => undefined), - ), - // The same shutdown, so the same hook: this is the only moment both hosts - // reliably reach, and every captured frame is still on disk until it runs. - removeScreenshotDir(), - ]); - } - - return { command, edit, screenshot, screenshotToFile, attach, open, popOut, popIn, closePoppedOut }; + streamUrl: async (port) => (deps.streamUrl ? deps.streamUrl(port) : `ws://127.0.0.1:${port}`), + }; } diff --git a/lib/src/host/browser-host-shared.ts b/lib/src/host/browser-host-shared.ts deleted file mode 100644 index df4c2436b..000000000 --- a/lib/src/host/browser-host-shared.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** Policy both browser-provider hosts share (`agent-browser-host.ts`, - * `playwright-host.ts`): the webview command parser, session and URL checks, - * the fixed editing scripts, GUI session minting, and capture quality. See - * docs/specs/dor-browser.md. */ -import { randomBytes } from 'crypto'; -import { sessionForKey } from 'dor-lib-common'; -import type { AgentBrowserEditOp } from '../lib/platform/types'; -import { isBrowsableUrl } from '../lib/platform/browser-automation'; - -// The host owns the exact JS for each editing op — the webview only selects a -// name, so this never becomes an arbitrary-eval channel. copy/cut return the -// selected text; selectAll returns ''. Inputs/textareas use selection ranges; -// everything else falls back to the Selection API + execCommand. -const EDIT_SCRIPTS: Record<AgentBrowserEditOp, string> = { - selectAll: `(()=>{const el=document.activeElement;if(el&&'select'in el&&'value'in el){el.select();}else{document.execCommand('selectAll');}return'';})()`, - copy: `(()=>{const el=document.activeElement;if(el&&'selectionStart'in el&&el.selectionStart!=null){return el.value.slice(el.selectionStart,el.selectionEnd);}return String(window.getSelection()||'');})()`, - cut: `(()=>{const el=document.activeElement;if(el&&'selectionStart'in el&&el.selectionStart!=null){const s=el.selectionStart,e=el.selectionEnd,t=el.value.slice(s,e);el.setRangeText('',s,e,'end');el.dispatchEvent(new Event('input',{bubbles:true}));return t;}const sel=String(window.getSelection()||'');if(sel)document.execCommand('delete');return sel;})()`, -}; - -/** The fixed script for an editing op; undefined for any other name. - * - * `op` is typed but arrives from webview IPC unvalidated, and a plain-object - * lookup answers for inherited keys too: `op: 'constructor'` yields `Object`, - * which is truthy and walks straight past a caller's rejection into the page. - * `hasOwnProperty.call` keeps the table's own three names the only ones that - * select a script, which is what the comment on `EDIT_SCRIPTS` claims. Same - * guard, same reason as `own()` in `RemoteControlSection.tsx`. */ -export function editScript(op: unknown): string | undefined { - return typeof op === 'string' && Object.prototype.hasOwnProperty.call(EDIT_SCRIPTS, op) - ? EDIT_SCRIPTS[op as AgentBrowserEditOp] - : undefined; -} - -// A fresh managed session for a surface spawned from the GUI (no `--key`), -// using dor ab's workspace-scoped sessionForKey namespacing so it can't collide -// with a user's own browser sessions. -export function generateGuiSession(): string { - return sessionForKey(`gui-${randomBytes(6).toString('hex')}`); -} - -/** A capture's image format: PNG when asked for, else JPEG. */ -export function captureFormat(format: unknown): 'png' | 'jpeg' { - return format === 'png' ? 'png' : 'jpeg'; -} - -/** A capture's JPEG quality: an integer in 1..100, defaulting to 85. */ -export function jpegQuality(quality: unknown): number { - if (typeof quality !== 'number' || !Number.isFinite(quality)) return 85; - return Math.min(100, Math.max(1, Math.round(quality))); -} - -/** An agent-browser session name. `dor ab --session` passes a user's raw name - * through, so anything goes but what agent-browser would read as an option or - * its socket directory as a path: the name lands after `--session` and in - * `<socket dir>/<session>.pid`, whose pid a relaunch signals. */ -export function isAgentBrowserSession(value: unknown): value is string { - return typeof value === 'string' && /^(?!-)[^/\\\x00-\x1f\x7f]{1,200}$/.test(value); -} - -/** A Playwright session name: Dormouse mints these, and the host passes them - * as `--session=<name>`, so a strict charset costs nothing. */ -export function isPlaywrightSession(value: unknown): value is string { - return typeof value === 'string' && /^[A-Za-z0-9._-]{1,200}$/.test(value); -} - -/** One command the webview may ask a provider host to run. */ -export type WebviewCommand = - | { kind: 'open'; url: string } - | { kind: 'back' | 'forward' | 'reload' | 'close' } - | { kind: 'cdp-url' } - | { kind: 'tab-list' } - | { kind: 'tab-select' | 'tab-close'; tab: string } - | { kind: 'viewport'; width: number; height: number; dpr: number } - | { kind: 'device'; name: string }; - -const TAB_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9 ()._-]{0,63}$/; - -function dimension(value: string, max: number): number | null { - const n = /^\d{1,5}(\.\d{1,20})?$/.test(value) ? Number(value) : NaN; - return n > 0 && n <= max ? n : null; -} - -/** - * The webview's command argv (the wire stays agent-browser's grammar), parsed - * into the one shape its verb takes, or null. The security boundary for both - * hosts: each renders its own argv or API call from the parsed value, so no - * webview token reaches a CLI as it came — agent-browser reads launch options - * anywhere on its command line (docs/specs/dor-browser.md → "Agent-Browser Host - * Capabilities"). `args` is typed but arrives from webview IPC unvalidated. - */ -export function parseWebviewCommand(args: unknown): WebviewCommand | null { - if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return null; - const [verb, ...rest] = args as string[]; - switch (verb) { - case 'open': - return rest.length === 1 && isBrowsableUrl(rest[0]) ? { kind: 'open', url: rest[0] } : null; - case 'back': - case 'forward': - case 'reload': - case 'close': - return rest.length === 0 ? { kind: verb } : null; - case 'get': - return rest.length === 1 && rest[0] === 'cdp-url' ? { kind: 'cdp-url' } : null; - case 'tab': - if (rest.length === 1 && rest[0] === 'list') return { kind: 'tab-list' }; - // `tab <ref>` selects; the verb's own operation words are not refs. - if (rest.length === 1 && TAB_REF.test(rest[0]) && !['new', 'close'].includes(rest[0])) return { kind: 'tab-select', tab: rest[0] }; - return rest.length === 2 && rest[0] === 'close' && TAB_REF.test(rest[1]) ? { kind: 'tab-close', tab: rest[1] } : null; - case 'set': { - if (rest[0] === 'device') return rest.length === 2 && DEVICE_NAME.test(rest[1]) ? { kind: 'device', name: rest[1] } : null; - if (rest[0] !== 'viewport' || rest.length !== 4) return null; - const [width, height, dpr] = [dimension(rest[1], 16384), dimension(rest[2], 16384), dimension(rest[3], 10)]; - return width && height && dpr ? { kind: 'viewport', width, height, dpr } : null; - } - default: - return null; - } -} diff --git a/lib/src/host/browser-host-test-utils.ts b/lib/src/host/browser-host-test-utils.ts new file mode 100644 index 000000000..be9b40766 --- /dev/null +++ b/lib/src/host/browser-host-test-utils.ts @@ -0,0 +1,50 @@ +/** + * A fake `BrowserProvider` for tests that run the real browser host + * (`createBrowserHost`) — its own tests, and the webview's against it. + */ +import type { BrowserProvider, ProviderBinding } from './browser-host'; + +/** A provider that records every primitive the host calls, in order; `stop`, + * `close` and `open` can be held open by a test. */ +export function fakeProvider() { + const calls: string[] = []; + const held = new Map<string, () => void>(); + const hold = (name: string) => new Promise<void>((resolve) => { held.set(name, resolve); }); + const gates = new Set<string>(); + const step = async (name: string) => { + calls.push(name); + if (gates.has(name)) await hold(name); + }; + let tabs: { tabId: string; url: string }[] = []; + const provider: BrowserProvider<ProviderBinding> = { + pollMs: 1, + bind: (binding) => binding, + identity: (b) => b.session, + describe: (b) => ({ session: b.session }), + find: async () => ({ gone: 'not running', named: false }), + stop: async (b) => { await step(`stop ${b.session}`); }, + open: async (b, url, headed) => { + await step(`open ${b.session} ${url ?? 'blank'}${headed ? ' headed' : ''}`); + return { exitCode: 0, stderr: '' }; + }, + probe: async () => ({ wsPort: 4321 }), + close: async (b) => { await step(`close ${b.session}`); }, + listTabs: async () => tabs, + closeTab: async (b, tabId) => { calls.push(`tab ${b.session} close ${tabId}`); }, + act: async (b, act) => { + calls.push(`${act.op} ${b.session}${act.op === 'tab' ? ` ${act.action} ${act.tabId}` : ''}`); + return { ok: true }; + }, + evaluate: async () => '', + screenshot: async () => ({ bytes: new Uint8Array([1]) }), + streamUrl: async (port) => `ws://127.0.0.1:${port}`, + }; + return { + provider, + calls, + /** Hold the named primitive call until `release(name)`. */ + gate: (name: string) => gates.add(name), + release: (name: string) => { gates.delete(name); held.get(name)?.(); }, + setTabs: (next: typeof tabs) => { tabs = next; }, + }; +} diff --git a/lib/src/host/browser-host.test.ts b/lib/src/host/browser-host.test.ts new file mode 100644 index 000000000..03467efe9 --- /dev/null +++ b/lib/src/host/browser-host.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { createBrowserHost } from './browser-host'; +import { fakeProvider } from './browser-host-test-utils'; + +const flush = async () => { for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); }; + +describe('createBrowserHost', () => { + it('finishes a close of a session before a launch into it begins', async () => { + const fake = fakeProvider(); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + const session = { session: 'dormouse.1.default' }; + fake.gate('close dormouse.1.default'); + const closing = host.request({ provider: 'agent-browser', binding: session, op: 'close' }); + // A failed swap reopening the browser it had just closed. + const launching = host.request({ provider: 'agent-browser', binding: session, op: 'launch', url: 'http://localhost:5173/', headed: false }); + await flush(); + expect(fake.calls).toEqual(['close dormouse.1.default']); + fake.release('close dormouse.1.default'); + expect(await closing).toEqual({ ok: true }); + expect(await launching).toMatchObject({ ok: true, session: 'dormouse.1.default', wsPort: 4321 }); + expect(fake.calls).toEqual(['close dormouse.1.default', 'stop dormouse.1.default', 'open dormouse.1.default http://localhost:5173/']); + }); + + it('closes a browser after the launch of it still running, superseding one sent before the close that has not begun', async () => { + const fake = fakeProvider(); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + const tool = { provider: 'agent-browser', binding: { session: 'dormouse.1.tool.t' } } as const; + const launch = { ...tool, op: 'launch', url: 'http://localhost:6006/', headed: false } as const; + fake.gate('stop dormouse.1.tool.t'); + // A Tool's browser still coming up when its Surface closes; the next + // Surface to launch the name is closed too before its turn comes, and a + // third launches it after both closes. + const first = host.request(launch); + await flush(); + const closes = [host.request({ ...tool, op: 'close' })]; + const superseded = host.request(launch); + closes.push(host.request({ ...tool, op: 'close' })); + const third = host.request(launch); + fake.release('stop dormouse.1.tool.t'); + expect(await first).toMatchObject({ ok: true, wsPort: 4321 }); + expect(await superseded).toEqual({ ok: false, error: 'the browser was closed' }); + expect(await Promise.all(closes)).toEqual([{ ok: true }, { ok: true }]); + expect(await third).toMatchObject({ ok: true, wsPort: 4321 }); + expect(fake.calls).toEqual([ + 'stop dormouse.1.tool.t', 'open dormouse.1.tool.t http://localhost:6006/', + 'close dormouse.1.tool.t', 'close dormouse.1.tool.t', + 'stop dormouse.1.tool.t', 'open dormouse.1.tool.t http://localhost:6006/', + ]); + }); + + it('opens nothing for a request a close cancelled before it arrived, for as long and as many as it keeps', async () => { + const fake = fakeProvider(); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + const s1 = { provider: 'agent-browser', binding: { session: 's1' } } as const; + const launch = (requestId: string) => host.request({ ...s1, op: 'launch', url: 'http://localhost:5173/', headed: false, requestId }); + // The Surface's launch was sent first; the transport delivered its close first. + expect(await host.request({ ...s1, op: 'close', cancels: ['launch-1'] })).toEqual({ ok: true }); + expect(await launch('launch-1')).toEqual({ ok: false, error: 'the browser was closed' }); + expect(await host.request({ ...s1, op: 'attach', url: 'http://localhost:5173/', requestId: 'launch-1' })).toMatchObject({ ok: true }); + expect(fake.calls).toEqual(['close s1', 'open s1 http://localhost:5173/']); + + // Forgotten after five minutes, and past the newest 256. + const now = Date.now(); + await host.request({ ...s1, op: 'close', cancels: ['stale'] }); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 5 * 60_000 + 1); + try { + expect(await launch('stale')).toMatchObject({ ok: true }); + } finally { + clock.mockRestore(); + } + for (let batch = 0; batch < 9; batch++) { + await host.request({ ...s1, op: 'close', cancels: Array.from({ length: 32 }, (_, i) => `r${batch * 32 + i}`) }); + } + expect(await launch('r0')).toMatchObject({ ok: true }); + expect(await launch('r287')).toEqual({ ok: false, error: 'the browser was closed' }); + }); + + it('refuses a request id or cancel list it cannot bound', async () => { + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fakeProvider().provider } }); + const s1 = { provider: 'agent-browser', binding: { session: 's1' } } as const; + for (const requestId of ['has space', 'x'.repeat(65), 7, '']) { + expect(await host.request({ ...s1, op: 'launch', url: 'http://localhost:5173/', headed: false, requestId })).toEqual({ ok: false, error: 'invalid request id' }); + } + for (const cancels of ['r1', [7], ['has space'], Array.from({ length: 33 }, (_, i) => `r${i}`)]) { + expect(await host.request({ ...s1, op: 'close', cancels })).toEqual({ ok: false, error: 'invalid cancelled request ids' }); + } + }); + + it('bounds a close, so a hung one holds its browser\'s later requests, and shutdown, only so long', async () => { + vi.useFakeTimers(); + try { + const fake = fakeProvider(); + // A daemon that never answers: its close ends when the bound kills the CLI. + fake.provider.close = (_b, timeoutMs) => new Promise((_resolve, reject) => { setTimeout(() => reject(new Error('close timed out')), timeoutMs); }); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + const s1 = { provider: 'agent-browser', binding: { session: 's1' } } as const; + const closing = host.request({ ...s1, op: 'close' }); + const launching = host.request({ ...s1, op: 'launch', url: 'http://localhost:5173/', headed: false }); + await vi.advanceTimersByTimeAsync(9_999); + expect(fake.calls).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(await closing).toEqual({ ok: false, error: 'close timed out' }); + expect(await launching).toMatchObject({ ok: true }); + + // One that ignores its bound still leaves shutdown only that long. + fake.provider.close = () => new Promise(() => {}); + void host.request({ ...s1, op: 'close' }); + let shutDown = false; + void host.close().then(() => { shutDown = true; }); + await vi.advanceTimersByTimeAsync(10_000); + expect(shutDown).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('sweeps the blank tabs a launch leaves once its open returns, last first, and only beside a real page', async () => { + const fake = fakeProvider(); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { 'agent-browser': () => fake.provider } }); + fake.setTabs([{ tabId: 't1', url: 'about:blank' }, { tabId: 't2', url: 'http://localhost:5173/' }, { tabId: 't3', url: 'about:blank' }]); + fake.gate('open s1 http://localhost:5173/'); + const launched = await Promise.race([ + host.request({ provider: 'agent-browser', binding: { session: 's1' }, op: 'launch', url: 'http://localhost:5173/', headed: false }), + flush().then(() => null), + ]); + // Up before the page loads: nothing reaches the browser while `open` holds it. + expect(launched).toMatchObject({ ok: true, wsPort: 4321 }); + expect(fake.calls.filter((call) => call.startsWith('tab'))).toEqual([]); + fake.release('open s1 http://localhost:5173/'); + await flush(); + expect(fake.calls.filter((call) => call.startsWith('tab'))).toEqual(['tab s1 close t3', 'tab s1 close t1']); + + // The sole tab, or blank tabs with no real page beside them, are left. + fake.setTabs([{ tabId: 't1', url: 'about:blank' }, { tabId: 't2', url: 'about:blank' }]); + await host.request({ provider: 'agent-browser', binding: { session: 's2' }, op: 'launch', url: 'http://localhost:5173/', headed: false }); + await flush(); + expect(fake.calls.filter((call) => call.startsWith('tab s2'))).toEqual([]); + }); + + it('makes each provider on its first request, and shuts down closing only the browsers launched headed', async () => { + const fake = fakeProvider(); + const playwright = vi.fn(() => fake.provider); + const host = createBrowserHost({ writeClipboardText: vi.fn(), providers: { playwright } }); + expect(playwright).not.toHaveBeenCalled(); + await host.request({ provider: 'playwright', binding: { session: 'shown' }, op: 'launch', headed: true }); + await host.request({ provider: 'playwright', binding: { session: 'hidden' }, op: 'launch', headed: false }); + // A browser relaunched headless leaves shutdown's list. + await host.request({ provider: 'playwright', binding: { session: 'back' }, op: 'launch', headed: true }); + await host.request({ provider: 'playwright', binding: { session: 'back' }, op: 'launch', headed: false }); + expect(playwright).toHaveBeenCalledOnce(); + fake.calls.length = 0; + await host.close(); + expect(fake.calls).toEqual(['close shown']); + // A headed launch still waiting on its browser is closed at once, so a + // window whose page never loads is not orphaned. + const slow = fakeProvider(); + slow.provider.probe = async () => undefined; + slow.gate('open slow blank headed'); + const quitting = createBrowserHost({ writeClipboardText: vi.fn(), providers: { playwright: () => slow.provider } }); + const launching = quitting.request({ provider: 'playwright', binding: { session: 'slow' }, op: 'launch', headed: true }); + await flush(); + void quitting.close(); + await flush(); + expect(slow.calls).toContain('close slow'); + slow.release('open slow blank headed'); + expect((await launching).ok).toBe(false); + // A provider no request needed is never made, not even to shut down. + const unused = vi.fn(() => fake.provider); + await createBrowserHost({ writeClipboardText: vi.fn(), providers: { playwright: unused } }).close(); + expect(unused).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/host/browser-host.ts b/lib/src/host/browser-host.ts new file mode 100644 index 000000000..e52f2f747 --- /dev/null +++ b/lib/src/host/browser-host.ts @@ -0,0 +1,658 @@ +/** + * The one browser-automation host behind every webview + * (docs/specs/dor-browser.md → "Browser Host"), shared by + * the VS Code extension host and the standalone sidecar. Every request arrives + * as one provider-tagged `BrowserRequest`, is validated here once — the + * security boundary for both providers — and runs under one lifecycle: launches + * and closes serialized per native identity, the post-launch blank-tab sweep, + * capture joins and their private directory, the editing scripts, headed + * tracking and shutdown. A provider implements only the primitives that + * genuinely differ (`BrowserProvider`): agent-browser its daemon state files + * and argv, Playwright its install discovery, registry and CDP viewer. + */ +import { randomBytes } from 'crypto'; +import * as path from 'path'; +import { promises as fs } from 'fs'; +import { + BROWSER_PROVIDERS, + isBrowserProvider, + sessionForKey, + type BrowserAutomationProvider, + type BrowserBinding, +} from 'dor-lib-common/browser-providers'; +import { messageOf } from '../lib/errors'; +import { settleAllWithin } from '../lib/settle-within'; +import { + BROWSER_CLOSE_MAX_CANCELS, + BROWSER_REQUEST_TIMEOUT_MS, + isBlankUrl, + isBrowsableUrl, + type BrowserEditOp, + type BrowserOp, + type BrowserRequest, + type BrowserRequestBinding, + type BrowserResult, +} from '../lib/platform/browser-automation'; +import { privateCaptureDir } from './private-capture-dir'; + +/** An operation on a live browser that each provider maps to its own call: + * a fixed agent-browser argv, or a Playwright client call. */ +export type BrowserAct = Extract<BrowserOp, { op: 'navigate' | 'history' | 'tab' | 'viewport' | 'device' | 'cdpUrl' }>; + +/** The binding a provider runs one request with: the session named, or minted + * for a new launch. */ +export type ProviderBinding = BrowserBinding; + +/** A browser that is up: where it streams, and whether it runs headed when the + * provider can tell. */ +export interface LiveBrowser { + wsPort: number; + headed?: boolean; +} + +/** How a CLI's `open` ended. */ +export interface OpenOutcome { + exitCode: number; + stderr: string; +} + +/** + * The primitives a provider implements beneath the shared lifecycle. `B` is + * its resolved binding, made once per request. + */ +export interface BrowserProvider<B = unknown> { + /** Resolve a validated binding; throws when the provider cannot run. */ + bind(binding: ProviderBinding): B; + /** The native identity of `b`'s browser: what launches and closes serialize + * on, and what a Surface is found by. */ + identity(b: B): string; + /** What a launch or attach answers with beside the port. */ + describe(b: B): BrowserBinding; + /** The live browser, found without starting one; `gone` (why) when nothing + * runs the session, `named` when something still carries its name, so a + * relaunch stops it first. Throws when one runs that cannot be viewed. */ + find(b: B): Promise<LiveBrowser | { gone: string; named: boolean }>; + /** End whatever runs the session so a relaunch starts it in the mode it + * asks for; answers what `probe` needs to tell the replacement from it. */ + stop(b: B, timeoutMs: number): Promise<unknown>; + /** Start the CLI's `open` — blank without a `url`. Settles when `open` + * returns, possibly long after the browser is up. */ + open(b: B, url: string | undefined, headed: boolean): Promise<OpenOutcome>; + /** One readiness check during a launch: the browser once it is up, why the + * launch is lost, or `undefined` for not yet. `opened` is set once `open` + * returned; `replaced` is what `stop` answered. */ + probe(b: B, launch: { replaced: unknown; opened?: OpenOutcome; deadline: number }): Promise<LiveBrowser | { failed: string } | undefined>; + /** How often a launch probes, in ms. */ + readonly pollMs: number; + /** Close the session within `timeoutMs`; throws when the CLI refused or + * overran. The host has released `b` first. */ + close(b: B, timeoutMs: number): Promise<void>; + /** Drop what the provider holds for `b` besides the session itself. */ + release?(b: B): Promise<void>; + /** The session's tabs, and closing one, for the post-launch sweep. */ + listTabs(b: B): Promise<{ tabId: string; url: string }[]>; + closeTab(b: B, tabId: string): Promise<void>; + act(b: B, act: BrowserAct): Promise<BrowserResult>; + /** Run one of the host's fixed editing scripts in the page. */ + evaluate(b: B, script: string): Promise<unknown>; + /** One device-resolution frame: written to `file()` by a CLI, or its bytes. */ + screenshot(b: B, opts: { format: 'jpeg' | 'png'; quality: number }, file: () => Promise<string>): Promise<{ path: string } | { bytes: Uint8Array }>; + /** The URL the webview connects to for a stream port. */ + streamUrl(port: number): Promise<string>; + /** Shutdown: release every client-side resource. */ + dispose?(): Promise<void>; +} + +export interface BrowserHostDeps { + /** Write text to the OS clipboard (copy/cut land here). VS Code passes + * `vscode.env.clipboard.writeText`; the sidecar shells out (pbcopy/clip/…). */ + writeClipboardText(text: string): void | Promise<void>; + log?(message: string): void; + /** Each provider this host drives, made on its first request — Playwright's + * bundle carries `ws`, and most sessions never open a Playwright pane. */ + providers: { [P in BrowserAutomationProvider]?: () => BrowserProvider<any> }; +} + +// The host owns the exact JS for each editing op — the webview only selects a +// name, so this never becomes an arbitrary-eval channel. copy/cut return the +// selected text; selectAll returns ''. Inputs/textareas use selection ranges; +// everything else falls back to the Selection API + execCommand. +const EDIT_SCRIPTS: Record<BrowserEditOp, string> = { + selectAll: `(()=>{const el=document.activeElement;if(el&&'select'in el&&'value'in el){el.select();}else{document.execCommand('selectAll');}return'';})()`, + copy: `(()=>{const el=document.activeElement;if(el&&'selectionStart'in el&&el.selectionStart!=null){return el.value.slice(el.selectionStart,el.selectionEnd);}return String(window.getSelection()||'');})()`, + cut: `(()=>{const el=document.activeElement;if(el&&'selectionStart'in el&&el.selectionStart!=null){const s=el.selectionStart,e=el.selectionEnd,t=el.value.slice(s,e);el.setRangeText('',s,e,'end');el.dispatchEvent(new Event('input',{bubbles:true}));return t;}const sel=String(window.getSelection()||'');if(sel)document.execCommand('delete');return sel;})()`, +}; + +/** The fixed script for an editing op; undefined for any other name. + * + * `op` arrives from webview IPC unvalidated, and a plain-object lookup answers + * for inherited keys too: `op: 'constructor'` yields `Object`, which is truthy + * and would walk straight past a caller's rejection into the page. + * `hasOwnProperty.call` keeps the table's own three names the only ones that + * select a script. Same guard, same reason as `own()` in + * `RemoteControlSection.tsx`. */ +function editScript(op: unknown): string | undefined { + return typeof op === 'string' && Object.prototype.hasOwnProperty.call(EDIT_SCRIPTS, op) + ? EDIT_SCRIPTS[op as BrowserEditOp] + : undefined; +} + +/** A fresh managed session for a browser the GUI opens with none named, in + * `dor`'s `sessionForKey` namespace so it can't collide with a user's own. */ +function generateGuiSession(): string { + return sessionForKey(`gui-${randomBytes(6).toString('hex')}`); +} + +const TAB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9 ()._-]{0,63}$/; +/** A webview-minted id of a request that can bring a browser up. */ +const REQUEST_ID = /^[A-Za-z0-9-]{1,64}$/; + +/** `value` as an optional request id: absent, valid, or `null` when invalid. */ +function optionalRequestId(value: unknown): { requestId?: string } | null { + if (value === undefined) return {}; + return typeof value === 'string' && REQUEST_ID.test(value) ? { requestId: value } : null; +} + +function dimension(value: unknown, max: number): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= max ? value : null; +} + +/** A capture's JPEG quality: an integer in 1..100, defaulting to 85. */ +function jpegQuality(quality: unknown): number { + if (typeof quality !== 'number' || !Number.isFinite(quality)) return 85; + return Math.min(100, Math.max(1, Math.round(quality))); +} + +/** + * `raw`, as a request the providers may run, or why it may not. The request + * arrives from webview IPC unvalidated, so the result is rebuilt field by + * field: a provider renders its own argv or client call from these values, and + * no caller token reaches a CLI as it came — agent-browser reads launch + * options anywhere on its command line (rationale in docs/specs/dor-browser.md + * → "Browser Host"). + */ +function parseBrowserRequest(raw: unknown): BrowserRequest | string { + if (!raw || typeof raw !== 'object') return 'invalid browser request'; + const r = raw as Record<string, unknown>; + const provider = r.provider; + if (!isBrowserProvider(provider)) return 'unknown browser provider'; + const given = (r.binding && typeof r.binding === 'object' ? r.binding : {}) as Record<string, unknown>; + const binding: BrowserRequestBinding = {}; + if (given.session !== undefined) { + if (!BROWSER_PROVIDERS[provider].isSessionName(given.session)) return 'a valid session name is required'; + binding.session = given.session; + } + if (typeof given.cwd === 'string' && path.isAbsolute(given.cwd)) binding.cwd = given.cwd; + // Checked against the provider's allowlist where it is spawned. + if (typeof given.binaryPath === 'string') binding.binaryPath = given.binaryPath; + const op = parseOp(r); + if (typeof op === 'string') return op; + if (op.op === 'launch') { + // A new session opens where it was asked; a relaunch only carries its page + // along, reopening blank on one it may not navigate to. + if (binding.session === undefined && !isBrowsableUrl(op.url)) return 'Browser navigation requires an http(s) URL'; + } else if (op.op !== 'streamUrl' && binding.session === undefined) { + return 'a valid session name is required'; + } + if (op.op === 'cdpUrl' && provider !== 'agent-browser') return `${provider} has no cdpUrl operation`; + return { provider, binding, ...op }; +} + +function parseOp(r: Record<string, unknown>): BrowserOp | string { + switch (r.op) { + case 'launch': + case 'attach': { + const id = optionalRequestId(r.requestId); + if (!id) return 'invalid request id'; + const url = isBrowsableUrl(r.url) ? { url: r.url } : {}; + return r.op === 'launch' + ? { op: 'launch', ...url, headed: r.headed === true, ...id } + : { op: 'attach', ...url, ...(r.headed === true ? { headed: true } : {}), ...id }; + } + case 'streamUrl': { + const port = r.port; + return typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65535 ? { op: 'streamUrl', port } : 'a stream port is required'; + } + case 'screenshot': + // Normalized where it is taken (`screenshot`). + return { op: 'screenshot', ...(r.format === 'png' ? { format: 'png' } : {}), ...(typeof r.quality === 'number' ? { quality: r.quality } : {}) }; + case 'edit': + return editScript(r.edit) !== undefined ? { op: 'edit', edit: r.edit as BrowserEditOp } : `unknown edit op '${String(r.edit)}'`; + case 'navigate': + return isBrowsableUrl(r.url) ? { op: 'navigate', url: r.url } : 'Browser navigation requires an http(s) URL'; + case 'history': + return r.dir === 'back' || r.dir === 'forward' || r.dir === 'reload' ? { op: 'history', dir: r.dir } : 'unknown history direction'; + case 'tab': { + const { action, tabId } = r; + if ((action !== 'select' && action !== 'close') || typeof tabId !== 'string' || !TAB_ID.test(tabId)) return 'invalid tab operation'; + return { op: 'tab', action, tabId }; + } + case 'viewport': { + const [width, height, dpr] = [dimension(r.width, 16384), dimension(r.height, 16384), dimension(r.dpr, 10)]; + return width && height && dpr ? { op: 'viewport', width, height, dpr } : 'invalid viewport'; + } + case 'device': + return typeof r.name === 'string' && DEVICE_NAME.test(r.name) ? { op: 'device', name: r.name } : 'invalid device name'; + case 'cdpUrl': + return { op: 'cdpUrl' }; + case 'close': { + const cancels = r.cancels; + if (cancels === undefined) return { op: 'close' }; + const valid = Array.isArray(cancels) && cancels.length <= BROWSER_CLOSE_MAX_CANCELS + && cancels.every((id) => typeof id === 'string' && REQUEST_ID.test(id)); + return valid ? { op: 'close', cancels: [...cancels] as string[] } : 'invalid cancelled request ids'; + } + default: + return `unsupported browser operation '${String(r.op)}'`; + } +} + +// A launch answers inside every transport's wait for any request +// (`BROWSER_REQUEST_TIMEOUT_MS`), or the webview gives up on a browser the host +// is still bringing up. The whole request, from its arrival, gets +// REQUEST_BUDGET_MS, a margin short of that wait for the transport. Startup — +// queueing behind an earlier launch, stopping the old browser, probing — ends +// LAUNCH_CLOSE_RESERVE_MS before it; a launch that gives up then waits up to +// OPEN_SETTLE_MS for its `open`, and closes the session with whatever remains. +const REQUEST_BUDGET_MS = BROWSER_REQUEST_TIMEOUT_MS - 2_000; +// Everything else run in a browser's lifecycle queue is bounded too, so one +// hung CLI holds that browser's later requests, and shutdown, only this long. +const CLOSE_TIMEOUT_MS = 10_000; +const OPEN_SETTLE_MS = 4_000; +const LAUNCH_CLOSE_RESERVE_MS = OPEN_SETTLE_MS + 4_000; +const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); + +/** Screenshots answer with their bytes, or — to the sidecar, whose Rust + * caller reads the file itself — with the private file holding them. */ +type Transport = 'bytes' | 'file'; + +/** One request, resolved: its provider, the provider's binding, and the + * native identity everything per-browser keys on. */ +type Bound = { p: BrowserProvider<unknown>; b: unknown; id: string }; + +export function createBrowserHost(deps: BrowserHostDeps) { + const log = (error: unknown) => deps.log?.(`[browser-host] ${messageOf(error)}`); + const providers = new Map<BrowserAutomationProvider, BrowserProvider<unknown>>(); + let closed = false; + + function providerFor(id: BrowserAutomationProvider): BrowserProvider<unknown> { + let provider = providers.get(id); + if (!provider) { + const make = deps.providers[id]; + if (!make) throw new Error(`${id} is unavailable on this host`); + provider = make(); + providers.set(id, provider); + } + return provider; + } + + // --- per-identity lifecycle --- + + // Launches, attaches and closes of one browser run one at a time, in arrival + // order. + const lifecycle = new Map<string, Promise<unknown>>(); + function serialize<T>(id: string, action: () => Promise<T>): Promise<T> { + const operation = (lifecycle.get(id) ?? Promise.resolve()).catch(() => {}).then(action); + lifecycle.set(id, operation); + void operation.finally(() => { if (lifecycle.get(id) === operation) lifecycle.delete(id); }).catch(() => {}); + return operation; + } + + // The closes of each browser that have arrived. + const closesArrived = new Map<string, number>(); + /** Serialize work that can bring `id`'s browser up — a launch, an attach — + * unless a close of it arrives before its turn: a Surface closed meanwhile + * sent it, and run after that close it would reopen the session, possibly + * under the next Surface to launch the name. */ + function bringUp<T>(id: string, action: () => Promise<T>): Promise<T> { + const closes = closesArrived.get(id) ?? 0; + return serialize(id, async () => { + if ((closesArrived.get(id) ?? 0) !== closes) throw new Error('the browser was closed'); + return action(); + }); + } + + // Requests a close cancelled — sent before it by the Surface it closed — + // that have not arrived yet: a transport may deliver them after the close, + // and one run then would bring the browser up for a closed Surface. Kept + // well past any request's wait (`BROWSER_REQUEST_TIMEOUT_MS`) and bounded, + // oldest first; one already arrived is superseded above if still queued, or + // closed after by the close if running, so its entry just expires. + const cancelled = new Map<string, number>(); + const CANCEL_TTL_MS = 5 * 60_000; + const MAX_CANCELLED = 256; + function cancelRequests(ids: readonly string[] = []): void { + const now = Date.now(); + // Insertion order is expiry order: the TTL is fixed. + for (const [id, expires] of cancelled) { + if (expires > now) break; + cancelled.delete(id); + } + for (const id of ids) { + cancelled.delete(id); + cancelled.set(id, now + CANCEL_TTL_MS); + } + for (const id of cancelled.keys()) { + if (cancelled.size <= MAX_CANCELLED) break; + cancelled.delete(id); + } + } + /** Whether a close already cancelled request `id`, forgetting it. */ + function wasCancelled(id: string | undefined): boolean { + if (id === undefined) return false; + const expires = cancelled.get(id); + cancelled.delete(id); + return expires !== undefined && expires > Date.now(); + } + + // Bumped by every launch and close: work begun for an earlier browser (a + // post-launch sweep, a capture to join) must not reach the one that + // replaced it. + const generations = new Map<string, number>(); + async function invalidate({ p, b, id }: Bound): Promise<number> { + const generation = (generations.get(id) ?? 0) + 1; + generations.set(id, generation); + forgetInFlight(id); + await p.release?.(b); + return generation; + } + + /** Release what the provider holds for the browser, then close its session. */ + async function shut({ p, b }: Bound, timeoutMs = CLOSE_TIMEOUT_MS): Promise<void> { + await p.release?.(b); + await p.close(b, Math.max(0, timeoutMs)); + } + + // Browsers launched headed are real OS windows, so shutdown closes them. + // Headless ones are left alive to reattach across webview reloads. + const headed = new Map<string, Bound>(); + function trackHeaded(bound: Bound, isHeaded: boolean | undefined): void { + if (isHeaded === true) headed.set(bound.id, bound); + else if (isHeaded === false) headed.delete(bound.id); + } + + // The newest launch per identity; a failed launch's late close defers to it. + const latestLaunch = new Map<string, object>(); + + /** Launch `bound`'s browser at `url`, or blank without one: headed or not, + * stopping what runs the session first unless it is `fresh`. Answers once + * the browser is up, never waiting for the page. */ + async function launch(bound: Bound, url: string | undefined, isHeaded: boolean, fresh: boolean, requestDeadline: number): Promise<LiveBrowser> { + const { p, b, id } = bound; + if (closed) throw new Error('the browser host is shutting down'); + const deadline = requestDeadline - LAUNCH_CLOSE_RESERVE_MS; + if (Date.now() >= deadline) throw new Error('the browser launch timed out behind an earlier one'); + const generation = await invalidate(bound); + const replaced = fresh ? undefined : await p.stop(b, Math.max(0, deadline - Date.now())); + const token = {}; + latestLaunch.set(id, token); + // Before the launch, so a window whose page never loads is still closed. + trackHeaded(bound, isHeaded); + let opened: OpenOutcome | undefined; + const opening = p.open(b, url, isHeaded).then( + (outcome) => { opened = outcome; }, + (error: unknown) => { opened = { exitCode: 1, stderr: messageOf(error) }; }, + ); + // The browser coming up, not the page loading, completes a launch. + let why: string | undefined; + while (Date.now() < deadline && !closed) { + let probe: Awaited<ReturnType<typeof p.probe>>; + try { + probe = await p.probe(b, { replaced, opened, deadline }); + } catch (error) { + why = messageOf(error); + } + if (probe && 'wsPort' in probe) { + sweepAfter(opening, bound, generation); + return probe; + } + if (probe && 'failed' in probe) { + why = probe.failed; + break; + } + await wait(p.pollMs); + } + // Until `open` registers the session, a close has nothing to close, and the + // browser it then brings up is one nothing tracks. Let it land first; if it + // is still running, close again once it does, unless a newer launch has + // taken the session over by then. + const [landed] = await settleAllWithin([opening.then(() => true)], OPEN_SETTLE_MS, false); + await shut(bound, requestDeadline - Date.now()).catch(log); + if (latestLaunch.get(id) === token) headed.delete(id); + if (!landed) { + void opening.then(async () => { + if (latestLaunch.get(id) === token) await shut(bound); + }).catch(log); + } + throw new Error(opened?.stderr.trim() || why || 'the browser launch timed out'); + } + + /** Once `open` returns, close the blank tabs a launch can leave — only while + * a real page is open, so never the sole tab, and only for the browser this + * launch brought up. Best-effort: a failure here fails nothing. */ + function sweepAfter(opening: Promise<void>, { p, b, id }: Bound, generation: number): void { + const current = () => !closed && generations.get(id) === generation; + void opening.then(async () => { + if (!current()) return; + const tabs = await p.listTabs(b); + // The list may have queued behind `open`; a newer launch or a close can + // begin while it waits. + if (!current() || tabs.length < 2 || !tabs.some((tab) => isBrowsableUrl(tab.url))) return; + // Last first, so a provider that names tabs by index keeps the rest's. + for (const tab of [...tabs].reverse()) { + if (!current()) return; + if (isBlankUrl(tab.url)) await p.closeTab(b, tab.tabId); + } + }).catch(log); + } + + /** A named launch into a browser already up in the mode it asks for: the + * page opens there — a navigation, not waited on — and nothing is stopped, + * so an agent driving the session keeps its tabs, state and CDP clients. + * Undefined when the browser is gone, cannot be viewed, or runs in the + * other mode, which a relaunch changes. agent-browser cannot report its + * mode, so a browser this host did not launch headed counts as headless. */ + async function reuse(bound: Bound, url: string | undefined, isHeaded: boolean): Promise<LiveBrowser | undefined> { + const { p, b, id } = bound; + const found = await p.find(b).catch(() => undefined); + if (!found || !('wsPort' in found) || (found.headed ?? headed.has(id)) !== isHeaded) return undefined; + if (isBrowsableUrl(url)) { + void p.act(b, { op: 'navigate', url }).then((result) => { + if (!result.ok) log(`navigating ${id} to ${url} failed: ${result.error ?? 'no reason given'}`); + }, log); + } + return { ...found, headed: isHeaded }; + } + + /** The live browser; one that is gone relaunches at `url` when the caller + * names a page — `relaunched`, so the caller has no navigation left to run + * there — and fails otherwise. Serialized with launches, so two panes + * restoring one session relaunch it once. */ + function attach(bound: Bound, url: string | undefined, isHeaded: boolean, requestDeadline: number): Promise<LiveBrowser & { relaunched?: true }> { + return bringUp(bound.id, async () => { + const found = await bound.p.find(bound.b); + if ('wsPort' in found) return found; + if (!isBrowsableUrl(url)) throw new Error(found.gone); + return { ...await launch(bound, url, isHeaded, !found.named, requestDeadline), relaunched: true }; + }); + } + + /** Close `bound`'s browser once the launch or attach of it running now has + * landed, so what that brings up is closed too; one still queued is + * superseded (`bringUp`), and one arriving later runs after. */ + function closeSession(bound: Bound): Promise<void> { + closesArrived.set(bound.id, (closesArrived.get(bound.id) ?? 0) + 1); + return serialize(bound.id, async () => { + // The session is closed on purpose, so it is no longer shutdown's to + // close. Invalidating released it. + await invalidate(bound); + headed.delete(bound.id); + await bound.p.close(bound.b, CLOSE_TIMEOUT_MS); + }); + } + + // --- captures --- + + // Screenshots of the user's authenticated browser land here, written by an + // external process under the ambient umask — which is why the private + // directory, not the file mode, is the control. Every file is a fresh + // random name — unguessable, and never one a reader may still be reading. + const captures = privateCaptureDir('dormouse-browser-'); + async function freshCapturePath(format: 'jpeg' | 'png'): Promise<string> { + return path.join(await captures.get(), `shot-${randomBytes(12).toString('hex')}.${format === 'png' ? 'png' : 'jpg'}`); + } + + // Files handed to the file transport's reader, which deletes each once read + // (the Tauri `browser_screenshot` command). Kept per browser until then: its + // close or relaunch removes those still there, and each new one removes any + // older than every reader's wait, which no reader will come for. + const handedOut = new Map<string, Map<string, number>>(); + async function handOut(id: string, bytes: Uint8Array, format: 'jpeg' | 'png'): Promise<string> { + const file = await freshCapturePath(format); + await fs.writeFile(file, bytes, { mode: 0o600 }); + let files = handedOut.get(id); + if (!files) handedOut.set(id, files = new Map()); + const now = Date.now(); + for (const [old, at] of files) { + if (at > now - BROWSER_REQUEST_TIMEOUT_MS) break; + files.delete(old); + void fs.unlink(old).catch(() => {}); + } + files.set(file, now); + return file; + } + + // A capture a caller asking meanwhile joins rather than repeats, one per + // browser and format: surfaces can share a session, and a caller re-asks + // after its adapter's timeout. Never one from before the browser's close or + // relaunch (`forgetInFlight`). + const inFlight = new Map<string, { id: string; promise: Promise<Uint8Array> }>(); + function joinInFlight(id: string, format: string, work: () => Promise<Uint8Array>): Promise<Uint8Array> { + const key = `${format}\0${id}`; + const pending = inFlight.get(key); + if (pending) return pending.promise; + const entry = { id, promise: work().finally(() => { if (inFlight.get(key) === entry) inFlight.delete(key); }) }; + inFlight.set(key, entry); + return entry.promise; + } + + /** Join none of `id`'s pending captures, and delete the frames of its page + * still handed out, rather than leave them on disk until shutdown. */ + function forgetInFlight(id: string): void { + for (const [key, entry] of inFlight) if (entry.id === id) inFlight.delete(key); + for (const file of handedOut.get(id)?.keys() ?? []) void fs.unlink(file).catch(() => {}); + handedOut.delete(id); + } + + async function screenshot({ p, b, id }: Bound, asked: { format?: 'jpeg' | 'png'; quality?: number }, transport: Transport): Promise<BrowserResult> { + const opts = { format: asked.format === 'png' ? 'png' as const : 'jpeg' as const, quality: jpegQuality(asked.quality) }; + const mime = opts.format === 'png' ? 'image/png' : 'image/jpeg'; + let bytes: Uint8Array; + try { + // The frame is held in memory and its capture file gone before anyone + // joined reads it, so each caller gets its own copy. + bytes = await joinInFlight(id, opts.format, async () => { + const shot = await p.screenshot(b, opts, () => freshCapturePath(opts.format)); + if ('bytes' in shot) return shot.bytes; + try { + const buffer = await fs.readFile(shot.path); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } finally { + await fs.unlink(shot.path).catch(() => {}); + } + }); + } catch (error) { + return { ok: false, error: messageOf(error) }; + } + if (transport === 'bytes') return { ok: true, bytes, mime }; + if (closed) return { ok: false, error: 'the browser host is shutting down' }; + return { ok: true, path: await handOut(id, bytes, opts.format), mime }; + } + + // --- editing --- + + async function edit({ p, b }: Bound, op: BrowserEditOp): Promise<BrowserResult> { + const result = await p.evaluate(b, EDIT_SCRIPTS[op]); + if (op === 'selectAll') return { ok: true }; + const text = typeof result === 'string' ? result : ''; + // Skip empty, so an empty selection doesn't clobber the clipboard. + if (text) { + try { + await deps.writeClipboardText(text); + } catch (error) { + return { ok: false, error: `clipboard write failed: ${messageOf(error)}` }; + } + } + return { ok: true, text }; + } + + // --- dispatch --- + + async function run(raw: unknown, transport: Transport): Promise<BrowserResult> { + const r = parseBrowserRequest(raw); + if (typeof r === 'string') return { ok: false, error: r }; + try { + if (closed) throw new Error('the browser host is shutting down'); + // Sent before a close that cancelled it, delivered after: it opens + // nothing for the Surface that close was for. + if ((r.op === 'launch' || r.op === 'attach') && wasCancelled(r.requestId)) throw new Error('the browser was closed'); + const p = providerFor(r.provider); + if (r.op === 'streamUrl') return { ok: true, url: await p.streamUrl(r.port) }; + const b = p.bind({ ...r.binding, session: r.binding.session ?? generateGuiSession() }); + const bound: Bound = { p, b, id: p.identity(b) }; + const answer = (live: LiveBrowser): BrowserResult => { + trackHeaded(bound, live.headed); + return { ok: true, ...p.describe(b), nativeIdentity: bound.id, wsPort: live.wsPort, ...(live.headed !== undefined ? { headed: live.headed } : {}) }; + }; + const requestDeadline = Date.now() + REQUEST_BUDGET_MS; + switch (r.op) { + case 'launch': { + const fresh = r.binding.session === undefined; + const live = await bringUp(bound.id, async () => (fresh ? undefined : await reuse(bound, r.url, r.headed)) + ?? launch(bound, r.url, r.headed, fresh, requestDeadline)); + return answer({ headed: r.headed, ...live }); + } + case 'attach': { + const { relaunched, ...live } = await attach(bound, r.url, r.headed === true, requestDeadline); + return { ...answer(live), ...(relaunched ? { relaunched } : {}) }; + } + case 'close': + cancelRequests(r.cancels); + await closeSession(bound); + return { ok: true }; + case 'screenshot': + return await screenshot(bound, r, transport); + case 'edit': + return await edit(bound, r.edit); + default: + return await p.act(b, r); + } + } catch (error) { + return { ok: false, error: messageOf(error) }; + } + } + + return { + /** One request from the webview; a screenshot answers with its bytes. */ + request: (raw: unknown) => run(raw, 'bytes'), + /** The same, but a screenshot answers with a private file's path. */ + requestFile: (raw: unknown) => run(raw, 'file'), + /** Shutdown: close every headed window — so quitting orphans none — and + * drop the capture directory, so no frame of the user's browser outlives + * the process that took it. */ + close: async () => { + // Every launch and sweep still pending now finds itself superseded. + closed = true; + const windows = [...headed.values()]; + headed.clear(); + await Promise.all([ + ...windows.map((bound) => shut(bound).catch(log)), + captures.remove().then(() => handedOut.clear()), + ]); + await settleAllWithin([...lifecycle.values()], CLOSE_TIMEOUT_MS, undefined); + await Promise.all([...providers.values()].map((provider) => provider.dispose?.())); + }, + }; +} + diff --git a/lib/src/host/playwright-host.lifecycle.test.ts b/lib/src/host/playwright-host.lifecycle.test.ts index c27011afc..da9f783ac 100644 --- a/lib/src/host/playwright-host.lifecycle.test.ts +++ b/lib/src/host/playwright-host.lifecycle.test.ts @@ -1,10 +1,12 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; import { Server } from 'node:http'; import { WebSocket } from 'ws'; -import { createPlaywrightHost } from './playwright-host'; -import { PLAYWRIGHT_REQUEST_TIMEOUT_MS, PLAYWRIGHT_TEXT_INPUT_MAX, playwrightTextInputs } from '../lib/platform/browser-automation'; +import { createBrowserHost } from './browser-host'; +import { createPlaywrightProvider } from './playwright-host'; +import { BROWSER_REQUEST_TIMEOUT_MS, PLAYWRIGHT_TEXT_INPUT_MAX, playwrightTextInputs, type BrowserOp, type BrowserRequestBinding } from '../lib/platform/browser-automation'; const mocks = vi.hoisted(() => ({ cli: vi.fn(), connect: vi.fn(), clipboard: vi.fn() })); vi.mock('dor-lib-common', async importOriginal => ({ ...await importOriginal<typeof import('dor-lib-common')>(), spawnAndCapture: mocks.cli })); @@ -13,12 +15,14 @@ vi.mock('./playwright-install', () => ({ playwrightWorkspace: () => process.cwd(), })); -let host: ReturnType<typeof createPlaywrightHost>; +let host: ReturnType<typeof createBrowserHost>; let page: EventEmitter & Record<string, any>; let browser: EventEmitter & Record<string, any>; let cdp: { send: ReturnType<typeof vi.fn>; detach: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> }; let attach: ReturnType<typeof vi.fn>; const binding = { cwd: process.cwd(), session: 'test' }; +/** One Playwright request through the shared host, bound to `b`. */ +const pw = (op: BrowserOp, b: BrowserRequestBinding = binding) => host.request({ provider: 'playwright', binding: b, ...op }); beforeEach(() => { vi.clearAllMocks(); @@ -41,16 +45,23 @@ beforeEach(() => { endpoint: '/tmp/test-playwright.pipe', browser: { browserName: 'chromium' }, }] } : { result: '- 0: (current) Test' }), })); - host = createPlaywrightHost({ writeClipboardText: mocks.clipboard }); + host = createBrowserHost({ writeClipboardText: mocks.clipboard, providers: { playwright: () => createPlaywrightProvider() } }); }); afterEach(async () => { await host.close(); vi.restoreAllMocks(); }); -test('concurrent captures share one CDP attachment and detach it once on close', async () => { - const results = await Promise.all(Array.from({ length: 3 }, () => host.request({ ...binding, op: 'screenshot' }))); - expect(results.map(result => result.error)).toEqual([undefined, undefined, undefined]); +test('concurrent captures join one, a concurrent control shares its CDP attachment, and close detaches it once', async () => { + page.setViewportSize = vi.fn(async () => {}); + const [first, second, sized] = await Promise.all([ + pw({ op: 'screenshot' }), + pw({ op: 'screenshot' }), + pw({ op: 'viewport', width: 640, height: 480, dpr: 1 }), + ]); + expect(first.error).toBeUndefined(); + expect(second).toEqual(first); + expect(sized.ok).toBe(true); expect(attach).toHaveBeenCalledTimes(1); - expect(cdp.send).toHaveBeenCalledTimes(3); - expect((await host.request({ ...binding, op: 'command', args: ['close'] })).ok).toBe(true); + expect(cdp.send.mock.calls.filter(([method]) => method === 'Page.captureScreenshot')).toHaveLength(1); + expect((await pw({ op: 'close' })).ok).toBe(true); expect(cdp.detach).toHaveBeenCalledTimes(1); expect(browser.close).toHaveBeenCalledTimes(1); }); @@ -58,11 +69,11 @@ test('concurrent captures share one CDP attachment and detach it once on close', test('closing during a CDP attachment releases it without capturing', async () => { let complete!: (value: typeof cdp) => void; attach.mockImplementation(() => new Promise(resolve => { complete = resolve; })); - const { wsPort } = await host.request({ ...binding, op: 'attach' }); - const capture = host.request({ ...binding, op: 'screenshot' }); + const { wsPort } = await pw({ op: 'attach' }); + const capture = pw({ op: 'screenshot' }); await vi.waitFor(() => expect(attach).toHaveBeenCalledTimes(1)); - const closing = host.request({ ...binding, op: 'command', args: ['close'] }); - await vi.waitFor(async () => expect((await host.request({ op: 'streamUrl', port: wsPort! })).ok).toBe(false)); + const closing = pw({ op: 'close' }); + await vi.waitFor(async () => expect((await pw({ op: 'streamUrl', port: wsPort! }, {})).ok).toBe(false)); complete(cdp); expect((await capture).ok).toBe(false); expect((await closing).ok).toBe(true); @@ -72,8 +83,8 @@ test('closing during a CDP attachment releases it without capturing', async () = test('a failed attachment can be retried', async () => { attach.mockRejectedValueOnce(new Error('Tab detached')); - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(false); - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); + expect((await pw({ op: 'screenshot' })).ok).toBe(false); + expect((await pw({ op: 'screenshot' })).ok).toBe(true); expect(attach).toHaveBeenCalledTimes(2); }); @@ -82,7 +93,7 @@ test('a viewer listener failure releases its browser connection', async () => { queueMicrotask(() => this.emit('error', new Error('Listener unavailable'))); return this; }); - const result = await host.request({ ...binding, op: 'attach' }); + const result = await pw({ op: 'attach' }); expect(result.error).toBe('Listener unavailable'); expect(browser.close).toHaveBeenCalledTimes(1); }); @@ -90,14 +101,14 @@ test('a viewer listener failure releases its browser connection', async () => { test('captures reuse recent tab state but refresh it when it expires', async () => { const now = vi.spyOn(Date, 'now').mockReturnValue(10000); browser.contexts = () => [{ pages: () => [page, Object.assign(new EventEmitter(), page)] }]; - expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(true); + expect((await pw({ op: 'attach' })).ok).toBe(true); // No viewer yet, so attach left the tab state to the first capture. - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); + expect((await pw({ op: 'screenshot' })).ok).toBe(true); mocks.cli.mockClear(); - for (let i = 0; i < 10; i++) expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); + for (let i = 0; i < 10; i++) expect((await pw({ op: 'screenshot' })).ok).toBe(true); expect(mocks.cli).not.toHaveBeenCalled(); now.mockReturnValue(10750); - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); + expect((await pw({ op: 'screenshot' })).ok).toBe(true); expect(mocks.cli).toHaveBeenCalledExactlyOnceWith('/tools/playwright-cli', ['--session=test', 'tab-list', '--json'], { cwd: binding.cwd, timeoutMs: 10_000 }); }); @@ -112,9 +123,9 @@ test('GUI tab selection refreshes immediately even with a recent capture', async if (args.includes('tab-list')) return { ok: true, exitCode: 0, stdout: JSON.stringify({ result: `- ${active}: (current) Test` }), stderr: '' }; return originalCli(binary, args, options); }); - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); - expect((await host.request({ ...binding, op: 'command', args: ['tab', '1'] })).ok).toBe(true); - expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); + expect((await pw({ op: 'screenshot' })).ok).toBe(true); + expect((await pw({ op: 'tab', action: 'select', tabId: '1' })).ok).toBe(true); + expect((await pw({ op: 'screenshot' })).ok).toBe(true); expect(attach).toHaveBeenLastCalledWith(second); }); @@ -130,49 +141,71 @@ test('a native reopen in headless mode clears headed shutdown ownership', async } return result; }); - expect((await host.request({ ...binding, op: 'attach' })).headed).toBe(true); + expect((await pw({ op: 'attach' })).headed).toBe(true); browser.emit('disconnected'); headless = true; - expect((await host.request({ ...binding, op: 'attach' })).headed).toBe(false); + expect((await pw({ op: 'attach' })).headed).toBe(false); mocks.cli.mockClear(); await host.close(); expect(mocks.cli).not.toHaveBeenCalled(); }); test('a single-page browser refreshes without asking the CLI for its selection', async () => { - expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(true); + expect((await pw({ op: 'attach' })).ok).toBe(true); expect(mocks.cli.mock.calls.map(([, args]) => args[1])).toEqual(['list']); }); -test('a GUI open launches its fresh session without closing it first; a relaunch still does', async () => { - const opened = await host.request({ cwd: process.cwd(), op: 'open', url: 'http://localhost/' }); +test('hands the sidecar a fresh frame file per capture, so the next never rewrites one being read', async () => { + page.setViewportSize = vi.fn(async () => {}); + const file = (op: BrowserOp) => host.requestFile({ provider: 'playwright', binding, ...op }); + const first = await file({ op: 'screenshot' }); + cdp.send.mockResolvedValue({ data: Buffer.from('next').toString('base64') }); + const second = await file({ op: 'screenshot' }); + expect(second.path).not.toBe(first.path); + expect(readFileSync(first.path!, 'utf8')).toBe('hello'); + expect(readFileSync(second.path!, 'utf8')).toBe('next'); +}); + +test('a GUI open launches its fresh session without closing it first; a named launch navigates it in its mode and relaunches it into the other', async () => { + page.goto = vi.fn(async () => null); + const opened = await pw({ op: 'launch', url: 'http://localhost/', headed: false }, { cwd: process.cwd() }); expect(opened.error).toBeUndefined(); expect(mocks.cli.mock.calls.map(([, args]) => args[1])).not.toContain('close'); + const named = { ...binding, session: opened.session! }; + + // Up headless (a Tool re-announced): its page opens there, and nothing an + // agent drives is stopped. mocks.cli.mockClear(); - expect((await host.request({ ...binding, session: opened.session!, op: 'popIn', url: 'http://localhost/' })).ok).toBe(true); + expect(await pw({ op: 'launch', url: 'http://localhost/next', headed: false }, named)).toMatchObject({ ok: true, headed: false }); + expect(mocks.cli.mock.calls.map(([, args]) => args[1])).not.toContain('close'); + expect(mocks.cli.mock.calls.map(([, args]) => args[1])).not.toContain('open'); + await vi.waitFor(() => expect(page.goto).toHaveBeenCalledWith('http://localhost/next', { waitUntil: 'commit' })); + + // A pop-out asks for the other mode: a relaunch, closing first. + mocks.cli.mockClear(); + expect((await pw({ op: 'launch', url: 'http://localhost/', headed: true }, named)).ok).toBe(true); expect(mocks.cli.mock.calls[0][1]).toEqual([`--session=${opened.session}`, 'close']); }); test('a relaunch without an http(s) page reopens blank; a GUI open still needs one', async () => { // A bare `dor pw open` pane sits on about:blank; a headed window can be left - // on a file, data or error page when the user closes it. + // on a file, data or error page when the user closes it. Each is a pop-out + // of the headless browser up, so a relaunch. for (const url of [undefined, 'about:blank', 'file:///etc/passwd', 'data:text/html,hi', 'chrome-error://chromewebdata/']) { - for (const op of ['popOut', 'popIn'] as const) { - mocks.cli.mockClear(); - const result = await host.request({ ...binding, op, ...(url === undefined ? {} : { url }) }); - expect(result.error, `${op} ${url}`).toBeUndefined(); - const open = mocks.cli.mock.calls.find(([, args]) => args[1] === 'open')![1]; - expect(open, `${op} ${url}`).toEqual(['--session=test', 'open', '--browser=chromium', ...(op === 'popOut' ? ['--headed'] : [])]); - } + mocks.cli.mockClear(); + const result = await pw({ op: 'launch', headed: true, ...(url === undefined ? {} : { url }) }); + expect(result.error, url).toBeUndefined(); + const open = mocks.cli.mock.calls.find(([, args]) => args[1] === 'open')![1]; + expect(open, url).toEqual(['--session=test', 'open', '--browser=chromium', '--headed']); } mocks.cli.mockClear(); - expect((await host.request({ ...binding, op: 'popIn', url: 'http://localhost/next' })).ok).toBe(true); + expect((await pw({ op: 'launch', url: 'http://localhost/next', headed: true })).ok).toBe(true); expect(mocks.cli.mock.calls.find(([, args]) => args[1] === 'open')![1]).toContain('http://localhost/next'); - expect((await host.request({ cwd: process.cwd(), op: 'open', url: 'file:///etc/passwd' })).error).toBe('Browser navigation requires an http(s) URL'); + expect((await pw({ op: 'launch', url: 'file:///etc/passwd', headed: false }, { cwd: process.cwd() })).error).toBe('Browser navigation requires an http(s) URL'); }); test.each(['attach', 'startScreencast'] as const)('a screencast whose %s fails mid-navigation is retried on the next poll', async (failing) => { - const { wsPort } = await host.request({ ...binding, op: 'attach' }); + const { wsPort } = await pw({ op: 'attach' }); const starts = () => cdp.send.mock.calls.filter(([method]) => method === 'Page.startScreencast').length; if (failing === 'attach') attach.mockRejectedValueOnce(new Error('Target navigated')); else { @@ -181,7 +214,7 @@ test.each(['attach', 'startScreencast'] as const)('a screencast whose %s fails m return { data: 'aGVsbG8=' }; }); } - const { url } = await host.request({ op: 'streamUrl', port: wsPort! }); + const { url } = await pw({ op: 'streamUrl', port: wsPort! }, {}); const ws = new WebSocket(url!); ws.on('error', () => {}); try { @@ -213,17 +246,17 @@ describe('attach', () => { test('a live session answers its viewer port without launching', async () => { running = true; - const attached = await host.request({ ...binding, op: 'attach', url: 'http://localhost/' }); + const attached = await pw({ op: 'attach', url: 'http://localhost/' }); expect(attached).toMatchObject({ ok: true, headed: false, wsPort: expect.any(Number) }); expect(attached).not.toHaveProperty('relaunched'); expect(verbs()).not.toContain('open'); }); test('a gone session relaunches at the page named, and fails without one', async () => { - expect((await host.request({ ...binding, op: 'attach' })).error).toBe('Playwright session is not open or has no viewable endpoint'); + expect((await pw({ op: 'attach' })).error).toBe('Playwright session is not open or has no viewable endpoint'); expect(verbs()).not.toContain('open'); - const attached = await host.request({ ...binding, op: 'attach', url: 'http://localhost/', headed: true }); + const attached = await pw({ op: 'attach', url: 'http://localhost/', headed: true }); // Opened at the page, so the caller has no navigation left to run. expect(attached).toMatchObject({ ok: true, wsPort: expect.any(Number), relaunched: true }); expect(mocks.cli.mock.calls.map(([, args]) => args)).toContainEqual(['--session=test', 'open', 'http://localhost/', '--browser=chromium', '--headed']); @@ -233,38 +266,38 @@ describe('attach', () => { running = true; // Two tabs, so a refresh would ask the CLI which is selected. browser.contexts = () => [{ pages: () => [page, Object.assign(new EventEmitter(), page)] }]; - expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(true); + expect((await pw({ op: 'attach' })).ok).toBe(true); expect(verbs()).toEqual(['list']); }); test('relaunches a session no registry entry names without closing it first', async () => { - expect((await host.request({ ...binding, op: 'attach', url: 'http://localhost/' })).ok).toBe(true); + expect((await pw({ op: 'attach', url: 'http://localhost/' })).ok).toBe(true); expect(verbs()).not.toContain('close'); }); test('a session it cannot view is never relaunched', async () => { running = true; browserName = 'firefox'; - expect((await host.request({ ...binding, op: 'attach', url: 'http://localhost/' })).ok).toBe(false); + expect((await pw({ op: 'attach', url: 'http://localhost/' })).ok).toBe(false); expect(verbs()).not.toContain('open'); }); }); test('copy runs the shared edit script and never overwrites the clipboard with an empty selection', async () => { page.evaluate = vi.fn(async (script: unknown) => typeof script === 'string' ? '' : { width: 640, height: 480 }); - expect(await host.request({ ...binding, op: 'edit', edit: 'copy' })).toMatchObject({ ok: true, text: '' }); + expect(await pw({ op: 'edit', edit: 'copy' })).toMatchObject({ ok: true, text: '' }); expect(mocks.clipboard).not.toHaveBeenCalled(); page.evaluate = vi.fn(async (script: unknown) => typeof script === 'string' ? 'hello' : { width: 640, height: 480 }); - expect(await host.request({ ...binding, op: 'edit', edit: 'copy' })).toMatchObject({ ok: true, text: 'hello' }); + expect(await pw({ op: 'edit', edit: 'copy' })).toMatchObject({ ok: true, text: 'hello' }); expect(mocks.clipboard).toHaveBeenCalledExactlyOnceWith('hello'); - expect((await host.request({ ...binding, op: 'edit', edit: 'constructor' as never })).error).toBe('Invalid editing operation'); + expect((await pw({ op: 'edit', edit: 'constructor' as never })).error).toBe("unknown edit op 'constructor'"); }); test('viewers get tabs, url and status only when they change, and the current state when they connect', async () => { - const { wsPort } = await host.request({ ...binding, op: 'attach' }); + const { wsPort } = await pw({ op: 'attach' }); const sockets: WebSocket[] = []; const connectViewer = async () => { - const { url } = await host.request({ op: 'streamUrl', port: wsPort! }); + const { url } = await pw({ op: 'streamUrl', port: wsPort! }, {}); const types: string[] = []; const ws = new WebSocket(url!); sockets.push(ws); @@ -276,10 +309,10 @@ test('viewers get tabs, url and status only when they change, and the current st try { const first = await connectViewer(); await vi.waitFor(() => expect(first).toEqual(['url', 'tabs', 'status'])); - await host.request({ ...binding, op: 'attach' }); - await host.request({ ...binding, op: 'attach' }); + await pw({ op: 'attach' }); + await pw({ op: 'attach' }); page.url = () => 'http://localhost/next'; - await host.request({ ...binding, op: 'attach' }); + await pw({ op: 'attach' }); // A repeated state message would have arrived ahead of the navigation. await vi.waitFor(() => expect(first).toEqual(['url', 'tabs', 'status', 'url', 'tabs'])); const second = await connectViewer(); @@ -291,8 +324,8 @@ test('viewers get tabs, url and status only when they change, and the current st }); test('a long paste reaches the page whole without tripping the input backlog', async () => { - const { wsPort } = await host.request({ ...binding, op: 'attach' }); - const { url } = await host.request({ op: 'streamUrl', port: wsPort! }); + const { wsPort } = await pw({ op: 'attach' }); + const { url } = await pw({ op: 'streamUrl', port: wsPort! }, {}); const ws = new WebSocket(url!); ws.on('error', () => {}); const closed = vi.fn(); @@ -353,7 +386,7 @@ describe('a GUI launch that gives up', () => { const popOut = () => { const done = { at: -1 }; const start = Date.now(); - const answer = host.request({ ...binding, op: 'popOut', url: 'http://localhost/' }).then((r) => { done.at = Date.now() - start; return r; }); + const answer = pw({ op: 'launch', url: 'http://localhost/', headed: true }).then((r) => { done.at = Date.now() - start; return r; }); return { answer, done }; }; @@ -371,9 +404,9 @@ describe('a GUI launch that gives up', () => { test('closes an open that lands after it answered, unless a newer launch owns the session', async () => { const { answer, done } = popOut(); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); expect((await answer).ok).toBe(false); - expect(done.at).toBeLessThan(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + expect(done.at).toBeLessThan(BROWSER_REQUEST_TIMEOUT_MS); const closes = () => events.filter(verb => verb === 'close').length; const before = closes(); open.resolve(ok); @@ -383,7 +416,7 @@ describe('a GUI launch that gives up', () => { // A newer launch of the session: the earlier open landing must not close it. const stale = open = Promise.withResolvers(); const gaveUp = popOut(); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); await gaveUp.answer; open = Promise.withResolvers(); const newer = popOut(); @@ -392,7 +425,7 @@ describe('a GUI launch that gives up', () => { stale.resolve(ok); await vi.advanceTimersByTimeAsync(0); expect(closes()).toBe(settled); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); await newer.answer; }); @@ -404,20 +437,20 @@ describe('a GUI launch that gives up', () => { setTimeout(() => reject(new Error('connect timed out')), timeout); })); const { answer, done } = popOut(); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); expect((await answer).ok).toBe(false); expect(done.at).toBeGreaterThan(0); - expect(done.at).toBeLessThan(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + expect(done.at).toBeLessThan(BROWSER_REQUEST_TIMEOUT_MS); }); test('a launch queued behind a slow one still answers inside its own budget', async () => { const first = popOut(); await vi.advanceTimersByTimeAsync(1_000); const second = popOut(); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); expect((await first.answer).ok).toBe(false); expect((await second.answer).ok).toBe(false); - expect(second.done.at).toBeLessThan(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + expect(second.done.at).toBeLessThan(BROWSER_REQUEST_TIMEOUT_MS); }); test('a wedged CLI cannot hold a launch past its budget', async () => { @@ -426,11 +459,11 @@ describe('a GUI launch that gives up', () => { const start = Date.now(); hung = (verb) => verb === 'close' || (verb === 'list' && Date.now() - start >= 29_000); let at = -1; - const answer = host.request({ cwd: process.cwd(), op: 'open', url: 'http://localhost/' }).then((r) => { at = Date.now() - start; return r; }); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + const answer = pw({ op: 'launch', url: 'http://localhost/', headed: false }, { cwd: process.cwd() }).then((r) => { at = Date.now() - start; return r; }); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); expect((await answer).ok).toBe(false); expect(at).toBeGreaterThan(0); - expect(at).toBeLessThan(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + expect(at).toBeLessThan(BROWSER_REQUEST_TIMEOUT_MS); }); test('a wedged close before a queued relaunch ends it at its startup deadline', async () => { @@ -440,7 +473,7 @@ describe('a GUI launch that gives up', () => { await vi.advanceTimersByTimeAsync(7_000); // The first gives up about 34 s in; the second then starts with 3 s of its 30 s left. const second = popOut(); - await vi.advanceTimersByTimeAsync(PLAYWRIGHT_REQUEST_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS); expect((await first.answer).ok).toBe(false); expect((await second.answer).ok).toBe(false); expect(second.done.at).toBeLessThanOrEqual(30_000); diff --git a/lib/src/host/playwright-host.test.ts b/lib/src/host/playwright-host.test.ts index 705f1c51c..fd08906ba 100644 --- a/lib/src/host/playwright-host.test.ts +++ b/lib/src/host/playwright-host.test.ts @@ -6,7 +6,9 @@ import os from 'node:os'; import path from 'node:path'; import { WebSocket } from 'ws'; import { spawnAndCapture } from 'dor-lib-common'; -import { createPlaywrightHost } from './playwright-host'; +import type { BrowserOp, BrowserRequestBinding } from '../lib/platform/browser-automation'; +import { createBrowserHost } from './browser-host'; +import { createPlaywrightProvider } from './playwright-host'; // Opt-in: tests the user's real CLI and matching Chromium, with a private session. const binaryPath = process.env.DORMOUSE_PLAYWRIGHT_TEST_BIN; @@ -18,7 +20,8 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; let clipboard = ''; - const host = createPlaywrightHost({ writeClipboardText: text => { clipboard = text; } }); + const host = createBrowserHost({ writeClipboardText: text => { clipboard = text; }, providers: { playwright: () => createPlaywrightProvider() } }); + const pw = (op: BrowserOp, binding: BrowserRequestBinding) => host.request({ provider: 'playwright', binding, ...op }); let session = ''; let socket: WebSocket | undefined; const messages: any[] = []; @@ -28,15 +31,15 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu expect(predicate()).toBe(true); }; try { - const opened = await host.request({ op: 'open', cwd, binaryPath, url }); + const opened = await pw({ op: 'launch', url, headed: false }, { cwd, binaryPath }); expect(opened.error).toBeUndefined(); expect(opened.ok).toBe(true); session = opened.session!; const req = { cwd, binaryPath, session }; - const nested = await host.request({ ...req, cwd: path.join(cwd, 'nested'), op: 'attach' }); + const nested = await pw({ op: 'attach' }, { ...req, cwd: path.join(cwd, 'nested') }); expect(nested.wsPort).toBe(opened.wsPort); expect(nested.nativeIdentity).toBe(opened.nativeIdentity); - const stream = await host.request({ op: 'streamUrl', port: opened.wsPort! }); + const stream = await pw({ op: 'streamUrl', port: opened.wsPort! }, {}); socket = new WebSocket(stream.url!); socket.on('message', raw => messages.push(JSON.parse(String(raw)))); await waitFor(() => messages.some(m => m.type === 'frame') && messages.some(m => m.type === 'tabs')); @@ -45,48 +48,50 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu // Parking drops the viewer socket, then reconnects to the same CLI browser. socket.close(); await new Promise<void>(resolve => socket!.once('close', () => resolve())); - expect((await host.request({ ...req, op: 'attach' })).wsPort).toBe(opened.wsPort); + expect((await pw({ op: 'attach' }, req)).wsPort).toBe(opened.wsPort); messages.length = 0; - const resumedStream = await host.request({ op: 'streamUrl', port: opened.wsPort! }); + const resumedStream = await pw({ op: 'streamUrl', port: opened.wsPort! }, {}); socket = new WebSocket(resumedStream.url!); socket.on('message', raw => messages.push(JSON.parse(String(raw)))); await waitFor(() => messages.some(m => m.type === 'frame')); - expect((await host.request({ ...req, op: 'edit', edit: 'selectAll' })).ok).toBe(true); - expect((await host.request({ ...req, op: 'edit', edit: 'copy' })).ok).toBe(true); + expect((await pw({ op: 'edit', edit: 'selectAll' }, req)).ok).toBe(true); + expect((await pw({ op: 'edit', edit: 'copy' }, req)).ok).toBe(true); expect(clipboard).toBe('hello'); socket.send(JSON.stringify({ type: 'input_keyboard', eventType: 'keyDown', key: 'x', code: 'KeyX', text: 'x', windowsVirtualKeyCode: 88 })); await new Promise(r => setTimeout(r, 200)); - await host.request({ ...req, op: 'edit', edit: 'selectAll' }); - await host.request({ ...req, op: 'edit', edit: 'copy' }); + await pw({ op: 'edit', edit: 'selectAll' }, req); + await pw({ op: 'edit', edit: 'copy' }, req); expect(clipboard).toBe('x'); - const shot = await host.request({ ...req, op: 'screenshot', format: 'png' }); + const shot = await pw({ op: 'screenshot', format: 'png' }, req); expect(Buffer.isBuffer(shot.bytes)).toBe(false); expect(Buffer.from(shot.bytes!).subarray(1, 4).toString()).toBe('PNG'); const native = await spawnAndCapture(binaryPath!, [`--session=${session}`, 'tab-new', `${url}/second`], { cwd }); expect(native.ok && native.exitCode).toBe(0); await waitFor(() => messages.some(m => m.type === 'tabs' && m.tabs.length === 2 && m.tabs[1].active)); - expect((await host.request({ ...req, op: 'command', args: ['tab', '0'] })).ok).toBe(true); + expect((await pw({ op: 'tab', action: 'select', tabId: '0' }, req)).ok).toBe(true); await waitFor(() => messages.at(-1)?.type === 'status' && [...messages].reverse().find(m => m.type === 'tabs')?.tabs[0].active); - const viewport = await host.request({ ...req, op: 'command', args: ['set', 'viewport', '640', '480', '2'] }); + const viewport = await pw({ op: 'viewport', width: 640, height: 480, dpr: 2 }, req); expect(viewport.ok).toBe(true); - const sized = await host.request({ ...req, op: 'screenshot', format: 'png' }); + const sized = await pw({ op: 'screenshot', format: 'png' }, req); expect(Buffer.from(sized.bytes!).readUInt32BE(16)).toBe(1280); - expect((await host.request({ ...req, op: 'command', args: ['eval', 'process.exit()'] })).ok).toBe(false); - const popped = await host.request({ ...req, op: 'popOut', url }); + // No operation outside the typed set reaches the CLI. + expect((await host.request({ provider: 'playwright', binding: req, op: 'eval', script: 'process.exit()' })).ok).toBe(false); + const popped = await pw({ op: 'launch', url, headed: true }, req); expect(popped.ok, popped.error).toBe(true); - expect((await host.request({ ...req, op: 'attach' })).headed).toBe(true); + expect((await pw({ op: 'attach' }, req)).headed).toBe(true); await new Promise(r => setTimeout(r, 1000)); - const popTabs = await host.request({ ...req, op: 'command', args: ['tab', 'list'] }); - expect(JSON.parse(popTabs.stdout!).tabs).toHaveLength(1); - const relaunched = await host.request({ ...req, op: 'popIn', url }); + // The relaunch swept its startup blank tab. + const popTabs = await spawnAndCapture(binaryPath!, [`--session=${session}`, 'tab-list', '--json'], { cwd }); + expect(popTabs.ok && popTabs.stdout.match(/\d+:/g)).toHaveLength(1); + const relaunched = await pw({ op: 'launch', url, headed: false }, req); expect(relaunched.ok, relaunched.error).toBe(true); expect(relaunched.wsPort).not.toBe(opened.wsPort); - expect((await host.request({ op: 'streamUrl', port: opened.wsPort! })).ok).toBe(false); - expect((await host.request({ ...req, op: 'command', args: ['close'] })).ok).toBe(true); - expect((await host.request({ ...req, op: 'attach' })).ok).toBe(false); + expect((await pw({ op: 'streamUrl', port: opened.wsPort! }, {})).ok).toBe(false); + expect((await pw({ op: 'close' }, req)).ok).toBe(true); + expect((await pw({ op: 'attach' }, req)).ok).toBe(false); } finally { socket?.terminate(); - if (session) await host.request({ cwd, binaryPath, session, op: 'command', args: ['close'] }); + if (session) await pw({ op: 'close' }, { cwd, binaryPath, session }); await host.close(); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); diff --git a/lib/src/host/playwright-host.ts b/lib/src/host/playwright-host.ts index bb77a0865..c102158d7 100644 --- a/lib/src/host/playwright-host.ts +++ b/lib/src/host/playwright-host.ts @@ -1,32 +1,21 @@ -/** The installed Playwright CLI owns browsers; this host owns only their Dormouse viewers. */ +/** + * The Playwright provider beneath the shared browser host (`browser-host.ts`; + * docs/specs/dor-browser.md → "Playwright"). The installed Playwright + * CLI owns browsers; this provider owns what is genuinely Playwright's — the + * install and registry discovery, and the Dormouse viewer each browser is + * streamed through over CDP. The host owns everything the providers share. + */ import { createServer, type Server } from 'node:http'; -import { writeFile } from 'node:fs/promises'; import { realpathSync } from 'node:fs'; -import path from 'node:path'; -import { randomBytes } from 'node:crypto'; import { WebSocketServer, WebSocket } from 'ws'; import type { Browser, Page, CDPSession } from 'playwright-core'; -import { spawnAndCapture } from 'dor-lib-common'; +import { BROWSER_PROVIDERS, spawnAndCapture } from 'dor-lib-common'; import { messageOf } from '../lib/errors'; -import { - isBrowsableUrl, - PLAYWRIGHT_REQUEST_TIMEOUT_MS, - PLAYWRIGHT_TEXT_INPUT_MAX, - type PlaywrightRequest, - type PlaywrightResult, -} from '../lib/platform/browser-automation'; -import { - captureFormat, - editScript, - generateGuiSession, - isPlaywrightSession, - jpegQuality, - parseWebviewCommand, -} from './browser-host-shared'; +import { PLAYWRIGHT_TEXT_INPUT_MAX, type BrowserResult } from '../lib/platform/browser-automation'; +import type { BrowserProvider, LiveBrowser } from './browser-host'; import { resolvePlaywrightInstall, playwrightWorkspace, type PlaywrightInstall } from './playwright-install'; import { isLoopbackHost } from './loopback-guard'; import { BrowserStreamGrants } from './browser-stream-guard'; -import { privateCaptureDir } from './private-capture-dir'; const TAB_REFRESH_INTERVAL_MS = 750; const CONNECT_TIMEOUT_MS = 8_000; @@ -35,18 +24,6 @@ const CONNECT_TIMEOUT_MS = 8_000; // unbounded: it lasts as long as the page load, nothing waits on it past a // launch's own bounds, and ending it could take down the browser it started. const CLI_TIMEOUT_MS = 10_000; -// A GUI launch answers inside the webview's wait for any host request -// (`PLAYWRIGHT_REQUEST_TIMEOUT_MS`), or the webview restores the previous -// renderer while the host is still bringing a browser up. The whole request, -// from its arrival, gets REQUEST_BUDGET_MS, a margin short of that wait for the -// transport. Startup — queueing behind an earlier launch, closing the old -// session, listing, polling and connecting — ends LAUNCH_CLOSE_RESERVE_MS -// before it; a launch that gives up then waits up to OPEN_SETTLE_MS for its -// `open`, and closes the session with whatever remains. -const REQUEST_BUDGET_MS = PLAYWRIGHT_REQUEST_TIMEOUT_MS - 2_000; -const OPEN_SETTLE_MS = 4_000; -const LAUNCH_CLOSE_RESERVE_MS = OPEN_SETTLE_MS + 4_000; -const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); function realpathOrUndefined(file: string): string | undefined { try { @@ -97,21 +74,18 @@ const pagesOf = (v: Viewer) => v.browser.contexts().flatMap(context => context.p const tabsOf = (v: Viewer) => Promise.all(pagesOf(v).map(async (page, index) => ({ tabId: String(index), url: page.url(), title: await page.title().catch(() => ''), active: page === v.page, }))); -export function createPlaywrightHost(deps: { writeClipboardText(text: string): void | Promise<void>; log?(text: string): void }) { +export function createPlaywrightProvider(deps: { log?(text: string): void } = {}): BrowserProvider<Binding> { const viewers = new Map<string, Viewer>(); const connecting = new Map<string, Promise<Viewer>>(); + // Bumped whenever the host releases a binding's viewer: a connect begun + // before must not publish the viewer it brings back. const generations = new Map<string, number>(); - const lifecycle = new Map<string, Promise<unknown>>(); - // The newest launch per native identity; a failed launch's late close defers to it. - const latestLaunch = new Map<string, object>(); - const headed = new Map<string, Binding>(); const grants = new BrowserStreamGrants(); - const captures = privateCaptureDir('dormouse-playwright-'); let closed = false; const log = (e: unknown) => deps.log?.(`[playwright] ${messageOf(e)}`); /** One CLI call, ended after `timeoutMs` (none for `null`); throws when it could not run or finish. */ async function cli(b: Binding, args: string[], timeoutMs: number | null = CLI_TIMEOUT_MS) { - const r = await spawnAndCapture(b.install.binary, [`--session=${b.session}`, ...args], { + const r = await spawnAndCapture(b.install.binary, [...BROWSER_PROVIDERS.playwright.sessionArgs(b.session), ...args], { cwd: b.cwd, ...(timeoutMs === null ? {} : { timeoutMs: Math.max(0, timeoutMs) }), }); @@ -151,12 +125,6 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v viewers.delete(b.key); if (v) await dispose(v); } - function serialize<T>(b: Binding, action: () => Promise<T>): Promise<T> { - const operation = (lifecycle.get(b.key) ?? Promise.resolve()).catch(() => {}).then(action); - lifecycle.set(b.key, operation); - void operation.finally(() => { if (lifecycle.get(b.key) === operation) lifecycle.delete(b.key); }).catch(() => {}); - return operation; - } async function activeIndex(b: Binding): Promise<number> { const r = await cli(b, ['tab-list', '--json']); if (r.exitCode !== 0) return 0; @@ -362,214 +330,145 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v browser.on('disconnected', () => { if (viewers.get(key) === v) viewers.delete(key); void dispose(v); }); if (closed || gen !== (generations.get(key) ?? 0)) { await dispose(v); throw new Error('Browser launch superseded'); } viewers.set(key, v); - if (v.headed) headed.set(key, b); else headed.delete(key); return v; })(); connecting.set(key, operation); try { return await operation; } finally { if (connecting.get(key) === operation) connecting.delete(key); } } - /** Launch `b`'s browser at `url`, or blank when there is none. */ - async function launch(b: Binding, url: string | undefined, isHeaded: boolean, fresh: boolean, requestDeadline: number) { - if (closed) throw new Error('Playwright host is shutting down'); - const deadline = requestDeadline - LAUNCH_CLOSE_RESERVE_MS; - if (Date.now() >= deadline) throw new Error('Playwright browser launch timed out behind an earlier one'); - await invalidate(b); + /** The viewer's live page, after a refresh — immediate for a control, the + * poll's own for a capture. */ + async function livePage(b: Binding, force: boolean): Promise<{ v: Viewer; page: Page }> { + const v = await connect(b); + await refresh(v, force); + if (!v.page) throw new Error('No Playwright page is open'); + return { v, page: v.page }; + } + const exited = (r: { exitCode: number; stderr: string }) => r.stderr.trim() || `playwright-cli exited ${r.exitCode}`; + const live = (v: Viewer): LiveBrowser => ({ wsPort: v.port, headed: v.headed }); + + return { + pollMs: 200, + + bind: (binding) => bind(binding.session, binding.cwd ?? process.cwd(), resolvePlaywrightInstall(binding.binaryPath)), + + // Installation, CLI project scope and session: a raw `--session` shares it + // across one project's subdirectories. + identity: (b) => b.key, + + describe: (b) => ({ session: b.session, cwd: b.cwd, binaryPath: b.install.binary }), + + async find(b) { + try { + const v = await connect(b); + // A connecting viewer is sent the current state; only live ones need it now. + if (v.sockets.size) await refresh(v); + return live(v); + } catch (error) { + if (!(error instanceof SessionNotOpenError)) throw error; + return { gone: error.message, named: error.named }; + } + }, + // Finish the old CLI session before discovering the replacement endpoint. - // A freshly minted session has none to finish. - if (!fresh) await cli(b, ['close'], deadline - Date.now()); - const key = b.key; - const launchToken = {}; - latestLaunch.set(key, launchToken); - if (isHeaded) headed.set(key, b); else headed.delete(key); - const generation = generations.get(key); - let result: Awaited<ReturnType<typeof cli>> | undefined; - const opening = cli(b, ['open', ...(url === undefined ? [] : [url]), '--browser=chromium', ...(isHeaded ? ['--headed'] : [])], null); - const opened = opening.then(r => { result = r; }, e => { result = { exitCode: 1, stdout: '', stderr: messageOf(e) }; }); - // Endpoint readiness, not the page load, completes GUI launches. - let last: unknown; - while (Date.now() < deadline && !closed) { + stop: (b, timeoutMs) => cli(b, ['close'], timeoutMs), + + async open(b, url, isHeaded) { + const r = await cli(b, ['open', ...(url === undefined ? [] : [url]), '--browser=chromium', ...(isHeaded ? ['--headed'] : [])], null); + return { exitCode: r.exitCode, stderr: r.stderr }; + }, + + // Endpoint readiness, not the page load, completes a launch: each probe + // lists the registry and connects. + async probe(b, { opened, deadline }) { try { - const v = await connect(b, deadline); - // Only a completed, still-current launch may remove startup blank tabs. - void opening.then(async () => { - if (closed || generation !== generations.get(key) || v.disposed) return; - const pages = pagesOf(v); - if (!pages.some(p => isBrowsableUrl(p.url()))) return; - for (let i = pages.length - 1; i >= 0; i--) { - if (closed || generation !== generations.get(key) || v.disposed) return; - if (pages[i].url() === 'about:blank') await cli(b, ['tab-close', String(i)]); - } - }).catch(log); - return v; - } catch (e) { last = e; } - if (result && result.exitCode !== 0) break; - await wait(200); - } - // Until `open` registers the session, `close` has nothing to close, and the - // browser it then brings up is one nothing tracks. Let it land first; if it - // is still running, close again once it does, unless a newer launch has - // taken the session over by then. - const landed = await Promise.race([opened.then(() => true), wait(OPEN_SETTLE_MS).then(() => false)]); - await cli(b, ['close'], requestDeadline - Date.now()).catch(log); - if (!landed) { - void opened.then(async () => { - if (latestLaunch.get(key) === launchToken) await cli(b, ['close']); - }).catch(log); - } - throw new Error(result?.stderr || (last === undefined ? 'Playwright browser launch timed out' : messageOf(last))); - } - async function execute(request: PlaywrightRequest): Promise<PlaywrightResult> { - if (!request || typeof request !== 'object') throw new Error('Invalid Playwright request'); - if (request.op === 'streamUrl') { - const v = [...viewers.values()].find(v => v.port === request.port && !v.disposed); - if (!v) throw new Error('Playwright stream is no longer live'); - return { ok: true, url: `ws://127.0.0.1:${v.port}/stream/${grants.issue(v.port)}` }; - } - const install = resolvePlaywrightInstall(request.binaryPath); - const cwd = typeof request.cwd === 'string' && path.isAbsolute(request.cwd) ? request.cwd : process.cwd(); - // `open` mints a session unless the caller names one to open the page in. - const session = request.op === 'open' ? request.session ?? generateGuiSession() : request.session; - if (!isPlaywrightSession(session)) throw new Error('Invalid Playwright session name'); - const b = bind(session, cwd, install); - const bound = (v: Viewer, extra?: Partial<PlaywrightResult>): PlaywrightResult => ( - { ok: true, session, cwd, binaryPath: install.binary, wsPort: v.port, nativeIdentity: b.key, ...extra }); - if (request.op === 'open' || request.op === 'popOut' || request.op === 'popIn') { - // A GUI open navigates where it was asked, so that URL must pass the - // http(s) check. A relaunch only carries the page along: one Dormouse may - // not navigate to (about:blank, `file:`, `data:`, an error page) reopens - // blank rather than failing the pop-out or pop-in. - if (request.op === 'open' && !isBrowsableUrl(request.url)) throw new Error('Browser navigation requires an http(s) URL'); - const url = isBrowsableUrl(request.url) ? request.url : undefined; - const isHeaded = request.op === 'open' ? !!request.headed : request.op === 'popOut'; - const fresh = request.op === 'open' && request.session === undefined; - const deadline = Date.now() + REQUEST_BUDGET_MS; - return bound(await serialize(b, () => launch(b, url, isHeaded, fresh, deadline))); - } - // The viewer for a live session; one whose browser is gone is relaunched at - // `url` when the caller names it, and fails otherwise. - if (request.op === 'attach') { - const url = request.url; - const deadline = Date.now() + REQUEST_BUDGET_MS; - let relaunched = false; - const v = await serialize(b, async () => { - try { - return await connect(b); - } catch (error) { - if (!(error instanceof SessionNotOpenError) || !isBrowsableUrl(url)) throw error; - // A session no entry names has nothing to close first. - const launched = await launch(b, url, !!request.headed, !error.named, deadline); - relaunched = true; - return launched; + return live(await connect(b, deadline)); + } catch (error) { + if (opened && opened.exitCode !== 0) return { failed: opened.stderr.trim() || messageOf(error) }; + throw error; + } + }, + + async close(b, timeoutMs) { + const r = await cli(b, ['close'], timeoutMs); + if (r.exitCode !== 0) throw new Error(exited(r)); + }, + + release: (b) => invalidate(b), + + async listTabs(b) { + const v = await connect(b); + return pagesOf(v).map((page, index) => ({ tabId: String(index), url: page.url() })); + }, + + // The sweep's own close: no refresh around it, unlike a GUI tab close. + async closeTab(b, tabId) { + await cli(b, ['tab-close', tabId]); + }, + + async act(b, act): Promise<BrowserResult> { + const { v, page } = await livePage(b, true); + switch (act.op) { + case 'navigate': await page.goto(act.url, { waitUntil: 'commit' }); break; + case 'history': { + const options = { waitUntil: 'commit' } as const; + if (act.dir === 'reload') await page.reload(options); + else if (act.dir === 'back') await page.goBack(options); + else await page.goForward(options); + break; } - }); - // A connecting viewer is sent the current state; only live ones need it now. - if (v.sockets.size) await refresh(v); - return bound(v, { headed: v.headed, ...(relaunched ? { relaunched } : {}) }); - } - // Parsed before anything connects, so a refused command costs nothing. - const command = request.op === 'command' ? parseWebviewCommand(request.args) : undefined; - if (command === null) throw new Error('Unsupported Playwright host command'); - if (command?.kind === 'close') { - return serialize(b, async () => { - await invalidate(b); - headed.delete(b.key); - const r = await cli(b, ['close']); - return { ok: r.exitCode === 0, ...r }; - }); - } - const v = await connect(b); - await refresh(v, request.op !== 'screenshot'); - const page = v.page; - if (!page) throw new Error('No Playwright page is open'); - if (request.op === 'screenshot') { - const format = captureFormat(request.format); + case 'tab': { + // The Playwright CLI names tabs by index. + if (!/^\d+$/.test(act.tabId)) throw new Error('Unsupported tab operation'); + const r = await cli(b, [act.action === 'select' ? 'tab-select' : 'tab-close', act.tabId]); + await refresh(v); + return r.exitCode === 0 ? { ok: true } : { ok: false, error: exited(r) }; + } + case 'viewport': + case 'device': { + const devices = b.install.library.devices; + const device = act.op === 'device' && Object.prototype.hasOwnProperty.call(devices, act.name) ? devices[act.name] : undefined; + const size = act.op === 'viewport' ? act + : device ? { width: device.viewport.width, height: device.viewport.height, dpr: device.deviceScaleFactor } : undefined; + if (!size) throw new Error('Invalid viewport/device'); + const { width, height, dpr } = size; + await page.setViewportSize({ width, height }); + const cdp = await control(v, page); + await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile: device?.isMobile ?? false }); + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: device?.hasTouch ?? false }); + if (device) await cdp.send('Emulation.setUserAgentOverride', { userAgent: device.userAgent }); + break; + } + default: throw new Error('Unsupported Playwright host operation'); + } + await refresh(v); + return { ok: true }; + }, + + async evaluate(b, script) { + const { page } = await livePage(b, true); + return page.evaluate(script); + }, + + // CDP capture in-process. Captures share the viewer's polling cadence. + async screenshot(b, { format, quality }) { + const { v, page } = await livePage(b, false); const cdp = await control(v, page); - const { data } = await cdp.send('Page.captureScreenshot', { format, ...(format === 'jpeg' ? { quality: jpegQuality(request.quality) } : {}), captureBeyondViewport: false }); + const { data } = await cdp.send('Page.captureScreenshot', { format, ...(format === 'jpeg' ? { quality } : {}), captureBeyondViewport: false }); // Keep the cross-host contract a plain typed array, including VS Code's message transport. - const bytes = new Uint8Array(Buffer.from(data, 'base64')); - return { ok: true, bytes, mime: `image/${format}` }; - } - if (request.op === 'edit') { - const script = editScript(request.edit); - if (!script) throw new Error('Invalid editing operation'); - const result: unknown = await page.evaluate(script); - const text = typeof result === 'string' ? result : ''; - // Skip empty, so an empty selection doesn't clobber the clipboard. - if (request.edit !== 'selectAll' && text) await deps.writeClipboardText(text); - return { ok: true, text }; - } - if (!command) throw new Error('Invalid browser operation'); - switch (command.kind) { - case 'open': await page.goto(command.url, { waitUntil: 'commit' }); break; - case 'reload': await page.reload({ waitUntil: 'commit' }); break; - case 'back': await page.goBack({ waitUntil: 'commit' }); break; - case 'forward': await page.goForward({ waitUntil: 'commit' }); break; - case 'tab-list': return { ok: true, exitCode: 0, stdout: JSON.stringify({ tabs: await tabsOf(v) }), stderr: '' }; - case 'tab-select': - case 'tab-close': { - // The Playwright CLI names tabs by index. - if (!/^\d+$/.test(command.tab)) throw new Error('Unsupported tab operation'); - const r = await cli(b, [command.kind, command.tab]); - await refresh(v); - return { ok: r.exitCode === 0, ...r }; - } - case 'viewport': - case 'device': { - const devices = install.library.devices; - const device = command.kind === 'device' && Object.prototype.hasOwnProperty.call(devices, command.name) ? devices[command.name] : undefined; - const size = command.kind === 'viewport' ? command - : device ? { width: device.viewport.width, height: device.viewport.height, dpr: device.deviceScaleFactor } : undefined; - if (!size) throw new Error('Invalid viewport/device'); - const { width, height, dpr } = size; - await page.setViewportSize({ width, height }); - const cdp = await control(v, page); - await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile: device?.isMobile ?? false }); - await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: device?.hasTouch ?? false }); - if (device) await cdp.send('Emulation.setUserAgentOverride', { userAgent: device.userAgent }); - break; - } - default: throw new Error('Unsupported Playwright host command'); - } - await refresh(v); - return { ok: true, exitCode: 0, stdout: '', stderr: '' }; - } - async function request(r: PlaywrightRequest): Promise<PlaywrightResult> { - try { - if (closed) throw new Error('Playwright host is shutting down'); - return await execute(r); - } catch (e) { - const error = messageOf(e); - return { ok: false, error, exitCode: 1, stdout: '', stderr: error }; - } - } - async function requestFile(r: PlaywrightRequest): Promise<PlaywrightResult> { - try { - const result = await request(r); - if (!result.bytes) return result; - if (closed) return { ok: false, error: 'Playwright host is shutting down' }; - const file = path.join(await captures.get(), `${randomBytes(16).toString('hex')}.frame`); - await writeFile(file, result.bytes, { mode: 0o600 }); - // Shutdown may have begun while the frame was written. - if (closed) { - await captures.remove(); - return { ok: false, error: 'Playwright host is shutting down' }; - } - return { ok: true, path: file, mime: result.mime }; - } catch (error) { - return { ok: false, error: messageOf(error) }; - } - } - async function close() { - closed = true; - await Promise.allSettled([...lifecycle.values(), ...connecting.values()]); - await Promise.all([...headed.values()].map(async b => { - await invalidate(b); - await cli(b, ['close']).catch(log); - })); - await Promise.all([...viewers.values()].map(dispose)); - viewers.clear(); - headed.clear(); - await captures.remove(); - } - return { request, requestFile, close }; + return { bytes: new Uint8Array(Buffer.from(data, 'base64')) }; + }, + + async streamUrl(port) { + const v = [...viewers.values()].find(v => v.port === port && !v.disposed); + if (!v) throw new Error('Playwright stream is no longer live'); + return `ws://127.0.0.1:${v.port}/stream/${grants.issue(v.port)}`; + }, + + async dispose() { + closed = true; + await Promise.allSettled(connecting.values()); + await Promise.all([...viewers.values()].map(dispose)); + viewers.clear(); + }, + }; } diff --git a/lib/src/host/private-capture-dir.ts b/lib/src/host/private-capture-dir.ts index d7b9fd7e0..da3aa27dd 100644 --- a/lib/src/host/private-capture-dir.ts +++ b/lib/src/host/private-capture-dir.ts @@ -1,6 +1,6 @@ /** * The private per-process directory a browser host writes screenshot frames - * into (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"), shared + * into (docs/specs/dor-browser.md → "Browser Host"), shared * by the agent-browser and Playwright hosts. * * A frame is a picture of the user's authenticated browser, so the *directory* diff --git a/lib/src/lib/agent-browser-binary.test.ts b/lib/src/lib/agent-browser-binary.test.ts index 7ec8ff0db..b9a9bde2a 100644 --- a/lib/src/lib/agent-browser-binary.test.ts +++ b/lib/src/lib/agent-browser-binary.test.ts @@ -4,7 +4,7 @@ import { isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary } from './agent- // `binaryPath` reaches the host from the webview realm and off the persisted // session blob, and the host hands it to `spawnAndCapture`. The predicate is // what keeps that from being an arbitrary-exec channel, so its edges are the -// test (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). +// test (docs/specs/dor-browser.md → "Browser Host"). describe('isAllowedAgentBrowserBinary', () => { it('accepts the bare name and an absolute path to an agent-browser', () => { expect(isAllowedAgentBrowserBinary('agent-browser')).toBe(true); diff --git a/lib/src/lib/agent-browser-binary.ts b/lib/src/lib/agent-browser-binary.ts index fd5042cda..0570875a3 100644 --- a/lib/src/lib/agent-browser-binary.ts +++ b/lib/src/lib/agent-browser-binary.ts @@ -1,17 +1,21 @@ /** * What may be spawned as a browser provider's CLI (docs/specs/dor-browser.md → - * "Agent-Browser Host Capabilities"). The predicates live in `dor-lib-common`, + * "Browser Host"). The predicates live in `dor-lib-common`, * whose comment explains why `binaryPath` is an exec channel, so `dor` applies * the same gate to a binding the host hands back; re-exported here for the * webview and the Node hosts. */ -import { isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary } from 'dor-lib-common/agent-browser'; -import type { BrowserAutomationProvider } from './platform/browser-automation'; +import { + BROWSER_PROVIDERS, + isAllowedAgentBrowserBinary, + isAllowedPlaywrightBinary, + type BrowserAutomationProvider, +} from 'dor-lib-common/browser-providers'; export { isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary }; /** The webview's gate for whichever provider will spawn `candidate`. */ export function isAllowedBinaryFor(provider: BrowserAutomationProvider, candidate: unknown): candidate is string { - return provider === 'playwright' ? isAllowedPlaywrightBinary(candidate) : isAllowedAgentBrowserBinary(candidate); + return BROWSER_PROVIDERS[provider].isAllowedBinary(candidate); } diff --git a/lib/src/lib/platform/browser-automation.ts b/lib/src/lib/platform/browser-automation.ts index 83ebc99f4..cd929e3ba 100644 --- a/lib/src/lib/platform/browser-automation.ts +++ b/lib/src/lib/platform/browser-automation.ts @@ -1,40 +1,85 @@ -/** Host-owned operations for the Playwright provider. No arbitrary code or CDP crosses this boundary. */ -export type { BrowserAutomationProvider } from 'dor/commands/types'; -export type PlaywrightRequest = { binaryPath?: string; cwd?: string } & ( - | { op: 'open'; url: string; headed?: boolean; session?: string } +/** + * The one webview → host channel for browser automation + * (docs/specs/dor-browser.md → "Browser Host"): a typed, + * provider-tagged request the host validates once and turns into fixed + * operations — no CLI argv, script or CDP method crosses it. + */ +import type { BrowserAutomationProvider, BrowserBinding } from 'dor-lib-common/browser-providers'; + +export type { BrowserAutomationProvider }; +export { BROWSER_REQUEST_TIMEOUT_MS } from 'dor-lib-common/browser-providers'; + +/** What a request names its browser by. Only a `launch` may omit the session, + * and the host then mints one. */ +export type BrowserRequestBinding = Partial<BrowserBinding>; + +/** A native editing operation the stream's input path cannot dispatch + * (CDP drops the `commands` field on macOS). The host owns the script for + * each; the webview only names one. */ +export type BrowserEditOp = 'selectAll' | 'copy' | 'cut'; + +/** One operation on a provider's browser. */ +export type BrowserOp = + /** Open `url` — blank when it is not http(s) — in a new session, or in the + * named one: navigating it when it is up in the mode asked for, else + * relaunching it headed or headless. Answers once the browser is up, never + * waiting for the page. */ + | { op: 'launch'; url?: string; headed: boolean; requestId?: string } + /** Where the session streams now, found without starting a browser; one + * that is gone relaunches at `url` when the caller names one. */ + | { op: 'attach'; url?: string; headed?: boolean; requestId?: string } + /** The URL the webview connects to for a stream port. */ | { op: 'streamUrl'; port: number } - | { op: 'command'; session: string; args: string[] } - | { op: 'edit'; session: string; edit: 'selectAll' | 'copy' | 'cut' } - | { op: 'screenshot'; session: string; format?: 'jpeg' | 'png'; quality?: number } - | { op: 'attach'; session: string; url?: string; headed?: boolean } - | { op: 'popOut' | 'popIn'; session: string; url?: string } -); -export interface PlaywrightResult { - headed?: boolean; - nativeIdentity?: string; + /** One device-resolution frame. */ + | { op: 'screenshot'; format?: 'jpeg' | 'png'; quality?: number } + | { op: 'edit'; edit: BrowserEditOp } + | { op: 'navigate'; url: string } + | { op: 'history'; dir: 'back' | 'forward' | 'reload' } + | { op: 'tab'; action: 'select' | 'close'; tabId: string } + | { op: 'viewport'; width: number; height: number; dpr: number } + | { op: 'device'; name: string } + /** agent-browser only: the browser's CDP endpoint, for the popped-out URL + * observer. */ + | { op: 'cdpUrl' } + /** Close the session — after the launch or attach of it running now — and + * cancel `cancels`: requests the closing Surface sent that can bring the + * browser up, by their `requestId`, however late the transport delivers + * them. */ + | { op: 'close'; cancels?: string[] }; + +export type BrowserRequest = { provider: BrowserAutomationProvider; binding: BrowserRequestBinding } & BrowserOp; + +export interface BrowserResult { ok: boolean; error?: string; - exitCode?: number; - stdout?: string; - stderr?: string; - text?: string; + /** `launch` / `attach`: what the browser is bound to — the session (minted + * or named), the directory and executable the host ran it with, and the + * provider's native identity for it. */ session?: string; cwd?: string; binaryPath?: string; + nativeIdentity?: string; + /** `launch` / `attach`: the stream port, and whether the browser runs + * headed, when the host knows. */ wsPort?: number; - /** `attach` only: see `AgentBrowserAttachResult`. */ + headed?: boolean; + /** `attach`: the session was gone, and the host started a browser at the + * caller's `url`, so that page is already open. */ relaunched?: boolean; + /** `streamUrl` / `cdpUrl`. */ url?: string; + /** `edit`: the text copy/cut placed on the OS clipboard. */ + text?: string; + /** `screenshot`: the image bytes, or — to the standalone sidecar's caller — + * the private file holding them. */ bytes?: Uint8Array; path?: string; mime?: string; } -/** How long the webview waits for any Playwright host request before its - * transport gives up. The host bounds a GUI launch to answer inside it, and - * every transport waits exactly this long (VS Code's `requestResponse`, the - * Tauri `playwright_request` command, the browser-dev harness). */ -export const PLAYWRIGHT_REQUEST_TIMEOUT_MS = 40_000; + +/** The most requests one `close` may cancel; the host refuses a longer list. */ +export const BROWSER_CLOSE_MAX_CANCELS = 32; /** The most characters one Playwright viewer `input_text` message carries. A * paste takes as many messages as it needs, each under the viewer socket's @@ -61,8 +106,14 @@ export function playwrightTextInputs(text: string): { type: 'input_text'; text: return messages; } +/** Whether a tab shows nothing: empty, or the blank page a launch can leave. */ +export function isBlankUrl(url: string): boolean { + const trimmed = url.trim(); + return trimmed === '' || trimmed === 'about:blank'; +} + /** A URL a browser provider may launch, relaunch or navigate to: http(s) only, - * untrimmed. The hosts refuse anything else (`parseWebviewCommand`), so the + * untrimmed. The hosts refuse anything else (`parseBrowserRequest`), so the * webview must never offer one — a relaunch would land on about:blank. */ export function isBrowsableUrl(value: unknown): value is string { if (typeof value !== 'string' || value !== value.trim()) return false; diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 0f758b14f..6f5b9f13d 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -1,4 +1,4 @@ -import type { PlaywrightRequest, PlaywrightResult } from './browser-automation'; +import type { BrowserAutomationProvider, BrowserRequest, BrowserResult } from './browser-automation'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; import type { AlertState, AwaitHandle, AwaitOptions, Engagement, EngagementLapse } from '../alert-manager'; import type { AlertSettings } from '../alert-settings'; @@ -89,79 +89,8 @@ export function openPortRequestTimeoutMs(count: number, hops = 1): number { export type AlertStateDetail = { id: string } & AlertState; -export interface AgentBrowserCommandResult { - exitCode: number; - stdout: string; - stderr: string; -} - -export interface AgentBrowserScreenshotResult { - ok: boolean; - /** Raw image bytes (transferred over the host↔webview channel via structured - * clone, so no base64 round-trip); present iff ok. */ - bytes?: Uint8Array; - /** e.g. 'image/jpeg' | 'image/png'. */ - mime?: string; - error?: string; -} - -/** Native editing operations that the stream's input_keyboard path cannot - * trigger on macOS (CDP drops the `commands` field — see - * docs/specs/dor-browser.md and the upstream issue). The host owns the - * exact JS for each; the webview only picks one of these names, so this stays - * a purpose-built channel rather than an arbitrary-eval one. */ -export type AgentBrowserEditOp = 'selectAll' | 'copy' | 'cut'; - -export interface AgentBrowserEditResult { - ok: boolean; - /** Text the host placed on the OS clipboard (copy/cut); omitted for selectAll. */ - text?: string; - error?: string; -} - export type { IframeProxyResult }; -/** Result of attaching to a session's live stream (docs/specs/dor-browser.md → - * "Agent-Browser Connection"): the port its stream serves now, found without - * starting a daemon — or, when the caller named a page and the session is gone, - * the port of the browser relaunched there. */ -export interface AgentBrowserAttachResult { - headed?: boolean; - nativeIdentity?: string; - ok: boolean; - wsPort?: number; - /** The session was gone, and the host started a browser at the caller's - * `url`: that page is already open. */ - relaunched?: boolean; - error?: string; -} - -/** Result of spawning a managed agent-browser session for a render swap - * (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). */ -export interface AgentBrowserOpenResult { - nativeIdentity?: string; - cwd?: string; - ok: boolean; - /** The resolved/namespaced session name the new surface should bind to. */ - session?: string; - /** The session's stream WebSocket port. */ - wsPort?: number; - /** The binary path the host resolved, threaded back so later host commands - * (close, screenshot…) reuse it. */ - binaryPath?: string; - error?: string; -} - -/** Result of a headed/headless relaunch (docs/specs/dor-browser.md → - * "Pop-Out"). The Chrome process is replaced, so the stream port - * changes; the session name is preserved. */ -export interface AgentBrowserPopResult { - ok: boolean; - /** The new stream WebSocket port after the relaunch. */ - wsPort?: number; - error?: string; -} - /** * The webview end of a Node-resident Burrow * (`lib/src/host/remote/service-protocol.ts`). @@ -245,7 +174,6 @@ export interface WritePtyOptions { } export interface PlatformAdapter { - playwright?(request: PlaywrightRequest): Promise<PlaywrightResult>; // Lifecycle init(): Promise<void>; shutdown(): void; @@ -360,34 +288,12 @@ export interface PlatformAdapter { // VS Code-only escape hatch for mirrored workbench shortcuts from webviews. runWorkbenchCommand?(command: VSCodeWorkbenchCommand): void; - // agent-browser surface support (see docs/specs/dor-browser.md). - // Runs the user's agent-browser binary against a session — only commands - // `parseWebviewCommand` (lib/src/host/browser-host-shared.ts) accepts, never a - // general exec path. `binaryPath` is the - // absolute path resolved by `dor ab` in the invoking terminal — the host's - // own PATH (e.g. a GUI-launched extension host) may not find the binary. - agentBrowserCommand?(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult>; - // Performs a native editing operation (select-all/copy/cut) the stream input - // path can't, via the daemon's CDP-backed eval. The host owns the JS and, - // for copy/cut, writes the result to the OS clipboard. Absent on hosts that - // can't run the binary (degrades to plain key forwarding). - agentBrowserEdit?(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult>; - // Captures a single device-resolution (HiDPI) frame via the user's - // agent-browser `screenshot` command and returns the raw image bytes. The - // stream's screencast is CSS-resolution only (a Chromium limitation — - // Page.startScreencast ignores deviceScaleFactor), so the panel settles on - // these crisp screenshots, painting stream frames provisionally for latency. - // Absent on hosts that can't run the binary — the panel then keeps every - // changed stream frame as its final, lower-resolution image. - agentBrowserScreenshot?(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult>; - // The session's live stream port, read without spawning anything — a CLI - // verb would start a daemon to answer. With `url`, a session whose daemon is - // gone is relaunched there (headed with `headed`); without, it fails. - agentBrowserAttach?(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise<AgentBrowserAttachResult>; - // The WebSocket URL for a session's stream port. Hosts whose webview origin - // the agent-browser stream server rejects (VS Code) return a tokenized relay - // URL; absent or null falls back to ws://127.0.0.1:<port>. - getAgentBrowserStreamUrl?(port: number): Promise<string | null>; + // Browser automation (docs/specs/dor-browser.md → "Browser Host"): the + // providers this host can drive, and the one typed + // request every browser operation of theirs rides. Both present or both + // absent; a host without them (the web demo) offers no automated renderer. + browserProviders?: readonly BrowserAutomationProvider[]; + browser?(request: BrowserRequest): Promise<BrowserResult>; // iframe surface support (see docs/specs/dor-browser.md → "Iframe // Renderer"). Stands up a loopback proxy in front of a `dor iframe` target and @@ -409,27 +315,6 @@ export interface PlatformAdapter { // filesystem, which names every Workspace by directory. gitInfo?(paths: string[]): Promise<GitInfoResult>; - // Render-swap support (docs/specs/dor-browser.md → "Display Modal And Render Swaps"; - // docs/specs/dor-browser.md → "Pop-Out"). All optional - // so hosts degrade: the modal hides whatever isn't backed by a capability. - // - // Open <url> in a new managed session, or in `session` when the caller names - // one — the launch behind every GUI-created browser Surface, headless or - // straight into a popped-out window (`headed: true`, so embed→popout is one - // spawn, not a headless launch immediately torn down). Resolves once the - // browser is up, never waiting for the page. `binaryPath` is the last one a - // `dor ab` surface resolved (a GUI-launched host's own PATH may miss the - // binary); the host falls back to PATH / DORMOUSE_AGENT_BROWSER_BIN. - agentBrowserOpen?(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult>; - // Relaunch a session's browser headed as a native OS window, reopening `url` - // (headed/headless is fixed at launch, so this is a close+relaunch — v1 - // preserves the active tab URL). Best-effort positioned over `rect` (CSS px - // in screen space). Returns the new stream port. Absent ⇒ pop-out hidden. - agentBrowserPopOut?(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult>; - // Relaunch headless (pop back in) reopening `url`, resuming the screencast; - // returns the new stream port. Pairs with agentBrowserPopOut. - agentBrowserPopIn?(session: string, opts: { url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult>; - // PTY event listeners onPtyData(handler: (detail: PtyDataDetail) => void): void; offPtyData(handler: (detail: PtyDataDetail) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index fc69649c8..25d0ebc8c 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -37,6 +37,7 @@ import { HOST_MESSAGE_TOKEN_FIELD, HOST_MESSAGE_TOKEN_GLOBAL } from '../vscode-m import { NOTEPAD_VOLATILE_GLOBAL } from '../vscode-notepad-global'; import type { NotepadArchiveV1, VolatileNotepadSnapshot } from '../notepad/types'; import { VSCodeAdapter } from './vscode-adapter'; +import { BROWSER_REQUEST_TIMEOUT_MS } from './browser-automation'; /** Stand-in for the per-boot token the extension host injects at webview boot. */ const BURROW_TOKEN = 'test-host-message-token'; @@ -455,7 +456,7 @@ describe('VSCodeAdapter PTY exit handling', () => { // (docs/specs/notepad.md). What is covered here is what this transport adds: the // compare-and-swap shape on the wire, failures that reject rather than resolve, // and the boot mirror being consumable exactly once. -describe('VSCodeAdapter agent-browser replies', () => { +describe('VSCodeAdapter browser requests', () => { beforeEach(stubWebviewEnv); afterEach(() => { vi.useRealTimers(); @@ -463,34 +464,40 @@ describe('VSCodeAdapter agent-browser replies', () => { vi.clearAllMocks(); }); + it('declares both providers', () => { + expect(new VSCodeAdapter().browserProviders).toEqual(['agent-browser', 'playwright']); + }); + // A capture queued behind a page-loading `open` answers only after the - // CLI's 25s action timeout; giving up sooner makes the webview ask again. + // CLI's 25s action timeout, and a launch the host bounds to answer inside + // the same wait; giving up sooner makes the webview ask again. it('waits out a daemon command held behind a page load', async () => { vi.useFakeTimers(); const adapter = new VSCodeAdapter(); + const binding = { session: 'sess' }; for (const request of [ - () => adapter.agentBrowserScreenshot('sess', { format: 'jpeg' }), - () => adapter.agentBrowserCommand('sess', ['reload']), - () => adapter.agentBrowserEdit('sess', 'copy'), + () => adapter.browser({ provider: 'agent-browser', binding, op: 'screenshot', format: 'jpeg' }), + () => adapter.browser({ provider: 'agent-browser', binding, op: 'history', dir: 'reload' }), + () => adapter.browser({ provider: 'agent-browser', binding, op: 'edit', edit: 'copy' }), ]) { let settled: unknown; void request().then((result) => { settled = result; }); - await vi.advanceTimersByTimeAsync(26_000); + await vi.advanceTimersByTimeAsync(BROWSER_REQUEST_TIMEOUT_MS - 1); expect(settled).toBeUndefined(); - await vi.advanceTimersByTimeAsync(4_000); - expect(JSON.stringify(settled)).toMatch(/timed out/); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toEqual({ ok: false, error: expect.stringMatching(/timed out/) }); } }); - it('keeps everything an attach answers with', async () => { + it('carries the typed request out and keeps everything the host answers with', async () => { const adapter = new VSCodeAdapter(); - const attached = adapter.agentBrowserAttach('sess', { url: 'https://example.com/' }); - const request = postMessage.mock.calls.map(([message]) => message).find((message) => message.type === 'agentBrowser:attach'); - expect(request).toMatchObject({ session: 'sess', url: 'https://example.com/' }); + const attached = adapter.browser({ provider: 'agent-browser', binding: { session: 'sess' }, op: 'attach', url: 'https://example.com/' }); + const request = postMessage.mock.calls.map(([message]) => message).find((message) => message.type === 'browser:request'); + expect(request.request).toEqual({ provider: 'agent-browser', binding: { session: 'sess' }, op: 'attach', url: 'https://example.com/' }); windowTarget.dispatchEvent(hostMessage({ - type: 'agentBrowser:attachResult', requestId: request.requestId, ok: true, wsPort: 4321, relaunched: true, headed: true, nativeIdentity: 'id', + type: 'browser:result', requestId: request.requestId, result: { ok: true, wsPort: 4321, relaunched: true, headed: true, nativeIdentity: 'id' }, })); - expect(await attached).toEqual({ ok: true, wsPort: 4321, relaunched: true, headed: true, nativeIdentity: 'id', error: undefined }); + expect(await attached).toEqual({ ok: true, wsPort: 4321, relaunched: true, headed: true, nativeIdentity: 'id' }); }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 2eb67f1c5..83b59f4a3 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,7 +1,8 @@ -import { PLAYWRIGHT_REQUEST_TIMEOUT_MS, type PlaywrightRequest, type PlaywrightResult } from './browser-automation'; +import { BROWSER_PROVIDER_IDS } from 'dor-lib-common/browser-providers'; +import { BROWSER_REQUEST_TIMEOUT_MS, type BrowserRequest, type BrowserResult } from './browser-automation'; import { recordToolEvents } from '../tool-events'; import type { TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; -import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserAttachResult, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, SpawnPtyOptions, ToolControlResult, ToolHostRequest, WritePtyOptions } from './types'; +import type { IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, SpawnPtyOptions, ToolControlResult, ToolHostRequest, WritePtyOptions } from './types'; import { openPortRequestTimeoutMs } from './types'; import { createBurrowLinkClient } from '../../host/remote/link-client'; import { createAlertClient, type AlertClientMethods } from '../../host/alert-client'; @@ -35,11 +36,6 @@ const DETACHED = Symbol('detached'); /** The `alert*` platform methods, taken from the shared client in the constructor. */ export interface VSCodeAdapter extends AlertClientMethods {} -// An agent-browser command, edit or capture can queue behind a page-loading -// `open` for the CLI's whole 25s action timeout. The reply waits past that, as -// the standalone host's does, so the webview never re-asks while the extension -// host is still working on the first request. -const AGENT_BROWSER_REPLY_TIMEOUT_MS = 30_000; export class VSCodeAdapter implements PlatformAdapter { // VS Code owns the theme here: it provides --vscode-* itself and has its own @@ -123,18 +119,8 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode = acquireVsCodeApi(); Object.assign(this, this.alerts.methods); - // These get called through detached references in the agent-browser panel - // (e.g. `getPlatform().agentBrowserScreenshot`), which would otherwise drop - // `this` and throw on the internal `requestResponse`. Bind them once so any - // call style is safe. - this.agentBrowserCommand = this.agentBrowserCommand.bind(this); - this.agentBrowserEdit = this.agentBrowserEdit.bind(this); - this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this); - this.agentBrowserAttach = this.agentBrowserAttach.bind(this); - this.getAgentBrowserStreamUrl = this.getAgentBrowserStreamUrl.bind(this); - this.agentBrowserOpen = this.agentBrowserOpen.bind(this); - this.agentBrowserPopOut = this.agentBrowserPopOut.bind(this); - this.agentBrowserPopIn = this.agentBrowserPopIn.bind(this); + // Called through a detached reference, which would otherwise drop `this` + // and throw on the internal `requestResponse`. this.createIframeProxyUrl = this.createIframeProxyUrl.bind(this); // Seed the default shell from the extension-injected global so that @@ -380,86 +366,12 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'dormouse:runWorkbenchCommand', command }); } - async playwright(request: PlaywrightRequest): Promise<PlaywrightResult> { - return await this.requestResponse<PlaywrightResult>( - 'playwright:request', 'playwright:result', { request }, msg => msg.result, PLAYWRIGHT_REQUEST_TIMEOUT_MS, - ) ?? { ok: false, error: 'Playwright host timed out' }; - } - - async agentBrowserCommand(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { - const result = await this.requestResponse<AgentBrowserCommandResult>( - 'agentBrowser:command', 'agentBrowser:commandResult', { session, args, binaryPath }, - (msg) => ({ exitCode: msg.exitCode, stdout: msg.stdout, stderr: msg.stderr }), - AGENT_BROWSER_REPLY_TIMEOUT_MS, - ); - return result ?? { exitCode: 1, stdout: '', stderr: 'agent-browser command timed out' }; - } - - async agentBrowserEdit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult> { - const result = await this.requestResponse<AgentBrowserEditResult>( - 'agentBrowser:edit', 'agentBrowser:editResult', { session, op, binaryPath }, - (msg) => ({ ok: msg.ok, text: msg.text, error: msg.error }), - AGENT_BROWSER_REPLY_TIMEOUT_MS, - ); - return result ?? { ok: false, error: 'agent-browser edit timed out' }; - } - - async agentBrowserScreenshot(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult> { - const result = await this.requestResponse<AgentBrowserScreenshotResult>( - 'agentBrowser:screenshot', 'agentBrowser:screenshotResult', - { session, format: opts.format, quality: opts.quality, binaryPath }, - (msg) => ({ ok: msg.ok, bytes: msg.bytes, mime: msg.mime, error: msg.error }), - AGENT_BROWSER_REPLY_TIMEOUT_MS, - ); - return result ?? { ok: false, error: 'agent-browser screenshot timed out' }; - } - - async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise<AgentBrowserAttachResult> { - // A gone session relaunches, so this waits as long as an open does. - const result = await this.requestResponse<AgentBrowserAttachResult>( - 'agentBrowser:attach', 'agentBrowser:attachResult', - { session, url: opts.url, headed: opts.headed, binaryPath }, - (msg) => ({ ok: msg.ok, wsPort: msg.wsPort, relaunched: msg.relaunched, headed: msg.headed, nativeIdentity: msg.nativeIdentity, error: msg.error }), - 15000, - ); - return result ?? { ok: false, error: 'agent-browser attach timed out' }; - } + readonly browserProviders = BROWSER_PROVIDER_IDS; - getAgentBrowserStreamUrl(port: number): Promise<string | null> { - // The agent-browser stream server rejects vscode-webview:// origins, so - // the extension host relays the stream (see agent-browser-host.ts). - return this.requestResponse<string | null>( - 'agentBrowser:getStreamUrl', 'agentBrowser:streamUrl', { port }, - (msg) => msg.url, - 5000, - ); - } - - async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult> { - const result = await this.requestResponse<AgentBrowserOpenResult>( - 'agentBrowser:open', 'agentBrowser:openResult', { url, headed: opts.headed, session: opts.session, binaryPath }, - (msg) => ({ ok: msg.ok, session: msg.session, wsPort: msg.wsPort, binaryPath: msg.binaryPath, error: msg.error }), - 15000, - ); - return result ?? { ok: false, error: 'agent-browser open timed out' }; - } - - async agentBrowserPopOut(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - const result = await this.requestResponse<AgentBrowserPopResult>( - 'agentBrowser:popOut', 'agentBrowser:popResult', { session, url: opts.url, rect: opts.rect, binaryPath }, - (msg) => ({ ok: msg.ok, wsPort: msg.wsPort, error: msg.error }), - 15000, - ); - return result ?? { ok: false, error: 'agent-browser pop-out timed out' }; - } - - async agentBrowserPopIn(session: string, opts: { url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - const result = await this.requestResponse<AgentBrowserPopResult>( - 'agentBrowser:popIn', 'agentBrowser:popResult', { session, url: opts.url, binaryPath }, - (msg) => ({ ok: msg.ok, wsPort: msg.wsPort, error: msg.error }), - 15000, - ); - return result ?? { ok: false, error: 'agent-browser pop-in timed out' }; + async browser(request: BrowserRequest): Promise<BrowserResult> { + return await this.requestResponse<BrowserResult>( + 'browser:request', 'browser:result', { request }, (msg) => msg.result, BROWSER_REQUEST_TIMEOUT_MS, + ) ?? { ok: false, error: 'the browser host timed out' }; } async toolControl(request: ToolHostRequest): Promise<ToolControlResult> { diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 4d1eca144..394d84c7a 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -107,7 +107,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth, paneHeigh scan={scenario === 'scanFailed' ? { status: 'failed' } : { status: 'loaded', entries: scenario === 'noPorts' ? [] : ports }} watchRule="pnpm" watching={watching} todo={todo} notification={scenario === 'notification' ? { title: 'Tests complete', body: '341 passed, 0 failed' } : null} status={preserved ? 'preserved' : scenario === 'running' ? 'running' : scenario === 'autorunOff' ? 'off' : 'completed'} command={command} - explorerLabel="Open in Finder" canExplore canAgent canIframe initialDetail={initialDetail} + explorerLabel="Open in Finder" canExplore browserProviders={['agent-browser']} canIframe initialDetail={initialDetail} notepadAction={<NotepadHeaderButton surfaceId="context-gallery" />} notepadPanel={<NotepadPanel surfaceId="context-gallery" pins={false} />} onClose={() => {}} onCopyRef={() => {}} onCopyPath={() => {}} onExplore={() => {}} onPort={() => {}} onWatch={() => setWatching(!watching)} onTodo={() => setTodo(!todo)} onModify={async value => setCommand(value)} onReset={async () => {}} onPromote={async () => {}}> diff --git a/lib/tsconfig.app.json b/lib/tsconfig.app.json index ba93b569b..29d3e4528 100644 --- a/lib/tsconfig.app.json +++ b/lib/tsconfig.app.json @@ -24,5 +24,5 @@ // The pure rewrite helpers typecheck here (DOM provides URL); the Node proxy // server is esbuild-only (bundled per host), like the rest of our host code. // The test utils import vitest, so they live with the tests, not the app. - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/components/wall/wall-test-utils.ts", "src/lib/alert-manager-test-utils.ts", "src/host/iframe-proxy.ts", "src/host/agent-browser-host.ts", "src/host/playwright-host.ts", "src/host/playwright-install.ts", "src/host/browser-stream-guard.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/components/wall/wall-test-utils.ts", "src/lib/alert-manager-test-utils.ts", "src/host/iframe-proxy.ts", "src/host/agent-browser-host.ts", "src/host/browser-host.ts", "src/host/browser-host-test-utils.ts", "src/host/playwright-host.ts", "src/host/playwright-install.ts", "src/host/browser-stream-guard.ts"] } diff --git a/lib/vite.config.ts b/lib/vite.config.ts index 59fd1c7e2..7ab6226bd 100644 --- a/lib/vite.config.ts +++ b/lib/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ // path; Vite (and vitest) do not read tsconfig paths, and `dor` has no // package exports, so resolve it to source — the same alias standalone uses. dor: path.resolve(import.meta.dirname, "../dor/src"), - // `connect-port.ts` imports `dor-lib-common/agent-browser`; that package's + // `connect-port.ts` imports `dor-lib-common/browser-providers`; that package's // `exports` resolve to a `dist` a vitest run has no reason to have built. // Alias to source so the tests never depend on build order. "dor-lib-common": path.resolve(import.meta.dirname, "../dor-lib-common/src"), diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 683611bdb..39eed55b4 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -5,7 +5,7 @@ "docs/specs/alert.md": 8150, "docs/specs/auto-update.md": 1150, "docs/specs/deploy.md": 1900, - "docs/specs/dor-browser.md": 6500, + "docs/specs/dor-browser.md": 7250, "docs/specs/dor-cli.md": 6300, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index ff214ad9e..1e335339d 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -2,6 +2,7 @@ // into CommonJS files the Node sidecar can require. Keeps each as a single // TypeScript source while the sidecar itself stays plain CJS. // - lib/src/host/iframe-proxy.ts → sidecar/iframe-proxy.cjs +// - lib/src/host/browser-host.ts → sidecar/browser-host.cjs // - lib/src/host/agent-browser-host.ts → sidecar/agent-browser-host.cjs // - lib/src/host/playwright-host.ts → sidecar/playwright-host.cjs // - lib/src/host/tool-host.ts → sidecar/tool-host.cjs @@ -60,6 +61,7 @@ if (!SIDECAR_RUNTIME_DEPS.includes('node-datachannel')) { const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, + { entry: 'browser-host.ts', out: 'browser-host.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, { entry: 'playwright-host.ts', out: 'playwright-host.cjs' }, { entry: 'tool-host.ts', out: 'tool-host.cjs' }, diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 0d09596aa..94a8ca592 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -2,9 +2,9 @@ import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { readFile, unlink } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { sessionForKey } from 'dor-lib-common/browser-providers'; // cross-spawn, not node:child_process: this script spawns `dor` and // `agent-browser`, which are `.cmd` shims on Windows that a bare-name spawn // can't resolve (ENOENT) and Node >=22 won't run directly (EINVAL). cross-spawn @@ -128,22 +128,20 @@ const fireAndForget = { // stdio pipe). Production reads that file in Rust; this dev bridge has no Rust, // so read it in Node and re-encode to the base64 the browser-sidecar adapter // expects — the base64 travels in the HTTP invoke response, outside the event -// stream. `remove` mirrors Rust deleting Playwright's one-shot frame files; -// agent-browser reuses one file per session. -async function readCapture(result, { remove = false } = {}) { +// stream. Like Rust, it deletes the file once read: each capture is its own. +async function readCapture(result) { if (!result?.ok || typeof result.path !== 'string') return result; try { return { ok: true, mime: result.mime, bytesBase64: (await readFile(result.path)).toString('base64') }; } finally { - if (remove) await unlink(result.path).catch(() => {}); + await rm(result.path, { force: true }); } } const invokeMap = { - // PLAYWRIGHT_REQUEST_TIMEOUT_MS in lib/src/lib/platform/browser-automation.ts. - playwright_request: async ({ request }) => readCapture( - await requestSidecar('playwright:request', { request }, 'agentBrowser:result', (data) => data.result, 40000), - { remove: true }, + // BROWSER_REQUEST_TIMEOUT_MS in dor-lib-common/src/browser-providers.ts. + browser_request: async ({ request }) => readCapture( + await requestSidecar('browser:request', { request }, 'browser:result', (data) => data.result, 40000), ), get_available_shells: (_args) => requestSidecar('pty:getShells', {}, 'pty:shells', (data) => data.shells ?? []), pty_get_cwd: ({ id }) => requestSidecar('pty:getCwd', { id }, 'pty:cwd', (data) => data.cwd ?? null), @@ -155,19 +153,10 @@ const invokeMap = { read_clipboard_image_as_file_path: () => requestSidecar('clipboard:readImage', {}, 'clipboard:image', (data) => data.path ?? null), read_clipboard_text: () => requestSidecar('clipboard:readText', {}, 'clipboard:text', (data) => data.text ?? null), iframe_create_proxy_url: ({ target, embedderOrigins }) => requestSidecar('iframe:createProxyUrl', { target, embedderOrigins }, 'iframe:proxyUrl', (data) => data.result), - agent_browser_command: ({ session, args, binaryPath }) => requestSidecar('agentBrowser:command', { session, args, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), - agent_browser_edit: ({ session, op, binaryPath }) => requestSidecar('agentBrowser:edit', { session, op, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), - agent_browser_screenshot: async ({ session, format, quality, binaryPath }) => readCapture( - await requestSidecar('agentBrowser:screenshot', { session, format, quality, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), - ), - agent_browser_attach: ({ session, url, headed, binaryPath }) => requestSidecar('agentBrowser:attach', { session, url, headed, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), tool_control: ({ request }) => requestSidecar('tool:control', { request }, 'tool:result', (data) => data.result), git_info: ({ paths }) => requestSidecar('git:info', { paths }, 'git:infoResult', (data) => data.result), - agent_browser_open: ({ url, headed, session, binaryPath }) => requestSidecar('agentBrowser:open', { url, headed, session, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), - agent_browser_pop_out: ({ session, url, rect, binaryPath }) => requestSidecar('agentBrowser:popOut', { session, url, rect, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), - agent_browser_pop_in: ({ session, url, binaryPath }) => requestSidecar('agentBrowser:popIn', { session, url, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), // Agent recovery (docs/specs/standalone.md -> "Agent recovery"). The harness // mirrors the persistence answer, so it claims exactly as Rust does, over the // identical sidecar half. There is no `capture_agent_recovery` here: capture diff --git a/standalone/scripts/dev-agent-browser.test.mjs b/standalone/scripts/dev-agent-browser.test.mjs index 169818ce9..7d196d0c6 100644 --- a/standalone/scripts/dev-agent-browser.test.mjs +++ b/standalone/scripts/dev-agent-browser.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { get } from 'node:http'; import { setTimeout as delay } from 'node:timers/promises'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { sessionForKey } from 'dor-lib-common/browser-providers'; import { cleanEnv, devWorkspace, runner, writeShims } from './dev-fixture.mjs'; const scripts = path.dirname(fileURLToPath(import.meta.url)); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 245e6f445..ed8cc7f0c 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -16,10 +16,12 @@ const { createDorControlServer } = require('./dor-control-server'); const { createIframeProxyUrl } = require('./iframe-proxy.cjs'); const { createToolHost } = require('./tool-host.cjs'); const { gitInfo } = require('./git-info.cjs'); -// Same pattern: lib/src/host/agent-browser-host.ts is the single source of truth -// for the agent-browser host capabilities, run here exactly as the VS Code -// extension host runs it. See docs/specs/dor-browser.md → "Agent-Browser Host Capabilities". -const { createAgentBrowserHost } = require('./agent-browser-host.cjs'); +// Same pattern: lib/src/host/browser-host.ts is the single source of truth for +// browser automation, run here exactly as the VS Code extension host runs it, +// over the providers in lib/src/host/agent-browser-host.ts and +// playwright-host.ts. See docs/specs/dor-browser.md → "Browser Host". +const { createBrowserHost } = require('./browser-host.cjs'); +const { createAgentBrowserProvider } = require('./agent-browser-host.cjs'); // Same pattern again: lib/src/host/remote/sidecar-entry.ts is the Burrow — // the relay socket, the enrollment, the ACL, and remote-api v1 — running next to // the PTYs it serves (docs/specs/remote-api.md), and the app's one @@ -32,24 +34,18 @@ const { createSidecarHost } = require('./burrow.cjs'); // store. See docs/specs/standalone.md -> "Agent recovery". const { captureAgentRecovery, createRecoveryStore, sliceSince } = require('./recovery.cjs'); -const agentBrowser = createAgentBrowserHost({ +const browserLog = (m) => console.error(m); +const browserHost = createBrowserHost({ writeClipboardText: (text) => clipboard.writeClipboardText(text), - log: (m) => console.error(m), + log: browserLog, + providers: { + 'agent-browser': () => createAgentBrowserProvider({ log: browserLog }), + // Required on the first Playwright request rather than at boot: its bundle + // carries `ws`, and most sessions never open a Playwright pane. + playwright: () => require('./playwright-host.cjs').createPlaywrightProvider({ log: browserLog }), + }, }); -// Same pattern again: lib/src/host/playwright-host.ts backs the Playwright -// renderer (docs/specs/dor-browser.md → "Playwright Renderer"). Required on the -// first request rather than at boot: the bundle carries `ws`, and most sessions -// never open a Playwright pane. Shutdown skips it while it is still undefined. -let playwright; -function playwrightHost() { - playwright ??= require('./playwright-host.cjs').createPlaywrightHost({ - writeClipboardText: (text) => clipboard.writeClipboardText(text), - log: (m) => console.error(m), - }); - return playwright; -} - function send(event, data) { process.stdout.write(JSON.stringify({ event, data }) + '\n'); } @@ -218,53 +214,14 @@ function handleLine(line) { }), })); break; - // Screenshots answer with a temp-file path, like agentBrowser:screenshot below. - case 'playwright:request': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await playwrightHost().requestFile(data.request), - })); - break; - case 'agentBrowser:command': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.command(data.session, data.args, data.binaryPath), - })); - break; - case 'agentBrowser:edit': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.edit(data.session, data.op, data.binaryPath), - })); - break; - case 'agentBrowser:screenshot': - // Return the temp-file PATH, not the bytes: a ~100-700KB base64 line would - // otherwise ride the JSON-lines stdio pipe shared with all PTY traffic - // (head-of-line blocking terminal output on every frame). Rust reads the - // file itself and returns a raw tauri::ipc::Response for the webview. - respondAsync('agentBrowser:result', data.requestId, async () => { - const shot = await agentBrowser.screenshotToFile( - data.session, { format: data.format, quality: data.quality }, data.binaryPath, - ); - if (!shot.ok) return { result: { ok: false, error: shot.error } }; - return { result: { ok: true, mime: shot.mime, path: shot.path } }; - }); - break; - case 'agentBrowser:attach': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.attach(data.session, { url: data.url, headed: data.headed }, data.binaryPath), - })); - break; - case 'agentBrowser:open': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.open(data.url, { headed: data.headed, session: data.session }, data.binaryPath), - })); - break; - case 'agentBrowser:popOut': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.popOut(data.session, { url: data.url, rect: data.rect }, data.binaryPath), - })); - break; - case 'agentBrowser:popIn': - respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.popIn(data.session, { url: data.url }, data.binaryPath), + case 'browser:request': + // A screenshot answers with its temp-file PATH, not the bytes: a + // ~100-700KB base64 line would otherwise ride the JSON-lines stdio pipe + // shared with all PTY traffic (head-of-line blocking terminal output on + // every frame). Rust reads the file itself and returns a raw + // tauri::ipc::Response for the webview. + respondAsync('browser:result', data.requestId, async () => ({ + result: await browserHost.requestFile(data.request), })); break; case 'clipboard:readFiles': @@ -298,7 +255,7 @@ async function shutdown() { // can't wedge the exit; mirrors the VS Code host's deactivate(). try { await Promise.race([ - Promise.allSettled([agentBrowser.closePoppedOut(), playwright?.close()]), + browserHost.close(), new Promise((resolve) => setTimeout(resolve, 1500).unref?.()), ]); } catch {} diff --git a/standalone/sidecar/shutdown.test.js b/standalone/sidecar/shutdown.test.js index 9dd26efcb..720747777 100644 --- a/standalone/sidecar/shutdown.test.js +++ b/standalone/sidecar/shutdown.test.js @@ -6,30 +6,29 @@ const { runInNewContext } = require('node:vm'); // Exercise the shipped shutdown function without starting PTYs or a sidecar. const source = readFileSync(require.resolve('./main.js'), 'utf8'); const shutdownSource = source.slice(source.indexOf('let shuttingDown = false;'), source.indexOf("rl.on('close', shutdown)")); -function fixture({ playwrightLoaded = true } = {}) { +// The browser host's own close waits for every provider it loaded +// (`createBrowserHost` in lib/src/host/browser-host.ts); here it is one call. +function fixture() { const calls = []; - let closeAgent, closePlaywright, deadline; + let closeBrowsers, deadline; const shutdown = runInNewContext(`${shutdownSource}\nshutdown`, { Promise, - agentBrowser: { closePoppedOut: () => new Promise(resolve => { closeAgent = resolve; }) }, - // The sidecar requires the Playwright host lazily, on its first request. - playwright: playwrightLoaded ? { close: () => new Promise(resolve => { closePlaywright = resolve; }) } : undefined, + browserHost: { close: () => new Promise(resolve => { closeBrowsers = resolve; }) }, setTimeout: callback => { deadline = callback; return { unref() {} }; }, dorControl: { close: () => calls.push('control') }, host: { dispose: () => calls.push('host') }, mgr: { killAll: () => calls.push('ptys') }, process: { exit: () => calls.push('exit') }, }); - return { calls, shutdown, closeAgent: () => closeAgent(), closePlaywright: () => closePlaywright(), deadline: () => deadline() }; + return { calls, shutdown, closeBrowsers: () => closeBrowsers(), deadline: () => deadline() }; } -test('shutdown waits for both browser providers before tearing down the sidecar', async () => { +test('shutdown waits for the browser host before tearing down the sidecar', async () => { const f = fixture(); const done = f.shutdown(); - f.closeAgent(); await new Promise(resolve => setImmediate(resolve)); assert.deepEqual(f.calls, []); - f.closePlaywright(); + f.closeBrowsers(); await done; assert.deepEqual(f.calls, ['control', 'host', 'ptys', 'exit']); await f.shutdown(); @@ -39,18 +38,7 @@ test('shutdown waits for both browser providers before tearing down the sidecar' test('the shared deadline still permits shutdown when a browser provider hangs', async () => { const f = fixture(); const done = f.shutdown(); - f.closePlaywright(); f.deadline(); await done; assert.deepEqual(f.calls, ['control', 'host', 'ptys', 'exit']); }); - -test('shutdown still waits for agent-browser when the Playwright host was never loaded', async () => { - const f = fixture({ playwrightLoaded: false }); - const done = f.shutdown(); - await new Promise(resolve => setImmediate(resolve)); - assert.deepEqual(f.calls, []); - f.closeAgent(); - await done; - assert.deepEqual(f.calls, ['control', 'host', 'ptys', 'exit']); -}); diff --git a/standalone/src-tauri/Cargo.lock b/standalone/src-tauri/Cargo.lock index 150d61841..dd83f98f5 100644 --- a/standalone/src-tauri/Cargo.lock +++ b/standalone/src-tauri/Cargo.lock @@ -103,12 +103,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bit-set" version = "0.8.0" @@ -677,7 +671,6 @@ dependencies = [ name = "dormouse" version = "1.1.0" dependencies = [ - "base64 0.23.1", "getrandom 0.4.3", "objc2", "objc2-app-kit", diff --git a/standalone/src-tauri/Cargo.toml b/standalone/src-tauri/Cargo.toml index bda5c7ac0..cf85d4fa4 100644 --- a/standalone/src-tauri/Cargo.toml +++ b/standalone/src-tauri/Cargo.toml @@ -18,7 +18,6 @@ serde_json = "1" tauri = { version = "2", features = [] } tauri-plugin-shell = "2" tauri-plugin-updater = "2" -base64 = "0.23" getrandom = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 173d9daf8..accb53137 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1,4 +1,3 @@ -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde::{Deserialize, Serialize}; use serde_json::{Map as JsonMap, Value as JsonValue}; mod log_tail; @@ -1004,7 +1003,7 @@ fn request_from_sidecar( /// must be declared `#[tauri::command(async)]` (or be an `async fn`). Tauri runs /// a plain sync command on the **main thread**, where the `recv_timeout` below /// stops the webview from painting for the whole round trip — up to -/// `AGENT_BROWSER_TIMEOUT` (30s) for a hung agent-browser, and a visible ~3s +/// `BROWSER_REQUEST_TIMEOUT` (40s) for a hung browser host, and a visible ~3s /// freeze on a cold `agent-browser open`, which is long enough to look like a /// pane that never appeared. `(async)` moves the same blocking body onto a /// runtime worker, so the UI keeps rendering while the sidecar works. @@ -1485,144 +1484,47 @@ fn tool_control( Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) } -// ── agent-browser host (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). +// ── Browser automation (docs/specs/dor-browser.md → "Browser Host"). // Thin forwarders to the Node sidecar, which runs the shared -// lib/src/host/agent-browser-host.ts — the very same module the VS Code -// extension host runs. Mirrors iframe_create_proxy_url; the logic lives in lib, -// not here, so the two hosts can't drift. ────────────────────────────────────── +// lib/src/host/browser-host.ts — the very same module the VS Code extension +// host runs, and the one place a request is validated. Mirrors +// iframe_create_proxy_url; the logic lives in lib, not here, so the two hosts +// can't drift. ──────────────────────────────────────────────────────────────── -// agent-browser launches Chrome (slow on first run), and pop-out is a -// close + relaunch, so allow a generous window before a forward times out. -const AGENT_BROWSER_TIMEOUT: Duration = Duration::from_secs(30); - -fn agent_browser_forward( - state: &SidecarState, - event: &str, - data: JsonValue, -) -> Result<JsonValue, String> { - let response = request_from_sidecar_timeout(state, event, data, AGENT_BROWSER_TIMEOUT)?; - Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) -} +// BROWSER_REQUEST_TIMEOUT_MS in dor-lib-common/src/browser-providers.ts: the +// host bounds a launch (slow on a first Chrome run, and pop-out is a close + +// relaunch) to answer inside it. +const BROWSER_REQUEST_TIMEOUT: Duration = Duration::from_secs(40); #[tauri::command(async)] -fn playwright_request( +fn browser_request( state: tauri::State<'_, SidecarState>, request: JsonValue, ) -> Result<JsonValue, String> { - // PLAYWRIGHT_REQUEST_TIMEOUT_MS in lib/src/lib/platform/browser-automation.ts: - // the host bounds a GUI launch to answer inside it. let response = request_from_sidecar_timeout( &state, - "playwright:request", + "browser:request", serde_json::json!({ "request": request }), - Duration::from_secs(40), + BROWSER_REQUEST_TIMEOUT, )?; Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) } +// The sidecar answers a screenshot with its temp-file PATH (the bytes never ride +// the JSON-lines stdio shared with PTY traffic). Read the file here and return a +// raw tauri::ipc::Response, so the webview gets an ArrayBuffer (what the panel +// decodes with createImageBitmap). Each capture is a fresh file handed to this +// reader alone, so it is deleted once read: a frame of the user's page must not +// wait on disk for the host's shutdown. #[tauri::command(async)] -fn playwright_screenshot( +fn browser_screenshot( state: tauri::State<'_, SidecarState>, request: JsonValue, ) -> Result<tauri::ipc::Response, String> { if request.get("op").and_then(JsonValue::as_str) != Some("screenshot") { return Err("Expected screenshot operation".to_string()); } - let result = playwright_request(state, request)?; - let path = capture_path(&result)?.ok_or("screenshot returned no path")?; - read_capture(path, true) -} - -#[tauri::command(async)] -fn agent_browser_command( - state: tauri::State<'_, SidecarState>, - session: String, - args: Vec<String>, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:command", - serde_json::json!({ "session": session, "args": args, "binaryPath": binary_path }), - ) -} - -#[tauri::command(async)] -fn agent_browser_edit( - state: tauri::State<'_, SidecarState>, - session: String, - op: String, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:edit", - serde_json::json!({ "session": session, "op": op, "binaryPath": binary_path }), - ) -} - -#[tauri::command(async)] -fn agent_browser_attach( - state: tauri::State<'_, SidecarState>, - session: String, - url: Option<String>, - headed: Option<bool>, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:attach", - serde_json::json!({ "session": session, "url": url, "headed": headed, "binaryPath": binary_path }), - ) -} - -#[tauri::command(async)] -fn agent_browser_open( - state: tauri::State<'_, SidecarState>, - url: String, - headed: Option<bool>, - session: Option<String>, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:open", - serde_json::json!({ "url": url, "headed": headed, "session": session, "binaryPath": binary_path }), - ) -} - -// `rect` is accepted by the adapter but unused — no window positioning today. -#[tauri::command(async)] -fn agent_browser_pop_out( - state: tauri::State<'_, SidecarState>, - session: String, - url: Option<String>, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:popOut", - serde_json::json!({ "session": session, "url": url, "binaryPath": binary_path }), - ) -} - -#[tauri::command(async)] -fn agent_browser_pop_in( - state: tauri::State<'_, SidecarState>, - session: String, - url: Option<String>, - binary_path: Option<String>, -) -> Result<JsonValue, String> { - agent_browser_forward( - &state, - "agentBrowser:popIn", - serde_json::json!({ "session": session, "url": url, "binaryPath": binary_path }), - ) -} - -/// A screenshot result's temp-file path, or its error. `None` is a success -/// that carried no path (a stale sidecar bundle's base64 fallback). -fn capture_path(result: &JsonValue) -> Result<Option<&str>, String> { + let result = browser_request(state, request)?; if result.get("ok").and_then(JsonValue::as_bool) != Some(true) { return Err(result .get("error") @@ -1630,53 +1532,14 @@ fn capture_path(result: &JsonValue) -> Result<Option<&str>, String> { .unwrap_or("screenshot failed") .to_string()); } - Ok(result.get("path").and_then(JsonValue::as_str)) -} - -/// Read a screenshot's temp file into a raw response. `remove` deletes a -/// one-shot Playwright frame; agent-browser reuses one file per session, which -/// the next capture overwrites and shutdown removes. -fn read_capture(path: &str, remove: bool) -> Result<tauri::ipc::Response, String> { - let bytes = std::fs::read(path); - if remove { - let _ = std::fs::remove_file(path); - } - bytes - .map(tauri::ipc::Response::new) - .map_err(|err| format!("could not read screenshot file '{path}': {err}")) -} - -// The sidecar hands back the screenshot's temp-file PATH (bytes no longer ride -// the JSON-lines stdio shared with PTY traffic). Read the file here and return a -// raw tauri::ipc::Response so the webview gets an ArrayBuffer (the path the panel -// decodes with createImageBitmap). A base64 `bytesBase64` field is kept as a -// fallback for a stale sidecar bundle (dev-time version skew), but the path -// branch is preferred. `playwright_screenshot` takes the same path branch. -#[tauri::command(async)] -fn agent_browser_screenshot( - state: tauri::State<'_, SidecarState>, - session: String, - format: Option<String>, - quality: Option<u32>, - binary_path: Option<String>, -) -> Result<tauri::ipc::Response, String> { - let result = agent_browser_forward( - &state, - "agentBrowser:screenshot", - serde_json::json!({ "session": session, "format": format, "quality": quality, "binaryPath": binary_path }), - )?; - if let Some(path) = capture_path(&result)? { - return read_capture(path, false); - } - // Fallback: an older sidecar bundle still base64s the bytes over stdio. - let b64 = result - .get("bytesBase64") + let path = result + .get("path") .and_then(JsonValue::as_str) - .ok_or("screenshot returned no path or bytes")?; - let bytes = BASE64 - .decode(b64) - .map_err(|err| format!("bad screenshot base64: {err}"))?; - Ok(tauri::ipc::Response::new(bytes)) + .ok_or("screenshot returned no path")?; + let bytes = std::fs::read(path) + .map_err(|err| format!("could not read screenshot file '{path}': {err}")); + let _ = std::fs::remove_file(path); + bytes.map(tauri::ipc::Response::new) } // Clipboard reads run natively on Windows (see clipboard_win) to avoid the @@ -4566,15 +4429,8 @@ pub fn run() { load_notepad_archive, save_notepad_archive, reset_notepad_archive, - agent_browser_command, - playwright_request, - playwright_screenshot, - agent_browser_edit, - agent_browser_screenshot, - agent_browser_attach, - agent_browser_open, - agent_browser_pop_out, - agent_browser_pop_in, + browser_request, + browser_screenshot, ]) .build(tauri::generate_context!()) .expect("error while building Dormouse") @@ -6308,14 +6164,14 @@ mod tests { } } - // Match direct callers of the blocking helper *and* the - // agent-browser commands, which reach it transitively through the - // `agent_browser_forward` wrapper (their bodies never name - // `request_from_sidecar` directly). That family carries the longest - // timeout (AGENT_BROWSER_TIMEOUT = 30s), so it's the worst case to - // let slip plain-sync. + // Match direct callers of the blocking helper *and* + // `browser_screenshot`, which reaches it transitively through + // `browser_request` (its body never names `request_from_sidecar` + // directly). That pair carries the longest timeout + // (BROWSER_REQUEST_TIMEOUT = 40s), so it's the worst case to let + // slip plain-sync. let reaches_blocking = [ - "request_from_sidecar", "agent_browser_forward", "ARRIVAL_DISK_LOCK", + "request_from_sidecar", "browser_request", "ARRIVAL_DISK_LOCK", "record_arrival_on_disk", "mark_arrival_adopted_on_disk", "return_arrival_on_disk", "forget_arrival_on_disk", "read_arrivals_from", "write_arrivals_to", "restore_arrivals", "close_window_snapshot", "finish_window_close", "begin_arrival", "hand_back_arrival", diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index bb90642d1..e7116dd88 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -88,6 +88,23 @@ describe("BrowserSidecarAdapter session persistence", () => { }); }); +// The harness has no Rust to read a capture file, so its bridge answers a +// screenshot with base64 (`readCapture` in standalone/scripts/dev-agent-browser.mjs). +describe("BrowserSidecarAdapter browser requests", () => { + it("forwards every request through one browser_request, decoding a screenshot's base64", async () => { + const host = new BrowserSidecarHost("http://localhost:1234"); + const invoke = vi.spyOn(host, "invoke").mockResolvedValueOnce({ ok: true, mime: "image/jpeg", bytesBase64: "/9gB" }); + const adapter = new BrowserSidecarAdapter(host); + const request = { provider: "agent-browser" as const, binding: { session: "sess" }, op: "screenshot" as const }; + + expect(await adapter.browser(request)).toEqual({ ok: true, mime: "image/jpeg", bytes: Uint8Array.from([0xff, 0xd8, 0x01]) }); + expect(invoke).toHaveBeenCalledWith("browser_request", { request }); + + invoke.mockRejectedValueOnce(new Error("bridge down")); + expect(await adapter.browser({ ...request, op: "close" })).toEqual({ ok: false, error: "bridge down" }); + }); +}); + // The parse boundary and the alert transport both sidecar adapters share are // pinned once, for both, in `sidecar-adapters.test.ts`. describe("BrowserSidecarAdapter event stream", () => { diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index c23476a8d..9a86813e0 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -1,15 +1,9 @@ -import type { PlaywrightRequest, PlaywrightResult } from '../../lib/src/lib/platform/browser-automation'; +import { BROWSER_PROVIDER_IDS } from 'dor-lib-common/browser-providers'; +import type { BrowserRequest, BrowserResult } from '../../lib/src/lib/platform/browser-automation'; import { recordToolEvents } from '../../lib/src/lib/tool-events'; import type { TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { installWorkspaceRegistry, type WorkspaceRegistrySnapshot } from "./workspace-registry"; import type { - AgentBrowserAttachResult, - AgentBrowserCommandResult, - AgentBrowserEditOp, - AgentBrowserEditResult, - AgentBrowserOpenResult, - AgentBrowserPopResult, - AgentBrowserScreenshotResult, IframeProxyResult, OpenPort, PlatformAdapter, @@ -109,13 +103,6 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // adapter binds for the same reason; mirror it so any call style is safe. this.createIframeProxyUrl = this.createIframeProxyUrl.bind(this); this.toolControl = this.toolControl.bind(this); - this.agentBrowserCommand = this.agentBrowserCommand.bind(this); - this.agentBrowserEdit = this.agentBrowserEdit.bind(this); - this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this); - this.agentBrowserAttach = this.agentBrowserAttach.bind(this); - this.agentBrowserOpen = this.agentBrowserOpen.bind(this); - this.agentBrowserPopOut = this.agentBrowserPopOut.bind(this); - this.agentBrowserPopIn = this.agentBrowserPopIn.bind(this); } async init(): Promise<void> { @@ -270,59 +257,19 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } } - async playwright(request: PlaywrightRequest): Promise<PlaywrightResult> { - try { - // A screenshot's bytes arrive as base64, like agent_browser_screenshot's. - const { bytesBase64, ...result } = await this.host.invoke<PlaywrightResult & { bytesBase64?: string }>("playwright_request", { request }); - return bytesBase64 ? { ...result, bytes: decodeBase64Bytes(bytesBase64) } : result; - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserCommand(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { - try { return await this.host.invoke("agent_browser_command", { session, args, binaryPath }); } - catch (err) { return { exitCode: 1, stdout: "", stderr: errMessage(err) }; } - } - - async agentBrowserEdit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult> { - try { return await this.host.invoke("agent_browser_edit", { session, op, binaryPath }); } - catch (err) { return { ok: false, error: errMessage(err) }; } - } + readonly browserProviders = BROWSER_PROVIDER_IDS; - async agentBrowserScreenshot(session: string, opts: { format?: "jpeg" | "png"; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult> { + async browser(request: BrowserRequest): Promise<BrowserResult> { try { - const result = await this.host.invoke<{ ok: true; mime?: string; bytesBase64: string } | { ok: false; error?: string }>( - "agent_browser_screenshot", - { session, format: opts.format, quality: opts.quality, binaryPath }, - ); - if (!result.ok) return { ok: false, error: result.error }; - return { ok: true, bytes: decodeBase64Bytes(result.bytesBase64), mime: result.mime ?? (opts.format === "png" ? "image/png" : "image/jpeg") }; + // A screenshot's bytes arrive as base64: this bridge has no Rust to read + // the capture file (`readCapture` in standalone/scripts/dev-agent-browser.mjs). + const { bytesBase64, ...result } = await this.host.invoke<BrowserResult & { bytesBase64?: string }>("browser_request", { request }); + return bytesBase64 ? { ...result, bytes: decodeBase64Bytes(bytesBase64) } : result; } catch (err) { return { ok: false, error: errMessage(err) }; } } - async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise<AgentBrowserAttachResult> { - try { return await this.host.invoke("agent_browser_attach", { session, url: opts.url, headed: opts.headed, binaryPath }); } - catch (err) { return { ok: false, error: errMessage(err) }; } - } - - async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult> { - try { return await this.host.invoke("agent_browser_open", { url, headed: opts.headed, session: opts.session, binaryPath }); } - catch (err) { return { ok: false, error: errMessage(err) }; } - } - - async agentBrowserPopOut(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - try { return await this.host.invoke("agent_browser_pop_out", { session, url: opts.url, rect: opts.rect, binaryPath }); } - catch (err) { return { ok: false, error: errMessage(err) }; } - } - - async agentBrowserPopIn(session: string, opts: { url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - try { return await this.host.invoke("agent_browser_pop_in", { session, url: opts.url, binaryPath }); } - catch (err) { return { ok: false, error: errMessage(err) }; } - } - openExternal(uri: string): void { const normalized = normalizeExternalUri(uri); if (normalized) window.open(normalized, "_blank", "noopener,noreferrer"); diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 71fc2e757..60e8769d6 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -108,6 +108,32 @@ describe("TauriAdapter cwd probing", () => { }); }); +describe("TauriAdapter browser requests", () => { + it("sends a screenshot for raw bytes, every other request as JSON, and answers a failure as a result", async () => { + // Image bytes must never ride the JSON-lines pipe: only `browser_screenshot` + // reads the capture file in Rust and answers an ArrayBuffer. + const adapter = new TauriAdapter(); + vi.mocked(rawInvoke).mockImplementation(async (cmd: string) => { + if (cmd === "browser_screenshot") return Uint8Array.from([0xff, 0xd8]).buffer; + if (cmd === "browser_request") return { ok: true, wsPort: 4321 }; + return undefined; + }); + const binding = { session: "sess" }; + + const shot = await adapter.browser({ provider: "agent-browser", binding, op: "screenshot", format: "png" }); + expect(shot).toEqual({ ok: true, bytes: Uint8Array.from([0xff, 0xd8]), mime: "image/png" }); + const attached = await adapter.browser({ provider: "playwright", binding, op: "attach" }); + expect(attached).toEqual({ ok: true, wsPort: 4321 }); + expect(vi.mocked(rawInvoke).mock.calls.filter(([cmd]) => cmd.startsWith("browser_"))).toEqual([ + ["browser_screenshot", { request: { provider: "agent-browser", binding, op: "screenshot", format: "png" } }], + ["browser_request", { request: { provider: "playwright", binding, op: "attach" } }], + ]); + + vi.mocked(rawInvoke).mockRejectedValueOnce(new Error("sidecar gone")); + expect(await adapter.browser({ provider: "agent-browser", binding, op: "close" })).toEqual({ ok: false, error: "sidecar gone" }); + }); +}); + describe("TauriAdapter port probing", () => { it("sends one pty_get_open_ports_many for a whole listing, and fails soft", async () => { // `dor list --ports` across Workspaces asks once for every terminal: the diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 37f1dd810..58293effa 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,17 +1,11 @@ -import type { PlaywrightRequest, PlaywrightResult } from '../../lib/src/lib/platform/browser-automation'; +import { BROWSER_PROVIDER_IDS } from 'dor-lib-common/browser-providers'; +import type { BrowserRequest, BrowserResult } from '../../lib/src/lib/platform/browser-automation'; import { recordToolEvents } from '../../lib/src/lib/tool-events'; import type { TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { invoke as rawInvoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-shell"; import { coalesceCwds } from "./coalesce-cwds"; import type { - AgentBrowserAttachResult, - AgentBrowserCommandResult, - AgentBrowserEditOp, - AgentBrowserEditResult, - AgentBrowserOpenResult, - AgentBrowserPopResult, - AgentBrowserScreenshotResult, IframeProxyResult, OpenPort, PlatformAdapter, @@ -467,88 +461,20 @@ export class TauriAdapter implements PlatformAdapter { } } - // --- agent-browser host capabilities (see docs/specs/dor-browser.md → - // "Agent-Browser Host Capabilities"). Each invokes the matching Rust command, which runs the - // user's agent-browser binary (binaryPath → DORMOUSE_AGENT_BROWSER_BIN → PATH, - // mirroring the VS Code host's runWithBinaryFallback). Note there is no - // getAgentBrowserStreamUrl here: the agent-browser stream server accepts the - // tauri://localhost origin, so the panel connects directly to - // ws://127.0.0.1:<port> via its built-in fallback when the method is absent. --- + // --- browser automation (docs/specs/dor-browser.md → "Browser Host"). + // One Rust command forwards every request to the sidecar's + // shared host; a screenshot takes `browser_screenshot`, which answers raw + // bytes (tauri::ipc::Response) rather than JSON. --- - async playwright(request: PlaywrightRequest): Promise<PlaywrightResult> { + readonly browserProviders = BROWSER_PROVIDER_IDS; + + async browser(request: BrowserRequest): Promise<BrowserResult> { try { - // Screenshots take agent_browser_screenshot's raw-Response path. if (request.op === "screenshot") { - const buffer = await rawInvoke<ArrayBuffer>("playwright_screenshot", { request }); + const buffer = await rawInvoke<ArrayBuffer>("browser_screenshot", { request }); return { ok: true, bytes: new Uint8Array(buffer), mime: request.format === "png" ? "image/png" : "image/jpeg" }; } - return await rawInvoke<PlaywrightResult>("playwright_request", { request }); - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserCommand(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { - try { - return await rawInvoke<AgentBrowserCommandResult>("agent_browser_command", { session, args, binaryPath }); - } catch (err) { - return { exitCode: 1, stdout: "", stderr: errMessage(err) }; - } - } - - async agentBrowserEdit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult> { - try { - return await rawInvoke<AgentBrowserEditResult>("agent_browser_edit", { session, op, binaryPath }); - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserScreenshot(session: string, opts: { format?: "jpeg" | "png"; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult> { - // The Rust command returns the raw image as an ArrayBuffer (tauri::ipc::Response) - // on success, or rejects with an error string — no base64 round-trip. - try { - const buffer = await rawInvoke<ArrayBuffer>("agent_browser_screenshot", { - session, - format: opts.format, - quality: opts.quality, - binaryPath, - }); - const mime = opts.format === "png" ? "image/png" : "image/jpeg"; - return { ok: true, bytes: new Uint8Array(buffer), mime }; - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise<AgentBrowserAttachResult> { - try { - return await rawInvoke<AgentBrowserAttachResult>("agent_browser_attach", { session, url: opts.url, headed: opts.headed, binaryPath }); - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise<AgentBrowserOpenResult> { - try { - return await rawInvoke<AgentBrowserOpenResult>("agent_browser_open", { url, headed: opts.headed, session: opts.session, binaryPath }); - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserPopOut(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - // `rect` is accepted by the type but unused — no window positioning today. - try { - return await rawInvoke<AgentBrowserPopResult>("agent_browser_pop_out", { session, url: opts.url, binaryPath }); - } catch (err) { - return { ok: false, error: errMessage(err) }; - } - } - - async agentBrowserPopIn(session: string, opts: { url?: string }, binaryPath?: string): Promise<AgentBrowserPopResult> { - try { - return await rawInvoke<AgentBrowserPopResult>("agent_browser_pop_in", { session, url: opts.url, binaryPath }); + return await rawInvoke<BrowserResult>("browser_request", { request }); } catch (err) { return { ok: false, error: errMessage(err) }; } diff --git a/standalone/tsconfig.json b/standalone/tsconfig.json index a9da8db1f..61891f659 100644 --- a/standalone/tsconfig.json +++ b/standalone/tsconfig.json @@ -20,7 +20,7 @@ "dor/*": ["../dor/src/*"], "remote-lib-common": ["../remote-lib-common/src/index.ts"], "dor-lib-common": ["../dor-lib-common/src/index.ts"], - "dor-lib-common/agent-browser": ["../dor-lib-common/src/agent-browser.ts"] + "dor-lib-common/browser-providers": ["../dor-lib-common/src/browser-providers.ts"] } }, "include": ["src"] diff --git a/vscode-ext/src/agent-browser-host.ts b/vscode-ext/src/agent-browser-host.ts index 18b5712c7..1265d1625 100644 --- a/vscode-ext/src/agent-browser-host.ts +++ b/vscode-ext/src/agent-browser-host.ts @@ -1,59 +1,51 @@ /** - * Extension-host wiring for the agent-browser surface - * (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). + * Extension-host wiring for browser automation + * (docs/specs/dor-browser.md → "Browser Host"). * - * The capability logic itself is host-agnostic and lives in - * `lib/src/host/agent-browser-host.ts` and `lib/src/host/playwright-host.ts` + * The host itself is host-agnostic and lives in `lib/src/host/browser-host.ts` * (shared verbatim with the standalone Node sidecar). This file only: - * 1. instantiates those shared hosts with the two VS-Code-specific bits — - * writing the OS clipboard and logging — and re-exports their methods; and - * 2. owns the **stream relay**, which is genuinely VS-Code-only: the - * agent-browser stream server returns 403 for `vscode-webview://` origins - * (only localhost or absent origins are accepted), so the webview cannot - * connect directly. It connects to a short-lived tokenized relay URL and - * the relay pipes bytes only to the authorized 127.0.0.1:<streamPort>. + * 1. instantiates it with the VS-Code-specific bits — writing the OS + * clipboard, logging, and the relay below; and + * 2. owns the agent-browser **stream relay**, which is genuinely VS-Code-only: + * the agent-browser stream server returns 403 for `vscode-webview://` + * origins (only localhost or absent origins are accepted), so the webview + * cannot connect directly. It connects to a short-lived tokenized relay URL + * and the relay pipes bytes only to the authorized 127.0.0.1:<streamPort>. * (The standalone webview's `tauri://localhost` origin is accepted, so it * connects directly and needs no relay.) */ import * as vscode from 'vscode'; import * as net from 'net'; import { log } from './log'; -import { createAgentBrowserHost } from '../../lib/src/host/agent-browser-host'; +import { createAgentBrowserProvider } from '../../lib/src/host/agent-browser-host'; +import { createBrowserHost } from '../../lib/src/host/browser-host'; import { BrowserStreamGrants } from '../../lib/src/host/browser-stream-guard'; -import { createPlaywrightHost } from '../../lib/src/host/playwright-host'; +import { createPlaywrightProvider } from '../../lib/src/host/playwright-host'; -const host = createAgentBrowserHost({ +const logInfo = (message: string) => log.info(message); +const host = createBrowserHost({ // Awaited rather than returned: `vscode.env.clipboard.writeText` yields a // `Thenable`, VS Code's minimal promise interface, which is not a `Promise`. writeClipboardText: async (text) => { await vscode.env.clipboard.writeText(text); }, - log: (message) => log.info(message), + log: logInfo, + providers: { + 'agent-browser': () => createAgentBrowserProvider({ log: logInfo, streamUrl: createStreamRelayUrl }), + playwright: () => createPlaywrightProvider({ log: logInfo }), + }, }); -export const runAgentBrowserCommand = host.command; -export const runAgentBrowserEdit = host.edit; -export const runAgentBrowserScreenshot = host.screenshot; -export const runAgentBrowserAttach = host.attach; -export const runAgentBrowserOpen = host.open; -export const runAgentBrowserPopOut = host.popOut; -export const runAgentBrowserPopIn = host.popIn; +export const runBrowserRequest = host.request; -const playwright = createPlaywrightHost({ - writeClipboardText: async (text) => { await vscode.env.clipboard.writeText(text); }, - log: (message) => log.info(message), -}); - -export const runPlaywrightRequest = playwright.request; - -// Both providers' cleanup shares one deadline, as in the standalone sidecar's +// Every provider's cleanup shares one deadline, as in the standalone sidecar's // shutdown: the Playwright host first waits out in-flight launches and connects, // and `deactivate` joins this ahead of the notepad archive and session flush, // which VS Code's unknown kill budget must still reach. const CLOSE_DEADLINE_MS = 1500; -export async function closePoppedOutSessions(): Promise<void> { +export async function closeBrowserSessions(): Promise<void> { let deadline: ReturnType<typeof setTimeout> | undefined; await Promise.race([ - Promise.all([host.closePoppedOut(), playwright.close()]), + host.close(), new Promise<void>((resolve) => { deadline = setTimeout(resolve, CLOSE_DEADLINE_MS); }), ]).finally(() => clearTimeout(deadline)); } @@ -62,7 +54,7 @@ let relayPortPromise: Promise<number> | null = null; // The same single-use, 60s, port-bound grants that guard the Playwright viewer. const streamRelayGrants = new BrowserStreamGrants(); -export async function createStreamRelayUrl(streamPort: number): Promise<string> { +async function createStreamRelayUrl(streamPort: number): Promise<string> { const relayPort = await ensureStreamRelayPort(); return `ws://127.0.0.1:${relayPort}/stream/${streamPort}/${streamRelayGrants.issue(streamPort)}`; } diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index f286d6dd5..18fbe7f8b 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as ptyManager from './pty-manager'; import { DormouseViewProvider } from './webview-view-provider'; import { attachRouter, flushAllSessions, getAlertStates, reportWindowPresence } from './message-router'; -import { closePoppedOutSessions } from './agent-browser-host'; +import { closeBrowserSessions } from './agent-browser-host'; import { serveWebview } from './webview-messaging'; import { log } from './log'; import { initToolHost } from './tool-host'; @@ -263,7 +263,7 @@ export async function deactivate() { // it would skip the session flush, the live-PTY refresh, and both kills, // leaking the pty host and every PTY under it. An orphaned Chrome window is a // far smaller failure than an unkilled pty host. - const poppedOutClosed = closePoppedOutSessions().catch((err) => { + const poppedOutClosed = closeBrowserSessions().catch((err) => { log.error('[deactivate] could not close popped-out browser windows:', String(err)); }); step('capturing agent recovery commands'); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 7a8c59027..1de7553ce 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -16,7 +16,7 @@ import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import type { DorControlRequest } from './pty-manager'; import { dorWorkspaceRefusal } from './dor-workspace-guard'; -import { createStreamRelayUrl, runAgentBrowserAttach, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runPlaywrightRequest } from './agent-browser-host'; +import { runBrowserRequest } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { toolControl } from './tool-host'; import type { ToolHostRequest } from '../../lib/src/lib/platform/types'; @@ -667,96 +667,11 @@ export function attachRouter( void vscode.commands.executeCommand(msg.command); } break; - case 'playwright:request': - runPlaywrightRequest(msg.request).then(result => post({ type: 'playwright:result', requestId: msg.requestId, result } satisfies ExtensionMessage)); - break; - case 'agentBrowser:command': - runAgentBrowserCommand( - msg.session, - Array.isArray(msg.args) ? msg.args : [], - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ - type: 'agentBrowser:commandResult', requestId: msg.requestId, ...result, - } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:edit': - runAgentBrowserEdit( - msg.session, - msg.op, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ - type: 'agentBrowser:editResult', requestId: msg.requestId, ...result, - } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:screenshot': - runAgentBrowserScreenshot( - msg.session, - { format: msg.format, quality: msg.quality }, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ - type: 'agentBrowser:screenshotResult', requestId: msg.requestId, ...result, - } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:attach': - runAgentBrowserAttach( - msg.session, - { - url: typeof msg.url === 'string' ? msg.url : undefined, - headed: msg.headed === true, - }, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ - type: 'agentBrowser:attachResult', requestId: msg.requestId, ...result, - } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:getStreamUrl': { - const streamPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port <= 65535 ? msg.port : null; - if (!streamPort) { - post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage); - break; - } - createStreamRelayUrl(streamPort).then( - (url) => post({ - type: 'agentBrowser:streamUrl', requestId: msg.requestId, - url, - } satisfies ExtensionMessage), - () => post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage), - ); - break; - } - case 'agentBrowser:open': - runAgentBrowserOpen( - typeof msg.url === 'string' ? msg.url : '', - { headed: msg.headed === true, session: typeof msg.session === 'string' ? msg.session : undefined }, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:popOut': - runAgentBrowserPopOut( - msg.session, - { url: typeof msg.url === 'string' ? msg.url : undefined, rect: msg.rect }, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); - }); - break; - case 'agentBrowser:popIn': - runAgentBrowserPopIn( - msg.session, - { url: typeof msg.url === 'string' ? msg.url : undefined }, - typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, - ).then((result) => { - post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + case 'browser:request': + // Validated host-side (`parseBrowserRequest`): the request arrives from + // the webview realm. + runBrowserRequest(msg.request).then((result) => { + post({ type: 'browser:result', requestId: msg.requestId, result } satisfies ExtensionMessage); }); break; case 'tool:control': diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index b1b3dc150..a7aa7b882 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,11 +1,11 @@ -import type { PlaywrightRequest, PlaywrightResult } from '../../lib/src/lib/platform/browser-automation'; +import type { BrowserResult } from '../../lib/src/lib/platform/browser-automation'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { TerminalColors, TerminalProtocolEvent } from '../../lib/src/lib/terminal-protocol'; import type { AlertCommand, AlertEvents } from '../../lib/src/host/alert-protocol'; import type { PersistedAlertState } from '../../lib/src/lib/session-types'; import type { DorControlCancelPayload, DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; -import type { AgentBrowserAttachResult, IframeProxyResult, OpenPort, ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; +import type { IframeProxyResult, OpenPort, ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; import type { BurrowCommand, BurrowResult } from '../../lib/src/host/remote/service-protocol'; import type { VolatileNotepadSnapshot } from '../../lib/src/lib/notepad/types'; @@ -24,15 +24,8 @@ export type WebviewMessage = | { type: 'clipboard:readImage'; requestId: string } | { type: 'dormouse:openExternal'; uri: string } | { type: 'dormouse:runWorkbenchCommand'; command: VSCodeWorkbenchCommand } - | { type: 'playwright:request'; request: PlaywrightRequest; requestId: string } - | { type: 'agentBrowser:command'; session: string; args: string[]; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:edit'; session: string; op: 'selectAll' | 'copy' | 'cut'; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:screenshot'; session: string; format?: 'jpeg' | 'png'; quality?: number; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:attach'; session: string; url?: string; headed?: boolean; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:getStreamUrl'; port: number; requestId: string } - | { type: 'agentBrowser:open'; url: string; headed?: boolean; session?: string; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:popOut'; session: string; url?: string; rect?: { x: number; y: number; width: number; height: number }; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } + // Validated host-side, so the webview's shape is not trusted here. + | { type: 'browser:request'; request: unknown; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; embedderOrigins: string[]; requestId: string } | { type: 'tool:control'; request: ToolHostRequest; requestId: string } // Peer surfaces: the Burrow runs in the extension host, but the terminals @@ -87,14 +80,7 @@ export type ExtensionMessage = | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } | { type: 'clipboard:files'; paths: string[] | null; requestId: string } | { type: 'clipboard:image'; path: string | null; requestId: string } - | { type: 'playwright:result'; result: PlaywrightResult; requestId: string } - | { type: 'agentBrowser:commandResult'; requestId: string; exitCode: number; stdout: string; stderr: string } - | { type: 'agentBrowser:editResult'; requestId: string; ok: boolean; text?: string; error?: string } - | { type: 'agentBrowser:screenshotResult'; requestId: string; ok: boolean; bytes?: Uint8Array; mime?: string; error?: string } - | ({ type: 'agentBrowser:attachResult'; requestId: string } & AgentBrowserAttachResult) - | { type: 'agentBrowser:streamUrl'; requestId: string; url: string | null } - | { type: 'agentBrowser:openResult'; requestId: string; ok: boolean; session?: string; wsPort?: number; binaryPath?: string; error?: string } - | { type: 'agentBrowser:popResult'; requestId: string; ok: boolean; wsPort?: number; error?: string } + | { type: 'browser:result'; result: BrowserResult; requestId: string } | { type: 'iframe:proxyUrl'; requestId: string; result: IframeProxyResult } | { type: 'tool:result'; requestId: string; result: ToolControlResult } | { type: 'peer:ask'; requestId: string; op: string; params: unknown } diff --git a/vscode-ext/test/agent-browser-host.test.ts b/vscode-ext/test/agent-browser-host.test.ts index 17b4f6b0b..c9e9edd56 100644 --- a/vscode-ext/test/agent-browser-host.test.ts +++ b/vscode-ext/test/agent-browser-host.test.ts @@ -1,24 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// Both shared hosts, with cleanup that never finishes: a Playwright launch or +// The shared host, with cleanup that never finishes: a Playwright launch or // connect still in flight when VS Code shuts down. const { never } = vi.hoisted(() => ({ never: () => new Promise<void>(() => {}) })); -vi.mock('../../lib/src/host/agent-browser-host', () => ({ - createAgentBrowserHost: () => ({ closePoppedOut: never }), -})); -vi.mock('../../lib/src/host/playwright-host', () => ({ - createPlaywrightHost: () => ({ request: vi.fn(), close: never }), +vi.mock('../../lib/src/host/browser-host', () => ({ + createBrowserHost: () => ({ request: vi.fn(), close: never }), })); -import { closePoppedOutSessions } from '../src/agent-browser-host'; +import { closeBrowserSessions } from '../src/agent-browser-host'; afterEach(() => { vi.useRealTimers(); }); -describe('closePoppedOutSessions', () => { +describe('closeBrowserSessions', () => { it('gives up on hung browser cleanup so deactivate reaches the session flush', async () => { vi.useFakeTimers(); let settled = false; - void closePoppedOutSessions().then(() => { settled = true; }); + void closeBrowserSessions().then(() => { settled = true; }); await vi.advanceTimersByTimeAsync(1499); expect(settled).toBe(false); await vi.advanceTimersByTimeAsync(1); diff --git a/website/src/data/dependencies-cargo.json b/website/src/data/dependencies-cargo.json index defd6f0c0..e2787d21b 100644 --- a/website/src/data/dependencies-cargo.json +++ b/website/src/data/dependencies-cargo.json @@ -1,13 +1,5 @@ { "direct": [ - { - "name": "base64", - "version": "0.23.1", - "license": "MIT OR Apache-2.0", - "author": "Marshall Pierce <marshall@mpierce.org>", - "homepage": "https://github.com/marshallpierce/rust-base64", - "declaredName": "base64" - }, { "name": "getrandom", "version": "0.4.3", diff --git a/website/vite.config.ts b/website/vite.config.ts index 6d1369c3e..14c72c1d2 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -57,7 +57,7 @@ export default defineConfig(({ mode }) => ({ // build never compiles. Alias it to source, exactly like `dormouse-lib`. "remote-lib-common": path.resolve(import.meta.dirname, "../remote-lib-common/src"), // Same story for `dor-lib-common`: `Wall` → `useDorControl` → `connect-port` - // imports its `./agent-browser` subpath. The directory alias covers both + // imports its `./browser-providers` subpath. The directory alias covers both // that subpath and the bare specifier. "dor-lib-common": path.resolve(import.meta.dirname, "../dor-lib-common/src"), // Wall also imports `dor/*` (protocol + command types); `dor` has no