From 2a7867a28bf57fd28c3a472f4a5a9b5d3c89d91b Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 22:16:41 -0700 Subject: [PATCH 1/6] Audit browser contracts and secure capture lifecycle --- docs/specs/dor-browser.md | 158 +++++++----------- docs/specs/dor-browser.rationale.md | 14 +- docs/specs/security-local.rationale.md | 9 +- .../wall/AgentBrowserScreenModal.tsx | 4 +- lib/src/components/wall/BrowserPanel.tsx | 9 +- .../wall/agent-browser-connection.test.ts | 14 ++ .../wall/agent-browser-connection.ts | 10 +- .../wall/agent-browser-surface-controller.ts | 20 ++- lib/src/host/agent-browser-host.test.ts | 20 ++- lib/src/host/agent-browser-host.ts | 2 + lib/src/host/iframe-proxy.test.ts | 14 ++ lib/src/host/iframe-proxy.ts | 4 + lib/src/host/private-capture-dir.test.ts | 92 ++++++++++ lib/src/host/private-capture-dir.ts | 50 +++--- scripts/spec-word-budgets.json | 2 +- 15 files changed, 281 insertions(+), 141 deletions(-) create mode 100644 lib/src/host/private-capture-dir.test.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index e43568840..53d62d9ae 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -45,11 +45,6 @@ prefix. **Must spell provider names in full and lowercase in UI, commands and re to its provider and presentation (`screencast` / `popout`), reading anything else as `iframe`. -| Provider | CLI | Render modes | Binary override / name | Install | -| --- | --- | --- | --- | --- | -| agent-browser | `dor agent-browser` | `agent-browser-screencast`, `agent-browser-popout` | `DORMOUSE_AGENT_BROWSER_BIN` / `agent-browser` | `npm i -g agent-browser` | -| playwright | `dor playwright` | `playwright-screencast`, `playwright-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`. @@ -65,10 +60,10 @@ Invariants on the flat persisted `BrowserPanelParams`: Agent-browser mirrors the newest http(s) active tab URL into it — the host relaunches at nothing else; iframe persists only navigations initiated by Dormouse chrome. -- **Must keep browser binding and lifecycle fields flat** (`session`, `launchSession`, - `launchFallback`, `binaryPath`, `cwd`, `nativeIdentity`, `syncEngaged`, - `key`); `browserViewport` stores resolved sizing, and `launchFallback` may carry restore params. Pop-out is not a param — it derives from `renderMode` - once, at controller construction. +- **Must keep persisted browser params flat**, using `BrowserPanelParams`; + `browserViewport` stores resolved sizing, and `launchFallback` may carry restore + params. **Must derive popped-out presentation from `renderMode`**, never a + separate persisted flag. - **Never carry a stream in params**: the port `dor agent-browser` reads, or the stream the host's `attach` answers for `dor playwright`, goes straight to the Surface's controller (rationale). @@ -167,18 +162,19 @@ Source of truth: `lib/src/components/wall/SurfacePaneHeader.tsx`, For loopback URLs (`localhost`, `*.localhost`, `127.0.0.1`, `::1`) the header registers interest in the port, and `PlatformAdapter.getOpenPorts(id)` resolves -it against terminal panes and minimized doors. +it against mounted terminal-backed Surfaces and minimized terminal Doors. - **One scan loop per Window**, over every mounted Wall's Surfaces; the wanted-port store and the resolutions are window-wide. -- **Show a chip only when exactly one terminal owns that port**; zero or - two-plus leave it unsettled, so a later dev server still matches. +- **Show a chip only when exactly one candidate Surface owns that port**; zero + or two-plus leave it unsettled, so a later dev server still matches. - **Match only binds that serve localhost** — loopback or any-interface (`0.0.0.0`, `::`), never a specific non-loopback bind. -- **The scan stays decorative and off the hot path**, so it may never pile onto - a tab open or poll forever. -- **Must label the chip from the serving pane's live state**; a settled port is - not rescanned when its pane is retitled. +- **Must defer scans off the tab-open path and allow only one in flight.** + Unmatched wanted ports keep polling; matched ports settle until reload, a + navigation changes port interest, or the set of mounted Walls changes. +- **Must label the chip from the serving pane's live state**; a retitle alone + does not rescan its settled port. Source of truth: `lib/src/components/wall/use-dev-server-ports.ts`, `lib/src/components/wall/port-url.ts` (`servesLoopback`), @@ -263,21 +259,13 @@ and `sync-to-pane` in `lib/src/host/browser-host.test.ts`, **Must resolve the nearest ancestor `dormouse.yml` browser settings over user configuration over built-ins**, using the browser's bound working directory. A named preset replaces its whole lower-priority definition. `pane-sync` is reserved and cannot be redefined. `browser.default_viewport` defaults to `desktop`; a Tool's explicit viewport takes precedence. User-only Tool lookup excludes project settings. Browser configuration is data and grants no Tool execution authority. **Must parse browser preferences independently of Tool declarations**; invalid YAML or browser settings still fail explicitly. -| Built-in preset | CSS width × height | -| --- | --- | -| `desktop` | 1440 × 900 | -| `laptop` | 1280 × 800 | -| `tablet` | 768 × 1024 | -| `phone` | 390 × 844 | -| `pane-sync` | Follow the pane | - **Must preserve the current device-pixel ratio when DPR is omitted.** A requested ratio unsupported by the provider fails before changing dimensions; playwright can accept only its current context ratio. **Never turn an observed playwright DPR into an explicit request for future contexts.** Presets describe viewport geometry, not devices. **Must label pixel density DPR; fit fractional ratios, padding to two decimals without rounding.** **Must apply initial dimensions before the destination page's first script runs**, for managed CLI, GUI and Tool launches. Agent-browser launches blank, sets the viewport and then navigates; playwright uses its context viewport configuration. Deferred `pane-sync` launches start at 1440 × 900 until placement; **must engage sync when resolving that preset and send the actual pane dimensions on first attach**. A failed initialization never navigates at a silently substituted size. **Must apply a renderer and viewport chosen together to the new renderer**, not discard sizing during a swap. **Must query the active page's measured CSS dimensions and DPR**, never infer them from the pane or screenshot. Dormouse sizing commands require an existing bound Surface, do not create a browser, and use the same serialized writes as the Display modal. The command contract belongs to `docs/specs/dor-cli.md` → Browser viewport control. -Source of truth: `resolveBrowserViewport` in `dor-lib-common/src/browser-viewports.ts`; `parseBrowserConfig` / `mergeBrowserConfig` in `lib/src/host/browser-config.ts`; `createToolHost` in `lib/src/host/tool-host.ts`; `lib/src/host/browser-config.test.ts`, `lib/src/host/tool-host.test.ts`. +Source of truth: `BUILTIN_BROWSER_VIEWPORTS`, `PANE_SYNC_PRESET` and `resolveBrowserViewport` in `dor-lib-common/src/browser-viewports.ts`; `parseBrowserConfig` / `mergeBrowserConfig` in `lib/src/host/browser-config.ts`; `createToolHost` in `lib/src/host/tool-host.ts`; `lib/src/host/browser-config.test.ts`, `lib/src/host/tool-host.test.ts`. ## Automated Browser @@ -391,16 +379,8 @@ handover moves any phase but `launching` and `relaunching` to its stream; a new for a live browser — agent-browser's daemon stream port, the host's number for a Playwright connection — and what `view` takes back. -| Phase | State | Next | -| --- | --- | --- | -| `idle` | No view has started it | `launching` without a session; `live` at a handed-over stream; else `attaching` with its page | -| `launching` | Opening `url`, in `launchSession` when set, then binding the session answered | `live` at the answered stream, else `attaching`; `ended` | -| `attaching` | Asking the host (`attach`) where the session streams | `live`; `ended` | -| `live` | Viewing its stream | `parked` (hidden ≥1s, headless); `relaunching`; `ended` when a headless browser goes (rationale); `attaching` with no page when an unpark's stream fails | -| `parked` | Socket released; browser up at its stream | `live` at that stream on unpark (rationale); `relaunching` | -| `relaunching` | Headed↔headless relaunch | `live` at the host's stream; else `attaching` with its page, headless | -| `ended` | No browser; `error` says why | `relaunching`; a navigation rebinds, with its page | -| `disposed` | Released | — | +`Phase` owns the controller's local transition state; the caller-facing gates, +pending intents, and parking rules follow below. - **Every browser operation must pass one gate (`driver`), open only in `live`** — chrome and Display modal actions, tabs, edit chords (rationale); @@ -449,11 +429,6 @@ frame of the canvas's shape draws scaled into it**; only a crisp frame, or one of another shape, sizes the canvas, so switching between the two reallocates nothing (rationale). -High-rate `[ab-panel]` console diagnostics sit behind the -`dormouse.flags.abDebugLogs` localStorage flag, read lazily and memoized on the -first log (reload to apply), which also has the host log each viewer socket's -rates and its event-loop delay every 5 s; `debugSnapshot()`'s ring is always on. - Input rules: - **Canvas pointer coordinates map through one width-derived scale on both @@ -470,9 +445,11 @@ Input rules: platform**, since those chords do not survive CDP input. Undo/redo is not emulated. -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 the host `tab` operation. The daemon gate is pinned by +**Must render the tab strip only at two or more tabs**, using the ordinary URL +header for one. Select/close use the host `tab` operation. **Must preserve the +last nonempty tab list through transient empty reports and select newly +observed inactive tabs only in a headless view**; the first list is baseline. +`maybeSelectNewTab` follows a provisional duplicate URL to its destination. 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`. @@ -492,7 +469,7 @@ socket per Surface, onto the browser at the stream `view` names: | Message | Direction | Carries | | --- | --- | --- | -| frame | host → webview, binary | A 12-byte header — kind (`provisional` / `crisp`), the viewport's CSS size — then the JPEG | +| frame | host → webview, binary | `ViewerFrame`: provisional/crisp JPEG and optional viewport CSS size | | `status`, `tabs` | host → webview | Whether the browser is up, its viewport size and, for a headed window or a Playwright page, device pixel ratio; the tab list | | `sync` | webview → host | The pane's CSS size and display ratio while Resize with pane is engaged, and its engagement | | `sync` | host → webview | That engagement's sync: `applying`, `synced`, or `off` ([Display Modal And Render Swaps](#display-modal-and-render-swaps)) | @@ -506,10 +483,11 @@ socket per Surface, onto the browser at the stream `view` names: request is refused (`docs/specs/security-local.md` → Loopback Listeners). - **Must rebuild every webview message field by field before a provider sees it** (`parseViewerInput`); inbound messages are capped at 64 KiB. -- **Must send state only on change, and the current state to a socket that - connects — except `url`, a commit edge, and `sync`, which answers each size - sent**: `tabs` refreshes only when the driving command completes (rationale), - so every commit clears the title until `tabs` refreshes, even at the same URL. +- **Must send state only on change, and current state to a connecting socket, + except `sync`, which answers each size sent.** Agent-browser's `url` is an + undeduplicated commit edge; Playwright publishes changed URLs from its poll. + **Must clear the previous title on each received commit**, even at the same + URL, until `tabs` supplies its replacement. (rationale) - **A browser that goes on its own is reported `status { connected: false }` before its socket closes**, ending a headless pane and auto-reverting a headed one seen connected. **A launch or close of the browser ends every @@ -520,27 +498,15 @@ socket per Surface, onto the browser at the stream `view` names: - **A headed socket carries no frames**; a socket with more than 2 MB queued skips a provisional one. -**Two-stage paint, in the host.** A changed stream frame is sent at once as a -CSS-resolution **provisional frame** when it is the first, within 250 ms of -input (over the socket, or a host editing op), or while a capture is -**overdue**; otherwise it pulses the crisp loop, whose device-resolution capture -replaces it (rationale): - -- **One capture in flight**, the next no sooner than 1.5× the average capture - after the last began. -- **No capture may start inside the provisional window** (rationale). -- **A capture is overdue past twice the average round trip, at least 400ms; it - is never re-issued, and a paint made only because it is overdue does not - supersede it** (rationale). -- **A capture a provisional paint superseded while it ran is dropped and owed - again** (rationale). -- **A byte-identical capture is resent only after a provisional frame or a - `repaint`.** -- **An active-tab change owes a capture**; a browser the host cannot prove - live at the stream viewed ([agent-browser](#agent-browser)) has every changed - frame sent as provisional. - -Pinned by `lib/src/host/browser-viewer.test.ts`. +**Must paint changed stream frames provisionally and replace them with +host-owned device-resolution captures.** `BrowserView` owns input-window, +backlog, pacing and overdue-frame mechanics. **Never overlap captures or +replace a newer input-driven provisional paint with an older capture**; +active-tab changes owe a capture, and an uncapturable browser stays provisional. +(rationale) + +Source of truth: `BrowserView` in `lib/src/host/browser-viewer.ts`, pinned by +`lib/src/host/browser-viewer.test.ts`. **Upstreams.** agent-browser: the daemon's stream, dialed on `127.0.0.1` only; **the host must drop its ~20 Hz re-broadcast by raw comparison against the last @@ -556,7 +522,7 @@ CDP screencast ([Playwright](#playwright)). Source of truth: `createViewerServer`, `BrowserView` (`pages`, `WINDOW_GONE_GRACE_MS`), `measuredViewport` and `parseViewerInput` in `lib/src/host/browser-viewer.ts`; `BrowserStreamGrants` in -`lib/src/host/browser-stream-guard.ts`; `encodeViewerFrame` and `ViewerState` in +`lib/src/host/browser-stream-guard.ts`; `ViewerFrame`, `encodeViewerFrame`, `decodeViewerFrame` and `ViewerState` in `lib/src/lib/platform/browser-automation.ts`; `viewStream`, `observeWindow` and `cdpEndpoint` in `lib/src/host/agent-browser-host.ts`. Pinned also by `lib/src/host/agent-browser-host.test.ts` and `lib/src/host/browser-host.test.ts`. @@ -624,9 +590,8 @@ host; standalone runs the bundled copy in the sidecar behind one Rust command. 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, view): +**Must run one lifecycle for both providers**, whose native primitives are +canonical in `BrowserProvider`: - **Must serialize a browser's launches, relaunching attaches and closes per native identity**, in arrival order, so two panes restoring one session @@ -690,8 +655,10 @@ private per-process directory, is read into memory and deleted — read or not, failed or killed — and the directory is removed at shutdown** (rationale). **One capture per browser is in flight**: a viewer socket asking meanwhile joins it — never one from before the browser's close or relaunch — and an -agent-browser capture's spawn is killed past 30s. **A tmpdir that cannot be -created fails that capture and is retried on the next, never memoized.** +agent-browser capture's spawn is killed past 30s. **Must restrict the exact capture directory to its owner before returning a +path**, with Unix modes or a protected current-user-only Windows DACL. Failed +creation or permission setup fails the capture without publishing a path; +**must attempt cleanup of a newly created directory**, and retry on the next capture. Successful setup is shared until removal. **Must prevent a removed setup from publishing a capture path or invalidating a newer setup.** Source of truth: `lib/src/host/browser-host.ts` (`parseBrowserRequest`, `createBrowserHost`, `BrowserProvider`), `BROWSER_PROVIDERS` (`isSessionName`, @@ -699,7 +666,9 @@ Source of truth: `lib/src/host/browser-host.ts` (`parseBrowserRequest`, `lib/src/lib/platform/browser-automation.ts`, `browserHandle` in `lib/src/components/wall/browser-automation.ts`, `createBrowserCaptures` in `lib/src/host/browser-capture.ts`, -`lib/src/host/private-capture-dir.ts`, `vscode-ext/src/agent-browser-host.ts`, +`privateCaptureDir` in `lib/src/host/private-capture-dir.ts`, pinned by +`lib/src/host/private-capture-dir.test.ts`; `ensurePrivateDirectory` in +`lib/src/host/private-path.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`), `standalone/sidecar/main.js`. @@ -786,10 +755,11 @@ The proxy instruments any `http://` upstream, loopback and remote alike: - HTTPS: refused, per the table below. **Every panel error but a non-http(s) URL offers Open in agent-browser** (a swap to `agent-browser-screencast`) where the host can launch one, with `dor agent-browser open ` as the fallback text. -- **Link-local / cloud-metadata address: refused (`scheme`)** — an SSRF guard - that stands regardless of the loosened framing policy. **Canonicalize every - equivalent spelling** (decimal/octal/hex, short forms, IPv4-mapped IPv6) before - range-checking, so `0xA9FEA9FE` and `::ffff:169.254.169.254` are caught too; +- **Must refuse link-local / cloud-metadata address literals (`scheme`) after + canonicalizing equivalent spellings**, including decimal/octal/hex, short + forms, and IPv4-mapped IPv6. This checks the URL's hostname literal, not DNS + answers; named targets remain the user's command authority. + Source of truth: `isBlockedAddress` in `lib/src/host/iframe-proxy-rewrite.ts`, pinned by `lib/src/host/iframe-proxy-rewrite.test.ts`. **Must refuse `https://` at every entry to the iframe renderer on a host with @@ -801,7 +771,7 @@ https raw): | `surface.iframe` (`dor iframe`) | refused before any pane opens, naming `dor agent-browser open ` | | Display modal | iframe option disabled, showing the wording | | Render swap to `iframe`, tool or not | refused with a console warning; the modal never offers it, since both judge the page on screen (chrome URL, then `params.url`) | -| New-tab request from a framed page | an `agent-browser-screencast` pane, bound to its launch like a render swap, closed if the launch fails | +| New-tab request from a framed page | `agent-browser-screencast` where supported, bound to its launch and closed on failure; otherwise the iframe refusal | | A pane already holding one | `scheme` panel error with Open in agent-browser and `dor agent-browser open ` | The terminal context's port rows are always `http://` (`listenerUrlsByPort`). @@ -856,8 +826,8 @@ paint` in `lib/src/components/wall/IframePanel.test.tsx`. **Must send only fixed shim message kinds to the app** — `leader`, `pointerdown`, `location`, `open-window`, `theme-request`; `location` carries `loaded: true` only on the document's own `pageshow`/`DOMContentLoaded` report. -**Must relay only `leader`, `pointerdown`, and `open-window` from nested documents.** **`open-window` -intercepts every anchor target but `_self`**, plus `window.open`. Theme delivery is `docs/specs/theme.md` → Tool iframe themes; `theme-request` stays in the outer document. +**Must relay only `leader`, `pointerdown`, and `open-window` from nested documents.** **`open-window` intercepts nonempty anchor targets other than `_self`, except +links with `download`, plus `window.open`.** Theme delivery is `docs/specs/theme.md` → Tool iframe themes; `theme-request` stays in the outer document. **Only `http:` and `https:` reach a browser Surface, re-checked at the sink.** `open-window` and the control socket's `surface.iframe` go through @@ -936,7 +906,7 @@ Security boundaries: - **the `Origin` rewrite applies only to a caller the proxy itself served**, - each grant fronts exactly one upstream, - no user script is injected, -- link-local/cloud-metadata ranges are blocked, +- link-local/cloud-metadata address literals follow [Iframe Renderer](#iframe-renderer), - every other user-supplied `http://` target is trusted as the user's command, at the cost of the upstream's own XSS policy unless it opts into preservation. @@ -986,15 +956,11 @@ A moved iframe Surface remounts at its saved URL after consent: `docs/specs/layo (`docs/specs/dor-tool.md` `## Future`), which subsumes the plugin/backend target axis formerly staged here. - Optional terminal-side "this port is viewed by surface:N" indicator. -- Replace the spawn-per-shot CLI screenshot with a persistent host-side CDP - capture channel. Measured against agent-browser 0.27.3 (headless, attach dance - + correct-target selection): `Page.captureScreenshot` is byte-identical to the - CLI at DPR 1 and follows external `set viewport`, but returns CSS-resolution - frames at DPR>1 — this path's whole point — unless the client re-applies - `Emulation.setDeviceMetricsOverride`, which Dormouse can do correctly only - while sync-to-pane owns the values (an external `set device`/`set viewport` DPR - is unrecoverable from frames), and which from the host's own CDP session is a - second viewport writer ([Playwright](#playwright)). `captureBeyondViewport:true` bypasses emulation - and crashed the headless daemon; `clip.scale` returns blank frames. Adopt only - with a daemon-side answer — an upstream verb exposing current viewport+DPR, or - a daemon-owned capture channel. +### Daemon-owned crisp captures + +Replace spawn-per-shot CLI screenshots only with a daemon-owned capture channel +or an upstream answer exposing current viewport and DPR. **Must retain +device-resolution output and external viewport/device changes without adding a +second viewport writer.** The host cannot reconstruct an externally chosen DPR +from screencast frames; a host-owned CDP metrics override is safe only while +sync-to-pane owns the values. (rationale) diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index c51db82c8..3fe80aee9 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -156,7 +156,7 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **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 capture directory 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. +**Why the capture directory is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask. Unguessability prevents pre-created filenames, while owner-only directory permissions prevent another local account reading a capture before its cleanup. A deliberately shared Windows temp parent reproduced an inherited Everyone read grant on both the old capture directory and its screenshot (2026-10-01); Unix mode bits alone do not remove Windows grants. The new setup rejects failures before returning any capture path and caches only success. Asynchronous permission setup may finish after removal begins; its generation fence prevents a discarded path being returned, and its promise-identity check keeps an old failure from evicting replacement setup (reviewed 2026-10-02). **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 agent-browser` command in flight failed or started a daemon mid-relaunch (review of #777, 2026-09). Only a change of mode needs a new browser. @@ -235,3 +235,15 @@ The built-in local-file viewer supplies its own content boundary and permits the **Why the policy also admits `'self'`.** Storybook and similar apps render same-origin documents in nested frames, so an app-only ancestor list blocks their inner document. Each proxy origin belongs to one grant and one fixed upstream; documents already executing there share same-origin authority. Admitting `'self'` deliberately lets a proxy document frame another document from that grant, including after a top-level navigation, but no foreign ancestor matches and no grant can frame another grant. **Why the idle timer refreshes for an absent `Origin`.** "Own origin only" would expire a grant the user is still looking at, because a live frame's navigations and sub-resource loads carry no `Origin` at all. What a foreign `Origin` must not buy is keeping a closed pane's grant — and its live upstream binding — alive indefinitely by polling. + +## Daemon-owned crisp captures + +The historical agent-browser 0.27.3 experiment (measurement date unrecorded) used +headless CDP attachment and correct-target selection. `Page.captureScreenshot` +was byte-identical to the CLI at DPR 1 and followed external `set viewport`, but +returned CSS-resolution frames at higher DPR unless the client reapplied +`Emulation.setDeviceMetricsOverride`. That override introduced another viewport +writer; external `set device`/`set viewport` ratios were not recoverable from +frames. `captureBeyondViewport:true` bypassed emulation and crashed the headless +daemon; `clip.scale` returned blank frames. These results motivate the +Future item's daemon-owned route. diff --git a/docs/specs/security-local.rationale.md b/docs/specs/security-local.rationale.md index 5c7a8c91b..58a24a38f 100644 --- a/docs/specs/security-local.rationale.md +++ b/docs/specs/security-local.rationale.md @@ -41,10 +41,11 @@ Why deceptive links are gated twice. The modal omits its Open action and focuses Why the origin check is not an authenticity check. The iframe proxy serves the untrusted upstream on the same origin it grants the shim, so `e.origin` cannot tell a message the shim sent from one the page sent; what the check buys is that -no *other* frame can send them at all. The four shim messages are bounded -downstream instead — exiting passthrough, selecting a pane, an `http:`/`https:` -only `browserSurfaceUrl` behind an open prompt, and a frame-URL reading that may -lie. `use-wall-keyboard`'s leader channel accepts any live grant rather than one +no *other* frame can send them at all. The shim's actions are bounded +downstream — exiting passthrough, selecting a pane, an `http:`/`https:` URL +behind an open prompt, a frame-URL reading that may lie, and read-only theme +variables. Theme delivery additionally checks the actual iframe window; +requesting it grants no host command. `use-wall-keyboard`'s leader channel accepts any live grant rather than one panel's, so a page in one browser pane can exit passthrough while another is focused. The nested-frame relay preserves this boundary: it accepts only the same proxy origin and reconstructs one of the three pane-level shapes, so diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index c89b150dc..595cb2118 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -136,8 +136,8 @@ export function AgentBrowserScreenModal({ const currentMode: RenderMode = snapshot?.renderMode ?? 'agent-browser-screencast'; const canSwapRender = !!controller.actions.setRenderMode; const [renderMode, setRenderMode] = useState(currentMode); - // The controller declares what this Surface can take (a tool never pops out - // or changes provider); the current mode always shows so it stays selected. + // The controller declares what this Surface can take; a Tool never pops out. + // The current mode always shows so it stays selected. const offered = (mode: RenderMode) => mode === currentMode || controller.renderModes.includes(mode); const providers = BROWSER_PROVIDER_IDS.filter(provider => offered(renderModeFor(provider, 'screencast')) || offered(renderModeFor(provider, 'popout'))); const [provider, setChosenProvider] = useBrowserProvider(providers, parseRenderMode(currentMode).provider); diff --git a/lib/src/components/wall/BrowserPanel.tsx b/lib/src/components/wall/BrowserPanel.tsx index db5e00040..faa17e638 100644 --- a/lib/src/components/wall/BrowserPanel.tsx +++ b/lib/src/components/wall/BrowserPanel.tsx @@ -4,19 +4,20 @@ * * One surface, swappable renderer: it reads the canonical `renderMode` and mounts * the matching child — `IframePanel` for `iframe`, `AgentBrowserPanel` for - * `agent-browser-screencast` / `agent-browser-popout`. The two children stay separate components (their + * either automated provider's screencast or popout modes. The two children stay separate components (their * input models differ — CDP `input_*` messages vs native DOM); the shell only owns * the renderer choice. The browser chrome each child registers is keyed by * `api.id`, so the shared header/modal are unaffected by which child is mounted. */ import type { RenderMode } from './agent-browser-screen'; -import { resolveRenderMode } from './browser-surface'; +import { resolveRenderMode, type LaunchFallback } from './browser-surface'; +import type { BrowserViewportSetting } from 'dor-lib-common/browser-viewports'; import type { PaneProps } from './pane-props'; import { AgentBrowserPanel } from './AgentBrowserPanel'; import { IframePanel } from './IframePanel'; /** Canonical persisted state for a browser surface. `renderMode` + `url` are the - * single source of truth across swaps; the agent-browser fields ride flat and are + * single source of truth across swaps; automation bindings ride flat and are * present only for automation modes. */ export type BrowserPanelParams = { surfaceType?: string; @@ -33,6 +34,8 @@ export type BrowserPanelParams = { key?: string; binaryPath?: string; syncEngaged?: boolean; + browserViewport?: BrowserViewportSetting; + launchFallback?: LaunchFallback; /** Set only on a Surface the pane context menu opened for a port, as * `::`, `agent` being * agent-browser's. Reuse looks a Surface up by it, diff --git a/lib/src/components/wall/agent-browser-connection.test.ts b/lib/src/components/wall/agent-browser-connection.test.ts index 98c3dbd13..26a2a919a 100644 --- a/lib/src/components/wall/agent-browser-connection.test.ts +++ b/lib/src/components/wall/agent-browser-connection.test.ts @@ -143,3 +143,17 @@ describe('viewer socket connection', () => { connection.dispose(); }); }); + +describe('malformed viewer state', () => { + it('ignores non-record JSON without losing the next valid state', async () => { + const { connection } = connect(); + try { + await flush(); + for (const value of [null, false, 7, 'state', []]) { + expect(() => socket().emitMessage(JSON.stringify(value))).not.toThrow(); + } + socket().emitMessage(JSON.stringify({ type: 'status', connected: true, screencasting: true })); + expect(connection.snapshot().status).toEqual({ connected: true, screencasting: true }); + } finally { connection.dispose(); } + }); +}); diff --git a/lib/src/components/wall/agent-browser-connection.ts b/lib/src/components/wall/agent-browser-connection.ts index 406bba351..a7e1bb86f 100644 --- a/lib/src/components/wall/agent-browser-connection.ts +++ b/lib/src/components/wall/agent-browser-connection.ts @@ -34,9 +34,9 @@ export type AgentBrowserConnectionEvent = | { type: 'status'; status: AgentBrowserStreamStatus } | { type: 'tabs'; tabs: AgentBrowserTab[]; previousTabs: AgentBrowserTab[] } /** The active tab committed a navigation. Fires at commit; the `tabs` - * snapshot refreshes only when the driving command completes, which for a - * slow page is the whole load (docs/specs/dor-browser.md → "Viewer - * Socket"). */ + * snapshot may lag a commit: agent-browser refreshes it when the driving + * command completes, while Playwright polls. See docs/specs/dor-browser.md + * → "Viewer Socket". */ | { type: 'url'; url: string } /** A popped-out window's page, as its browser reports it. */ | { type: 'page'; url: string; title: string | null } @@ -208,7 +208,9 @@ export class AgentBrowserConnection { if (typeof raw !== 'string') return; let msg: ViewerState; try { - msg = JSON.parse(raw) as ViewerState; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + msg = parsed as ViewerState; } catch { return; } diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 188998cf1..771fbd0a5 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -139,9 +139,15 @@ function allowedBinaryPath(candidate: unknown, provider: BrowserAutomationProvid } /** - * Where the Surface's browser is in its life (docs/specs/dor-browser.md → - * "Browser Connection" has the transition table). The stream connection - * exists exactly in `live`, and so does every daemon command (`driver`). + * Local lifecycle transitions. The stream and command driver exist only in live. + * idle → launching without a session, live for a handed-over stream, otherwise attaching; + * launching → live with the returned stream, otherwise attaching, or ended on failure; + * attaching → live or ended; live → parked after hidden headless delay, relaunching, + * or ended when its browser goes; a failed unpark reattaches without a page. + * parked → live at its stream on unpark, or relaunching; + * relaunching → live with the returned stream, otherwise attaching headless; + * ended → relaunching, or a navigation rebinds with its page; disposed is terminal. + * Cross-file lifetime/visibility requirements: docs/specs/dor-browser.md → "Browser Connection". */ type Phase = /** Constructed; no view has started it yet. */ @@ -218,11 +224,9 @@ export class AgentBrowserSurfaceController { } /** Gates the render modes offered; see `ensureStarted`. */ private readonly isTool: boolean; - /** What `setRenderMode` accepts, fixed on first use with the host's - * capabilities. Never a popout or another provider for a tool, whose - * `render` is `iframe` or `agent-browser-screencast`: the swap would tear the browser - * down and re-derive the same screencast, so asking for a native window would - * get a reload (`docs/specs/dor-tool.md` -> Declaring tools). */ + /** What `setRenderMode` accepts, fixed on first use with host capabilities. + * Tools offer iframe and the declarable screencast modes of either provider, + * never popout (`docs/specs/dor-tool.md` → Declaring tools). */ private renderModesCache: readonly RenderMode[] | null = null; private get renderModes(): readonly RenderMode[] { return this.renderModesCache ??= offeredRenderModes(this.isTool, this.provider); diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index fe156249e..da541dac8 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -689,6 +689,15 @@ describe('agent-browser host viewer', () => { // A frame's base64 body, large enough to be told from a control message by size. const frame = (fill: number, deviceWidth = 800) => ({ type: 'frame', data: Buffer.alloc(13_000, fill).toString('base64'), metadata: { deviceWidth, deviceHeight: 600 } }); + it('ignores malformed JSON values from the daemon and resumes valid state', async () => { + const daemon = await fakeServer(); + const viewer = await view(daemon.port); + await daemon.connected(); + for (const value of [null, false, 7, 'state', []]) daemon.send(JSON.stringify(value)); + daemon.send({ type: 'status', connected: true, screencasting: false }); + await vi.waitFor(() => expect(viewer.states).toEqual([{ type: 'status', connected: true, screencasting: false }])); + }); + it('relays the daemon stream, dropping its unchanged re-broadcasts and decoding each changed frame once', async () => { running(session); const daemon = await fakeServer(); @@ -868,6 +877,15 @@ describe('agent-browser host viewer', () => { const cdpVerbs = () => spawnMock.mock.calls.map((call) => [(call[1] as string[]).slice(2), call[2]]); + it('ignores malformed JSON values from CDP and resumes valid page events', async () => { + const cdp = await fakeBrowser(['one']); + const { viewer } = await headedView(cdp); + await vi.waitFor(() => expect(viewer.states).toHaveLength(1)); + for (const value of [null, false, 7, 'state', []]) cdp.send(JSON.stringify(value)); + cdp.send({ method: 'Target.targetInfoChanged', params: { targetInfo: { targetId: 'one', type: 'page', url: 'https://two.example/', title: 'Two' } } }); + await vi.waitFor(() => expect(viewer.states.at(-1)).toEqual({ type: 'page', url: 'https://two.example/', title: 'Two' })); + }); + it('follows a headed window\'s page over its browser\'s CDP, and sends it no frames', async () => { const cdp = await fakeBrowser(['one']); const { viewer, daemon } = await headedView(cdp); @@ -1018,7 +1036,7 @@ describe('agent-browser host captures', () => { expect(existsSync(file)).toBe(false); const dir = dirname(file); expect(dir).not.toBe(tmpdir()); - expect(statSync(dir).mode & 0o777).toBe(0o700); + if (process.platform !== 'win32') expect(statSync(dir).mode & 0o777).toBe(0o700); // Never a name another capture wrote, and nothing left behind. expect([...await take(captures)]).toEqual([0xff, 0xd8, 2]); expect((spawnMock.mock.calls[1][1] as string[])[3]).not.toBe(file); diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 1628b6e4c..03a48dbba 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -147,6 +147,7 @@ function cdpCalls(socket: WebSocket, event: (method: string, params: Record { await expect(upgrade(url, wsHeaders({}))).rejects.toMatchObject({ code: 'ECONNREFUSED' }); }); }); + +describe('iframe grant capacity', () => { + it.each(['sequential', 'concurrent'] as const)('keeps at most 32 published listeners after %s creation', async (mode) => { + advanceClock(6 * 60_000); + await sweep(); + const port = await upstream((_q, s) => { s.writeHead(204); s.end(); }); + const target = `http://127.0.0.1:${port}/`; + const urls: string[] = []; + if (mode === 'concurrent') urls.push(...await Promise.all(Array.from({ length: 40 }, () => frame(target)))); + else for (let i = 0; i < 40; i++) urls.push(await frame(target)); + const listening = await Promise.all(urls.map((url) => isListening(Number(new URL(url).port)))); + expect(listening.filter(Boolean)).toHaveLength(32); + }); +}); diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 6c297daf5..57d9caecc 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -181,6 +181,10 @@ export async function createIframeProxyUrl( grant.port = port; grant.proxyOrigin = `http://127.0.0.1:${port}`; grants.set(port, grant); + // A bind yields: other creations may have committed since the first sweep. + // Sweep after insertion as well, so neither sequential nor concurrent calls + // leave more than MAX_GRANTS published listeners behind. + sweepGrants(Date.now()); log(`[iframe-proxy] ${upstream.href} → ${grant.proxyOrigin}`); // The proxy origin maps to one fixed upstream, so the full path resolves diff --git a/lib/src/host/private-capture-dir.test.ts b/lib/src/host/private-capture-dir.test.ts new file mode 100644 index 000000000..39ad3dc01 --- /dev/null +++ b/lib/src/host/private-capture-dir.test.ts @@ -0,0 +1,92 @@ +import * as fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { privateCaptureDir, type PrivateCaptureDir } from './private-capture-dir'; +import * as privatePaths from './private-path'; +import { readAcl, seedEveryoneRead } from './private-path.test-utils'; + +let parent: string; +let capture: PrivateCaptureDir; +beforeEach(() => { + parent = fs.mkdtempSync(join(tmpdir(), 'dormouse-capture-test-')); + // A nested prefix creates a child of this per-test temp directory without + // changing process-wide TEMP/TMP. + capture = privateCaptureDir(join(basename(parent), 'capture-')); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await capture.remove(); + fs.rmSync(parent, { recursive: true, force: true }); +}); + +describe('private browser capture directory', () => { + it('prepares one owner-only directory and creates a fresh one after removal', async () => { + const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory'); + const [first, shared] = await Promise.all([capture.get(), capture.get()]); + expect(shared).toBe(first); + expect(prepare).toHaveBeenCalledTimes(1); + await capture.remove(); + expect(fs.existsSync(first)).toBe(false); + const next = await capture.get(); + expect(next).not.toBe(first); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it('removes a directory whose permission setup failed, then retries without exposing a path', async () => { + const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory').mockRejectedValue(new Error('ACL failed')); + await expect(capture.get()).rejects.toThrow('ACL failed'); + expect(fs.readdirSync(parent)).toEqual([]); + prepare.mockRestore(); + const dir = await capture.get(); + expect(fs.existsSync(dir)).toBe(true); + }); + + it('does not publish a removed pending path or evict its replacement from the cache', async () => { + const original = privatePaths.ensurePrivateDirectory; + let entered!: () => void, finish!: () => void; + const started = new Promise((resolve) => { entered = resolve; }); + const paused = new Promise((resolve) => { finish = resolve; }); + let discarded!: string; + const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory').mockImplementationOnce(async (dir) => { + discarded = dir; + entered(); + await paused; + await original(dir); + }); + const first = capture.get(); + const refused = expect(first).rejects.toThrow('removed during setup'); + await started; + const removing = capture.remove(); + const replacement = await capture.get(); + finish(); + await refused; + await removing; + expect(fs.existsSync(discarded)).toBe(false); + expect(fs.existsSync(replacement)).toBe(true); + expect(await capture.get()).toBe(replacement); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it.skipIf(process.platform !== 'win32')('removes inherited foreign access before the first screenshot can be written', async () => { + seedEveryoneRead(parent); + const dir = await capture.get(); + const file = join(dir, 'capture.png'); + fs.writeFileSync(file, 'private screenshot'); + for (const [target, inheritance] of [[dir, 3], [file, 0]] as const) { + const acl = readAcl(target); + expect(acl.owner).toBe(acl.currentUser); + expect(acl.rules).toEqual([{ sid: acl.currentUser, rights: 0x001F01FF, allow: true, inheritance }]); + if (target === dir) expect(acl.protected).toBe(true); + } + }, 10_000); + + it.skipIf(process.platform === 'win32')('restricts the directory and its screenshots without changing the parent', async () => { + fs.chmodSync(parent, 0o755); + const dir = await capture.get(); + const file = join(dir, 'capture.png'); + fs.writeFileSync(file, 'private screenshot'); + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + expect(fs.statSync(parent).mode & 0o777).toBe(0o755); + }); +}); diff --git a/lib/src/host/private-capture-dir.ts b/lib/src/host/private-capture-dir.ts index 2d8dec6a2..9c0e7ad8b 100644 --- a/lib/src/host/private-capture-dir.ts +++ b/lib/src/host/private-capture-dir.ts @@ -1,19 +1,13 @@ -/** - * The private per-process directory the browser host's captures are written - * into (docs/specs/dor-browser.md → "Viewer Socket"; `./browser-capture.ts`). - * - * A frame is a picture of the user's authenticated browser, so the *directory* - * is the control: one `mkdtemp` per host, which is `0700` and unguessable. A - * derivable path directly in `os.tmpdir()` let any other local account read - * every frame, or pre-create the name as a symlink and have the writer clobber - * whatever it pointed at. `standalone/sidecar/clipboard-ops.js` does the same - * for clipboard images; the paths are meant to match, cleanup included — a - * frame of someone's authenticated browser is not something to leave in tmp for - * the OS to reap whenever it gets round to it. +/** Private, unguessable per-process storage for authenticated browser frames. + * Owner-only permissions precede every published capture path; successful + * setup is shared until removal, and failures permit the next capture to retry. + * Cleanup covers explicit shutdown and process exit. See + * docs/specs/dor-browser.md → "Browser Host". */ import * as os from 'os'; import * as path from 'path'; import { promises as fs, rmSync } from 'fs'; +import { ensurePrivateDirectory } from './private-path'; export interface PrivateCaptureDir { /** The directory, created on first use. */ @@ -24,12 +18,23 @@ export interface PrivateCaptureDir { export function privateCaptureDir(prefix: string): PrivateCaptureDir { let once: Promise | null = null; + let generation = 0; function get(): Promise { - // mkdtemp creates at 0700 already; the chmod covers an inherited-mode - // filesystem and is a no-op on Windows, where %TEMP% is per-user. - once ??= fs.mkdtemp(path.join(os.tmpdir(), prefix)).then(async (dir) => { - if (process.platform !== 'win32') await fs.chmod(dir, 0o700).catch(() => {}); + if (once) return once; + const ownedGeneration = generation; + const pending = fs.mkdtemp(path.join(os.tmpdir(), prefix)).then(async (dir) => { + try { + await ensurePrivateDirectory(dir); + // Removal invalidates setup already in flight; never publish a path + // after its owner has asked to discard it. + if (ownedGeneration !== generation) throw new Error('Capture directory removed during setup'); + } catch (error) { + // A failed ACL/mode setup must expose no screenshot path or leaked + // directory. The outer catch permits a later capture to retry. + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + throw error; + } // Backstop for an exit that never reaches `remove` — a crash, or a host // that skips its shutdown hook. An `exit` handler cannot await, hence the // sync removal. @@ -38,18 +43,21 @@ export function privateCaptureDir(prefix: string): PrivateCaptureDir { }); return dir; }).catch((err: unknown) => { - // Never memoize the failure. `??=` would otherwise cache the rejected - // promise, so one transient EACCES/ENOSPC on tmpdir would disable - // screenshots for the rest of this process's life with no retry. - once = null; + // Never memoize rejected setup: a transient EACCES/ENOSPC must allow + // a later capture to retry. + // A discarded setup may fail after another get has begun. It must not + // evict that newer generation from the cache. + if (once === pending) once = null; throw err; }); - return once; + once = pending; + return pending; } async function remove(): Promise { const pending = once; once = null; + generation++; // Awaited, so a directory still being created is dropped rather than leaked. const dir = await pending?.catch(() => undefined); if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 8bebab38f..e8c127c1d 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/alert.md": 8650, "docs/specs/auto-update.md": 1450, "docs/specs/deploy.md": 1950, - "docs/specs/dor-browser.md": 9350, + "docs/specs/dor-browser.md": 9050, "docs/specs/dor-cli.md": 6600, "docs/specs/dor-tool.md": 6250, "docs/specs/dor-tools-builtin.md": 1100, From d04561210190de279a1bd59e9209bdc43f1946d1 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 22:39:04 -0700 Subject: [PATCH 2/6] Stamp iframe grants when their listener is published --- lib/src/host/iframe-proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 57d9caecc..4e229546f 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -180,6 +180,7 @@ export async function createIframeProxyUrl( } grant.port = port; grant.proxyOrigin = `http://127.0.0.1:${port}`; + grant.lastUsed = Date.now(); grants.set(port, grant); // A bind yields: other creations may have committed since the first sweep. // Sweep after insertion as well, so neither sequential nor concurrent calls From 7023dae413ddfd4e639f06dbb2c4a584d03ccf55 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:00:56 -0700 Subject: [PATCH 3/6] Keep browser cleanup on the simpler permission boundary --- docs/specs/dor-browser.md | 12 ++-- docs/specs/dor-browser.rationale.md | 6 +- docs/specs/security-local.md | 5 ++ lib/src/host/private-capture-dir.test.ts | 92 ------------------------ lib/src/host/private-capture-dir.ts | 41 ++++------- scripts/spec-word-budgets.json | 2 +- 6 files changed, 28 insertions(+), 130 deletions(-) delete mode 100644 lib/src/host/private-capture-dir.test.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 53d62d9ae..54aaf9649 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -655,10 +655,9 @@ private per-process directory, is read into memory and deleted — read or not, failed or killed — and the directory is removed at shutdown** (rationale). **One capture per browser is in flight**: a viewer socket asking meanwhile joins it — never one from before the browser's close or relaunch — and an -agent-browser capture's spawn is killed past 30s. **Must restrict the exact capture directory to its owner before returning a -path**, with Unix modes or a protected current-user-only Windows DACL. Failed -creation or permission setup fails the capture without publishing a path; -**must attempt cleanup of a newly created directory**, and retry on the next capture. Successful setup is shared until removal. **Must prevent a removed setup from publishing a capture path or invalidating a newer setup.** +agent-browser capture's spawn is killed past 30s. **Must use a per-process +`mkdtemp` directory, mode `0700` on Unix, and retry failed creation.** Windows +permission limits are `docs/specs/security-local.md` -> "Browser panes". Source of truth: `lib/src/host/browser-host.ts` (`parseBrowserRequest`, `createBrowserHost`, `BrowserProvider`), `BROWSER_PROVIDERS` (`isSessionName`, @@ -666,9 +665,8 @@ Source of truth: `lib/src/host/browser-host.ts` (`parseBrowserRequest`, `lib/src/lib/platform/browser-automation.ts`, `browserHandle` in `lib/src/components/wall/browser-automation.ts`, `createBrowserCaptures` in `lib/src/host/browser-capture.ts`, -`privateCaptureDir` in `lib/src/host/private-capture-dir.ts`, pinned by -`lib/src/host/private-capture-dir.test.ts`; `ensurePrivateDirectory` in -`lib/src/host/private-path.ts`, `vscode-ext/src/agent-browser-host.ts`, +`privateCaptureDir` in `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`), `standalone/sidecar/main.js`. diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 3fe80aee9..2b1b67f69 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -156,7 +156,11 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **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 capture directory is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask. Unguessability prevents pre-created filenames, while owner-only directory permissions prevent another local account reading a capture before its cleanup. A deliberately shared Windows temp parent reproduced an inherited Everyone read grant on both the old capture directory and its screenshot (2026-10-01); Unix mode bits alone do not remove Windows grants. The new setup rejects failures before returning any capture path and caches only success. Asynchronous permission setup may finish after removal begins; its generation fence prevents a discarded path being returned, and its promise-identity check keeps an old failure from evicting replacement setup (reviewed 2026-10-02). +**Why captures use a private directory.** External screenshot writers use the +ambient umask; a random directory prevents pre-created names and Unix `0700` +blocks other accounts. A shared Windows temp parent reproduced inherited +Everyone read grants on the directory and screenshot (2026-10-01); Unix modes +do not remove those grants. Windows therefore relies on the temp parent's ACL. **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 agent-browser` command in flight failed or started a daemon mid-relaunch (review of #777, 2026-09). Only a change of mode needs a new browser. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index f4f1fe5a4..f44d9a372 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -47,6 +47,11 @@ shell-integration scripts — the parser scans raw bytes and cannot defend it The attacker is the page inside a browser pane. +**Known gap: Windows screenshots/clipboard images inherit parent ACLs.** + +Source of truth: `lib/src/host/private-capture-dir.ts`, +`standalone/sidecar/clipboard-ops.js`, `standalone/src-tauri/src/clipboard_win.rs`. + **Every listener the webview realm exposes to a framed page checks the sender's origin before it acts** — `IframePanel` against its own panel's proxy origin, the Wall's leader channel against any live grant (`docs/specs/dor-browser.md` -> diff --git a/lib/src/host/private-capture-dir.test.ts b/lib/src/host/private-capture-dir.test.ts deleted file mode 100644 index 39ad3dc01..000000000 --- a/lib/src/host/private-capture-dir.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { basename, join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { privateCaptureDir, type PrivateCaptureDir } from './private-capture-dir'; -import * as privatePaths from './private-path'; -import { readAcl, seedEveryoneRead } from './private-path.test-utils'; - -let parent: string; -let capture: PrivateCaptureDir; -beforeEach(() => { - parent = fs.mkdtempSync(join(tmpdir(), 'dormouse-capture-test-')); - // A nested prefix creates a child of this per-test temp directory without - // changing process-wide TEMP/TMP. - capture = privateCaptureDir(join(basename(parent), 'capture-')); -}); -afterEach(async () => { - vi.restoreAllMocks(); - await capture.remove(); - fs.rmSync(parent, { recursive: true, force: true }); -}); - -describe('private browser capture directory', () => { - it('prepares one owner-only directory and creates a fresh one after removal', async () => { - const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory'); - const [first, shared] = await Promise.all([capture.get(), capture.get()]); - expect(shared).toBe(first); - expect(prepare).toHaveBeenCalledTimes(1); - await capture.remove(); - expect(fs.existsSync(first)).toBe(false); - const next = await capture.get(); - expect(next).not.toBe(first); - expect(prepare).toHaveBeenCalledTimes(2); - }); - - it('removes a directory whose permission setup failed, then retries without exposing a path', async () => { - const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory').mockRejectedValue(new Error('ACL failed')); - await expect(capture.get()).rejects.toThrow('ACL failed'); - expect(fs.readdirSync(parent)).toEqual([]); - prepare.mockRestore(); - const dir = await capture.get(); - expect(fs.existsSync(dir)).toBe(true); - }); - - it('does not publish a removed pending path or evict its replacement from the cache', async () => { - const original = privatePaths.ensurePrivateDirectory; - let entered!: () => void, finish!: () => void; - const started = new Promise((resolve) => { entered = resolve; }); - const paused = new Promise((resolve) => { finish = resolve; }); - let discarded!: string; - const prepare = vi.spyOn(privatePaths, 'ensurePrivateDirectory').mockImplementationOnce(async (dir) => { - discarded = dir; - entered(); - await paused; - await original(dir); - }); - const first = capture.get(); - const refused = expect(first).rejects.toThrow('removed during setup'); - await started; - const removing = capture.remove(); - const replacement = await capture.get(); - finish(); - await refused; - await removing; - expect(fs.existsSync(discarded)).toBe(false); - expect(fs.existsSync(replacement)).toBe(true); - expect(await capture.get()).toBe(replacement); - expect(prepare).toHaveBeenCalledTimes(2); - }); - - it.skipIf(process.platform !== 'win32')('removes inherited foreign access before the first screenshot can be written', async () => { - seedEveryoneRead(parent); - const dir = await capture.get(); - const file = join(dir, 'capture.png'); - fs.writeFileSync(file, 'private screenshot'); - for (const [target, inheritance] of [[dir, 3], [file, 0]] as const) { - const acl = readAcl(target); - expect(acl.owner).toBe(acl.currentUser); - expect(acl.rules).toEqual([{ sid: acl.currentUser, rights: 0x001F01FF, allow: true, inheritance }]); - if (target === dir) expect(acl.protected).toBe(true); - } - }, 10_000); - - it.skipIf(process.platform === 'win32')('restricts the directory and its screenshots without changing the parent', async () => { - fs.chmodSync(parent, 0o755); - const dir = await capture.get(); - const file = join(dir, 'capture.png'); - fs.writeFileSync(file, 'private screenshot'); - expect(fs.statSync(dir).mode & 0o777).toBe(0o700); - expect(fs.statSync(parent).mode & 0o777).toBe(0o755); - }); -}); diff --git a/lib/src/host/private-capture-dir.ts b/lib/src/host/private-capture-dir.ts index 9c0e7ad8b..8e08bde6e 100644 --- a/lib/src/host/private-capture-dir.ts +++ b/lib/src/host/private-capture-dir.ts @@ -1,13 +1,11 @@ -/** Private, unguessable per-process storage for authenticated browser frames. - * Owner-only permissions precede every published capture path; successful - * setup is shared until removal, and failures permit the next capture to retry. - * Cleanup covers explicit shutdown and process exit. See - * docs/specs/dor-browser.md → "Browser Host". +/** + * Per-process screenshot directory (docs/specs/dor-browser.md -> "Browser Host"). + * mkdtemp makes an unguessable 0700 directory on Unix. Windows inherits the + * temp parent's ACL; this module applies no Windows permission boundary. */ import * as os from 'os'; import * as path from 'path'; import { promises as fs, rmSync } from 'fs'; -import { ensurePrivateDirectory } from './private-path'; export interface PrivateCaptureDir { /** The directory, created on first use. */ @@ -18,23 +16,11 @@ export interface PrivateCaptureDir { export function privateCaptureDir(prefix: string): PrivateCaptureDir { let once: Promise | null = null; - let generation = 0; function get(): Promise { - if (once) return once; - const ownedGeneration = generation; - const pending = fs.mkdtemp(path.join(os.tmpdir(), prefix)).then(async (dir) => { - try { - await ensurePrivateDirectory(dir); - // Removal invalidates setup already in flight; never publish a path - // after its owner has asked to discard it. - if (ownedGeneration !== generation) throw new Error('Capture directory removed during setup'); - } catch (error) { - // A failed ACL/mode setup must expose no screenshot path or leaked - // directory. The outer catch permits a later capture to retry. - await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); - throw error; - } + // Unix modes do not restrict an inherited Windows ACL. + once ??= fs.mkdtemp(path.join(os.tmpdir(), prefix)).then(async (dir) => { + if (process.platform !== 'win32') await fs.chmod(dir, 0o700).catch(() => {}); // Backstop for an exit that never reaches `remove` — a crash, or a host // that skips its shutdown hook. An `exit` handler cannot await, hence the // sync removal. @@ -43,21 +29,18 @@ export function privateCaptureDir(prefix: string): PrivateCaptureDir { }); return dir; }).catch((err: unknown) => { - // Never memoize rejected setup: a transient EACCES/ENOSPC must allow - // a later capture to retry. - // A discarded setup may fail after another get has begun. It must not - // evict that newer generation from the cache. - if (once === pending) once = null; + // Never memoize the failure. `??=` would otherwise cache the rejected + // promise, so one transient EACCES/ENOSPC on tmpdir would disable + // screenshots for the rest of this process's life with no retry. + once = null; throw err; }); - once = pending; - return pending; + return once; } async function remove(): Promise { const pending = once; once = null; - generation++; // Awaited, so a directory still being created is dropped rather than leaked. const dir = await pending?.catch(() => undefined); if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b87b95d07..6aa950640 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/alert.md": 8650, "docs/specs/auto-update.md": 1450, "docs/specs/deploy.md": 1950, - "docs/specs/dor-browser.md": 9050, + "docs/specs/dor-browser.md": 9000, "docs/specs/dor-cli.md": 6600, "docs/specs/dor-tool.md": 6250, "docs/specs/dor-tools-builtin.md": 1100, From 53281702fb224eda0ca9eebb556ecf9f873028cc Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:33:24 -0700 Subject: [PATCH 4/6] Qualify Windows temporary and recovery storage disclosures --- docs/compatible-agents.md | 2 +- docs/specs/security-local.md | 4 ++-- docs/specs/security.md | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/compatible-agents.md b/docs/compatible-agents.md index f7cbdfbaf..e58fbe60c 100644 --- a/docs/compatible-agents.md +++ b/docs/compatible-agents.md @@ -111,7 +111,7 @@ Source of truth: `CODING_AGENTS` in `lib/src/lib/coding-agents.ts`; `detectResum - **Must keep one rebuilt invocation per Surface in a host-owned, single-use record outside the persisted Session.** The renderer save path never derives or writes it. (rationale) - **Must call `beginCapture` before capture can return early.** The first call per host process clears the previous record; subsequent calls merge, preserving captures from other Windows. (rationale) -- **Must persist every detection synchronously through `createRecoveryStore`**, using `recovery.json` in the host-selected directory, an owner-only temporary file, and atomic rename. A failed write must not throw through teardown. Without a directory the store is memory-only and logs that limitation once. +- **Must persist every detection synchronously through `createRecoveryStore`**, using `recovery.json` in the host-selected directory, a temporary file with mode `0600` on Unix, and atomic rename. Windows storage permissions follow `docs/specs/security-local.md` -> "Persisted state". A failed write must not throw through teardown. Without a directory the store is memory-only and logs that limitation once. - **Must read and unlink the durable record on the first claim**, including on parse failure; if unlink fails, ignore it. Discard records older than 7 days after unlinking. Within the process, each container claims only its saved pane ids, and each entry is handed out once. (rationale) - **Must deliver claimed commands out of band on boot through `PlatformAdapter.getRecoveryCommands()`**; adapters whose hosts capture nothing may omit it. Only cold restore consumes these commands for execution; live resume never executes them. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index f44d9a372..e8aeee3de 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -203,8 +203,8 @@ buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record" under `dormouse.session`, and `vscode.setState()`, a WebviewPanel's only store — so the modes there are VS Code's, not ours, and no transcript reaches either (`docs/specs/vscode.md` -> "Serialization and restore"). Dormouse also writes -`recovery.json` under the extension's storage directory, owner-only and -temp-then-rename: one rebuilt agent-resume invocation per Surface, no buffer, +`recovery.json` in extension storage, mode `0600` on Unix +and temp-then-rename: one rebuilt agent-resume invocation per Surface, no buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record"). **The VS Code peer-link token is a local credential at rest** — diff --git a/docs/specs/security.md b/docs/specs/security.md index e7f2ad74f..131422716 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -118,6 +118,8 @@ Gaps rather than accepted risks: we intend to close them. WebSocket cookie headers are stripped, but `document.cookie` remains shared; cookie-authenticated iframe pages are unsupported ([Loopback Listeners](./security-local.md#loopback-listeners)). +- **Browser screenshots and pasted clipboard images inherit parent ACLs on Windows.** + ([Browser panes](./security-local.md#browser-panes)). - **Neither VS Code's peer-link token, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; From 52844014f8eae3dec25cbfe569316afc5421bc56 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 23:37:14 -0700 Subject: [PATCH 5/6] Qualify recovery-store mode comments by platform --- lib/src/host/recovery-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/host/recovery-store.ts b/lib/src/host/recovery-store.ts index 95f96199c..c2e5753c0 100644 --- a/lib/src/host/recovery-store.ts +++ b/lib/src/host/recovery-store.ts @@ -45,7 +45,7 @@ export interface RecoveryStore { /** * The record under `dir`, or a memory-only store when no directory was given. * - * Owner-only and temp-then-rename, because a kill during the write must not + * Owner-only on Unix and temp-then-rename, because a kill during the write must not * leave a torn record for the next start to parse — the same durability shape as * the standalone session snapshot. */ @@ -71,8 +71,8 @@ export function createRecoveryStore(dir?: string, opts: { log?: RecoveryLog } = const tmp = `${file}.${randomUUID()}.tmp`; try { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); - // Mode on create, so the bytes are never briefly world-readable; the rename - // preserves it. + // Unix mode applies before bytes are written and survives the rename. + // Windows permissions come from the containing directory. fs.writeFileSync(tmp, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); fs.renameSync(tmp, file); } catch (err) { From 99da64af69528cb14301598be5c18b90727e2864 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 2 Oct 2026 06:38:47 -0700 Subject: [PATCH 6/6] Drop speculative non-record JSON guards; qualify the Windows ACL gap The PR added guards rejecting null, numbers, and arrays where the viewer socket, the agent-browser daemon stream, and CDP deliver a JSON record, plus three tests for them. Every sender of those messages is trusted (agent-browser, CDP, Dormouse's own host) and none sends a non-record, so the guards were speculative hardening. Restore the base parsing at those sites and drop the tests. The new Windows known gap on the public /security page and in security-local.md read as if screenshots and clipboard images were exposed by default. The default %TEMP% is per-user and private; the rationale reproduced the exposure only with a shared temp parent. Reword both entries to say the files inherit that ACL and are exposed only when %TEMP% (or the capture parent) is shared or loosened, and ratchet security-local.md's budget for the qualifier. Co-Authored-By: Claude Opus 5.5 --- docs/specs/security-local.md | 4 +++- docs/specs/security.md | 3 ++- .../wall/agent-browser-connection.test.ts | 14 -------------- .../wall/agent-browser-connection.ts | 4 +--- lib/src/host/agent-browser-host.test.ts | 18 ------------------ lib/src/host/agent-browser-host.ts | 2 -- scripts/spec-word-budgets.json | 2 +- 7 files changed, 7 insertions(+), 40 deletions(-) diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index e8aeee3de..1c32e5b37 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -47,7 +47,9 @@ shell-integration scripts — the parser scans raw bytes and cannot defend it The attacker is the page inside a browser pane. -**Known gap: Windows screenshots/clipboard images inherit parent ACLs.** +**Known gap: Windows screenshots and pasted clipboard images inherit their +parent's ACL** — private under the default per-user `%TEMP%`, exposed only when +it (or the capture parent) is shared or loosened. Source of truth: `lib/src/host/private-capture-dir.ts`, `standalone/sidecar/clipboard-ops.js`, `standalone/src-tauri/src/clipboard_win.rs`. diff --git a/docs/specs/security.md b/docs/specs/security.md index 131422716..06a96ea72 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -118,7 +118,8 @@ Gaps rather than accepted risks: we intend to close them. WebSocket cookie headers are stripped, but `document.cookie` remains shared; cookie-authenticated iframe pages are unsupported ([Loopback Listeners](./security-local.md#loopback-listeners)). -- **Browser screenshots and pasted clipboard images inherit parent ACLs on Windows.** +- **Windows screenshots and pasted clipboard images inherit `%TEMP%`'s ACL:** + private by default, exposed if it is shared or loosened ([Browser panes](./security-local.md#browser-panes)). - **Neither VS Code's peer-link token, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** diff --git a/lib/src/components/wall/agent-browser-connection.test.ts b/lib/src/components/wall/agent-browser-connection.test.ts index 26a2a919a..98c3dbd13 100644 --- a/lib/src/components/wall/agent-browser-connection.test.ts +++ b/lib/src/components/wall/agent-browser-connection.test.ts @@ -143,17 +143,3 @@ describe('viewer socket connection', () => { connection.dispose(); }); }); - -describe('malformed viewer state', () => { - it('ignores non-record JSON without losing the next valid state', async () => { - const { connection } = connect(); - try { - await flush(); - for (const value of [null, false, 7, 'state', []]) { - expect(() => socket().emitMessage(JSON.stringify(value))).not.toThrow(); - } - socket().emitMessage(JSON.stringify({ type: 'status', connected: true, screencasting: true })); - expect(connection.snapshot().status).toEqual({ connected: true, screencasting: true }); - } finally { connection.dispose(); } - }); -}); diff --git a/lib/src/components/wall/agent-browser-connection.ts b/lib/src/components/wall/agent-browser-connection.ts index a7e1bb86f..6213452bc 100644 --- a/lib/src/components/wall/agent-browser-connection.ts +++ b/lib/src/components/wall/agent-browser-connection.ts @@ -208,9 +208,7 @@ export class AgentBrowserConnection { if (typeof raw !== 'string') return; let msg: ViewerState; try { - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; - msg = parsed as ViewerState; + msg = JSON.parse(raw) as ViewerState; } catch { return; } diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index da541dac8..5ad2a441e 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -689,15 +689,6 @@ describe('agent-browser host viewer', () => { // A frame's base64 body, large enough to be told from a control message by size. const frame = (fill: number, deviceWidth = 800) => ({ type: 'frame', data: Buffer.alloc(13_000, fill).toString('base64'), metadata: { deviceWidth, deviceHeight: 600 } }); - it('ignores malformed JSON values from the daemon and resumes valid state', async () => { - const daemon = await fakeServer(); - const viewer = await view(daemon.port); - await daemon.connected(); - for (const value of [null, false, 7, 'state', []]) daemon.send(JSON.stringify(value)); - daemon.send({ type: 'status', connected: true, screencasting: false }); - await vi.waitFor(() => expect(viewer.states).toEqual([{ type: 'status', connected: true, screencasting: false }])); - }); - it('relays the daemon stream, dropping its unchanged re-broadcasts and decoding each changed frame once', async () => { running(session); const daemon = await fakeServer(); @@ -877,15 +868,6 @@ describe('agent-browser host viewer', () => { const cdpVerbs = () => spawnMock.mock.calls.map((call) => [(call[1] as string[]).slice(2), call[2]]); - it('ignores malformed JSON values from CDP and resumes valid page events', async () => { - const cdp = await fakeBrowser(['one']); - const { viewer } = await headedView(cdp); - await vi.waitFor(() => expect(viewer.states).toHaveLength(1)); - for (const value of [null, false, 7, 'state', []]) cdp.send(JSON.stringify(value)); - cdp.send({ method: 'Target.targetInfoChanged', params: { targetInfo: { targetId: 'one', type: 'page', url: 'https://two.example/', title: 'Two' } } }); - await vi.waitFor(() => expect(viewer.states.at(-1)).toEqual({ type: 'page', url: 'https://two.example/', title: 'Two' })); - }); - it('follows a headed window\'s page over its browser\'s CDP, and sends it no frames', async () => { const cdp = await fakeBrowser(['one']); const { viewer, daemon } = await headedView(cdp); diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 03a48dbba..1628b6e4c 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -147,7 +147,6 @@ function cdpCalls(socket: WebSocket, event: (method: string, params: Record