From 5053538df92c8757c8a8a0b8162266c5b7ca5781 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 18:34:53 -0700 Subject: [PATCH 01/18] Check every webview token the agent-browser host puts on the command line The host's `command()` matched only `args[0]` against a verb allowlist, then ran `--session ...args`. agent-browser reads launch options anywhere on its command line (verified against 0.31.1: `open about:blank --executable-path /x` tries to launch /x), so an allowlisted verb from the webview could carry `--executable-path`, `--args`, `--extension`, `--init-script`, `--profile`, `--state` or `--proxy` past the `binaryPath` gate. The allowlist also passed `close --all`, `tab new `, and `screenshot ` (an image written over any user-writable file). `command()` now accepts exactly one argv shape per verb (`WEBVIEW_COMMANDS`): the controller's chrome, tab and Display actions, `get cdp-url`, and a bare `close`. Sessions must not be option- or path-shaped on every entry point (a session names `/.pid`, whose pid a relaunch SIGTERMs), and `open`/pop-out/pop-in launch URLs must be absolute. The webview-side `AGENT_BROWSER_ALLOWED_SUBCOMMANDS` constant is gone; the host owns the table. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 14 ++- docs/specs/dor-browser.rationale.md | 4 +- .../wall/agent-browser-surface-controller.ts | 4 +- .../components/wall/tool-browser-session.ts | 4 +- lib/src/host/agent-browser-host.test.ts | 106 +++++++++++++++- lib/src/host/agent-browser-host.ts | 116 +++++++++++++----- lib/src/lib/platform/types.ts | 16 +-- 7 files changed, 209 insertions(+), 55 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 5f146474c..296ac02c1 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -387,7 +387,7 @@ sidecar/Rust adapter. | Method | Contract | | --- | --- | -| `agentBrowserCommand` | Allowlisted CLI subcommands (`AGENT_BROWSER_ALLOWED_SUBCOMMANDS` in `lib/src/lib/platform/types.ts`); host-side `get` limited to `get cdp-url`. | +| `agentBrowserCommand` | One fixed argv shape per verb: `open `, `back`/`forward`/`reload`, bare `close`, `get cdp-url`, `tab `, `tab close `, `set viewport `, `set device `. | | `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). | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `agentBrowserEdit` | select-all/copy/cut via fixed host-owned JS plus an OS clipboard write. | @@ -395,9 +395,12 @@ sidecar/Rust adapter. | `agentBrowserOpen` | Spawn a GUI-owned session for iframe -> agent-browser; resolves when the daemon is up, not when the page loads ([Pop-Out](#pop-out)). | | `agentBrowserPopOut` / `agentBrowserPopIn` | Headed/headless relaunch. | -**Host-side validation is the security boundary:** every `agentBrowserCommand` -implementation must enforce the shared allowlist; the CLI is not trusted to -pre-filter arguments. +**Host-side validation is the security boundary, and it checks every token, +never only the verb** — agent-browser reads launch options anywhere on its +command line (rationale). **Must refuse an argv that is not exactly one of those +shapes, an option- or path-shaped session name, and a launch URL that is not +absolute**, on every entry point; the webview is not trusted to pre-filter. +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 @@ -416,7 +419,8 @@ 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`), +Source of truth: `lib/src/host/agent-browser-host.ts` (`WEBVIEW_COMMANDS`, +`isSessionName`, `runWithBinaryFallback`), `dor-lib-common/src/agent-browser.ts` (`isAllowedAgentBrowserBinary`), `lib/src/host/private-capture-dir.ts`, `lib/src/host/browser-host-shared.ts`, `lib/src/host/browser-stream-guard.ts`, `vscode-ext/src/agent-browser-host.ts`, `vscode-ext/src/webview-html.ts`, diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 5502d91b6..df7e53803 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -74,7 +74,9 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **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 `binaryPath` needs a gate of its own.** The subcommand allowlist covers arguments, not the executable: `streamStatus`, `open` and `popOut` supply their own args and each take a `binaryPath`, so an allowlist on subcommands 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 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. + +**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 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. diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 871ab4b73..aa4a8eec7 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -396,8 +396,8 @@ export class AgentBrowserSurfaceController { }, }; - // Native history nav — issued like tab actions (allowlisted in - // agentBrowserCommand). + // Native history nav — issued like tab actions (fixed argv shapes the + // host's agentBrowserCommand accepts). this.chromeActions = { navigate: (url) => { if (url) this.runAgentBrowser(['open', url]); }, back: () => this.runAgentBrowser(['back']), diff --git a/lib/src/components/wall/tool-browser-session.ts b/lib/src/components/wall/tool-browser-session.ts index 94c1b8b12..bc2cdd130 100644 --- a/lib/src/components/wall/tool-browser-session.ts +++ b/lib/src/components/wall/tool-browser-session.ts @@ -26,8 +26,8 @@ export async function attachAgentBrowserSession({ refreshSurface: (surfaceId: string, patch: Record) => void; }): Promise { if (!platform.agentBrowserCommand) return; - // 'open' is on the host's subcommand allowlist; the CLI boots the daemon/browser - // if it isn't already running. + // `open ` is one of the host's fixed webview argv shapes; the CLI boots + // the daemon/browser if it isn't already running. const opened = await platform.agentBrowserCommand(session, ['open', url], binaryPath); if (opened.exitCode !== 0) { refreshSurface(surfaceId, { session }); diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index b6b10bd9b..217723a8b 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -470,7 +470,7 @@ describe('agent-browser host screenshot transport', () => { enqueueSpawnResults([{}]); const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - await host.command('sess', ['tab', 'list'], '/usr/bin/curl'); + await host.command('sess', ['reload'], '/usr/bin/curl'); expect(spawnMock).toHaveBeenCalledTimes(1); // Fell through to the host's own candidate rather than spawning curl. @@ -482,12 +482,114 @@ describe('agent-browser host screenshot transport', () => { spawnMock.mockReset(); enqueueSpawnResults([{}]); const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); - await host.command('sess', ['tab', 'list'], candidate); + await host.command('sess', ['reload'], candidate); expect(spawnMock.mock.calls[0][0]).toBe(candidate); } }); }); +// Every webview token lands on agent-browser's command line, and agent-browser +// reads launch options anywhere on it (`open --executable-path x` launches +// x — checked against 0.31.1), so matching only the verb would let an +// allowlisted command walk around the `binaryPath` gate. +describe('agent-browser host webview argv', () => { + const originalSocketDir = process.env.AGENT_BROWSER_SOCKET_DIR; + + beforeEach(() => { + spawnMock.mockReset(); + process.env.AGENT_BROWSER_SOCKET_DIR = mkdtempSync(join(tmpdir(), 'dormouse-ab-argv-test-')); + }); + + afterEach(() => { + if (originalSocketDir === undefined) delete process.env.AGENT_BROWSER_SOCKET_DIR; + else process.env.AGENT_BROWSER_SOCKET_DIR = originalSocketDir; + }); + + it('runs exactly the argv shapes the webview sends', async () => { + const shapes = [ + ['open', 'https://example.com/path?q=1'], + ['open', 'about:blank'], + ['back'], + ['forward'], + ['reload'], + ['close'], + ['get', 'cdp-url'], + ['tab', 't2'], + ['tab', 'close', 't2'], + ['set', 'viewport', '1280', '720', '2'], + ['set', 'viewport', '801', '599', '1.100000023841858'], + ['set', 'device', 'iPhone 16 Pro'], + ['set', 'device', 'iPad (gen 11)'], + ]; + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + for (const args of shapes) { + spawnMock.mockReset(); + enqueueSpawnResults([{}]); + expect(await host.command('dormouse.1.gui-abc', args)).toEqual({ exitCode: 0, stdout: '', stderr: '' }); + expect(spawnMock.mock.calls[0][1]).toEqual(['--session', 'dormouse.1.gui-abc', ...args]); + } + }); + + it('refuses any other argv, including an allowlisted verb carrying options', async () => { + const refused: unknown[] = [ + ['open', 'https://example.com/', '--executable-path', '/tmp/evil'], + ['open', '--executable-path=/tmp/evil'], + ['open', ' https://example.com/'], + ['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/', + ]; + 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/); + } + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('refuses an option-shaped or path-shaped session on every entry point', async () => { + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + 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.streamStatus(session)).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); + } + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('refuses a launch URL that is not an absolute URL, 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 absolute url is required' }); + 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' }); + expect(calls).toContainEqual(['--session', 'dormouse.1.default', '--headed', 'open', 'about:blank']); + expect(calls.flat()).not.toContain('--executable-path=/tmp/evil'); + }); +}); + describe('agent-browser host edit ops', () => { beforeEach(() => { spawnMock.mockReset(); }); diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 5dfc68522..c539a56fe 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -15,8 +15,8 @@ * 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. Subcommands are allowlisted; not a - * general exec channel. + * actions, navigation, and teardown. Only fixed argv shapes pass + * (`WEBVIEW_COMMANDS`); 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. @@ -54,20 +54,75 @@ import { import { randomBytes } from 'crypto'; import { isAllowedAgentBrowserBinary } from '../lib/agent-browser-binary'; import { type AgentBrowserTab, parseAgentBrowserTabs } from '../lib/agent-browser-tab'; -import { - AGENT_BROWSER_ALLOWED_SUBCOMMANDS, - type AgentBrowserCommandResult, - type AgentBrowserEditOp, - type AgentBrowserEditResult, - type AgentBrowserOpenResult, - type AgentBrowserPopResult, - type AgentBrowserScreenshotResult, - type AgentBrowserStreamStatusResult, +import type { + AgentBrowserCommandResult, + AgentBrowserEditOp, + AgentBrowserEditResult, + AgentBrowserOpenResult, + AgentBrowserPopResult, + AgentBrowserScreenshotResult, + AgentBrowserStreamStatusResult, } from '../lib/platform/types'; import { privateCaptureDir } from './private-capture-dir'; import { editScript, generateGuiSession, jpegQuality } from './browser-host-shared'; -const ALLOWED_SUBCOMMANDS = new Set(AGENT_BROWSER_ALLOWED_SUBCOMMANDS); +// Every token the webview hands this host — a session, a command's arguments, a +// launch URL — lands on agent-browser's command line, and agent-browser reads +// its launch options anywhere on that line (`open --executable-path x` +// launches x; `close --all` closes every session). So each token is checked +// against the one shape its position takes, never the verb alone +// (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). + +/** A session name the host will put after `--session` and into a state-file + * path: never option-shaped, never a path separator or control character. */ +function isSessionName(value: unknown): value is string { + return typeof value === 'string' && /^(?!-)[^/\\\x00-\x1f\x7f]{1,200}$/.test(value); +} + +/** An absolute URL, untrimmed — a scheme must start with a letter, so it can + * never be read as an option. */ +function isAbsoluteUrl(value: unknown): value is string { + if (typeof value !== 'string' || value !== value.trim()) return false; + try { + new URL(value); + return true; + } catch { + return false; + } +} + +const TAB_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +// `tab ` selects; these words are the verb's own operations instead. +const TAB_OPERATIONS = new Set(['list', 'new', 'close']); +const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9 ()._-]{0,63}$/; +const isPositiveNumber = (value: string) => /^\d{1,6}(\.\d{1,20})?$/.test(value) && Number(value) > 0; + +/** The argv the webview sends through `command`, one shape per verb: the + * controller's chrome, tab, and Display actions, and a kill/swap `close`. */ +const WEBVIEW_COMMANDS: Record boolean> = { + open: (args) => args.length === 1 && isAbsoluteUrl(args[0]), + back: (args) => args.length === 0, + forward: (args) => args.length === 0, + reload: (args) => args.length === 0, + close: (args) => args.length === 0, + // The popped-out pane's CDP observer. + get: (args) => args.length === 1 && args[0] === 'cdp-url', + tab: (args) => args.length === 1 + ? TAB_REF.test(args[0]) && !TAB_OPERATIONS.has(args[0]) + : args.length === 2 && args[0] === 'close' && TAB_REF.test(args[1]), + set: (args) => args[0] === 'viewport' + ? args.length === 4 && args.slice(1).every(isPositiveNumber) + : args[0] === 'device' && args.length === 2 && DEVICE_NAME.test(args[1]), +}; + +/** Whether `args` is exactly one of the `WEBVIEW_COMMANDS` shapes. `args` is + * typed but arrives from webview IPC unvalidated. */ +function isWebviewCommand(args: unknown): args is string[] { + if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return false; + const [verb, ...rest] = args as string[]; + // Own keys only: a verb of `constructor` must not find the prototype's. + return Object.prototype.hasOwnProperty.call(WEBVIEW_COMMANDS, verb) && WEBVIEW_COMMANDS[verb](rest); +} const STREAM_PORT_READ_ATTEMPTS = 4; const STREAM_PORT_READ_DELAY_MS = 150; @@ -133,8 +188,8 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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 subcommand allowlist in `command()` does - // not cover it: `streamStatus`, `open` and `popOut` supply their own args and + // interaction on the next launch. The argv check in `command()` does not + // cover it: `streamStatus`, `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. @@ -189,7 +244,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser function usableRelaunchUrl(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); - if (!trimmed || trimmed === 'about:blank') return undefined; + if (!isAbsoluteUrl(trimmed) || trimmed === 'about:blank') return undefined; return trimmed; } @@ -383,21 +438,18 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } async function command(session: string, args: string[], binaryPath?: string): Promise { - if (typeof session !== 'string' || !session) { - return { exitCode: 1, stdout: '', stderr: 'session is required' }; - } - const subcommand = args[0]; - if (!subcommand || !ALLOWED_SUBCOMMANDS.has(subcommand)) { - return { exitCode: 1, stdout: '', stderr: `agent-browser subcommand '${subcommand ?? ''}' is not allowed from the webview` }; + if (!isSessionName(session)) { + return { exitCode: 1, stdout: '', stderr: 'a valid session name is required' }; } - if (subcommand === 'get' && args[1] !== 'cdp-url') { - return { exitCode: 1, stdout: '', stderr: `agent-browser get '${args[1] ?? ''}' is not allowed from the webview` }; + if (!isWebviewCommand(args)) { + 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 (subcommand === 'close') { + if (args[0] === 'close') { poppedOutSessions.delete(session); relaunchGenerations.delete(session); } @@ -405,8 +457,8 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } async function edit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise { - if (typeof session !== 'string' || !session) { - return { ok: false, error: 'session is required' }; + if (!isSessionName(session)) { + return { ok: false, error: 'a valid session name is required' }; } const script = editScript(op); if (!script) { @@ -460,8 +512,8 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string, ): Promise { - if (typeof session !== 'string' || !session) { - return { ok: false, error: 'session is required' }; + if (!isSessionName(session)) { + return { ok: false, error: 'a valid session name is required' }; } const format = opts.format === 'png' ? 'png' : 'jpeg'; const ext = format === 'png' ? 'png' : 'jpg'; @@ -512,7 +564,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } async function streamStatus(session: string, binaryPath?: string): Promise { - if (typeof session !== 'string' || !session) return { ok: false, error: 'session is required' }; + if (!isSessionName(session)) return { ok: false, error: 'a valid session name is required' }; const wsPort = await readStreamPort(session, binaryPath); if (!wsPort) return { ok: false, error: 'stream port unavailable' }; return { ok: true, wsPort }; @@ -523,7 +575,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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 }, binaryPath?: string): Promise { - if (typeof url !== 'string' || !url) return { ok: false, error: 'url is required' }; + if (!isAbsoluteUrl(url)) return { ok: false, error: 'an absolute url is required' }; const session = generateGuiSession(); const args = ['--session', session, ...(opts?.headed ? ['--headed'] : []), 'open', url]; // A headed spawn is a real OS window — track it before the launch so a @@ -554,7 +606,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string, ): Promise { - if (typeof session !== 'string' || !session) return { ok: false, error: 'session is required' }; + if (!isSessionName(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}`); @@ -606,7 +658,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { url?: string }, binaryPath?: string, ): Promise { - if (typeof session !== 'string' || !session) return { ok: false, error: 'session is required' }; + if (!isSessionName(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}`); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 9339f0ca8..cf4bcccc4 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -95,14 +95,6 @@ export interface AgentBrowserCommandResult { stderr: string; } -/** Subcommands the host will run on the webview's behalf — this is a narrow - * channel for tab actions, screen-mode resizing (`set viewport` / `set - * device`), HiDPI frame capture (`screenshot`), navigation (`open `, - * `reload` / `back` / `forward`), and session teardown, not a general exec - * path. `get` is limited host-side to `get cdp-url` for CDP event - * subscription while a browser is popped out. */ -export const AGENT_BROWSER_ALLOWED_SUBCOMMANDS = ['tab', 'set', 'screenshot', 'open', 'reload', 'back', 'forward', 'close', 'get'] as const; - export interface AgentBrowserScreenshotResult { ok: boolean; /** Raw image bytes (transferred over the host↔webview channel via structured @@ -366,8 +358,10 @@ export interface PlatformAdapter { runWorkbenchCommand?(command: VSCodeWorkbenchCommand): void; // agent-browser surface support (see docs/specs/dor-browser.md). - // Runs the user's agent-browser binary against a session; the host validates - // args[0] against AGENT_BROWSER_ALLOWED_SUBCOMMANDS. `binaryPath` is the + // Runs the user's agent-browser binary against a session — only the fixed + // argv shapes the host's `WEBVIEW_COMMANDS` table accepts (tab select/close, + // `set viewport`/`set device`, `open `, back/forward/reload, `close`, + // `get cdp-url`), 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; @@ -385,7 +379,7 @@ export interface PlatformAdapter { // changed stream frame as its final, lower-resolution image. agentBrowserScreenshot?(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise; // Reads the current stream port for an already-running session. This is a - // purpose-built status channel, not part of agentBrowserCommand's allowlist, + // purpose-built status channel, not one of agentBrowserCommand's argv shapes, // so restored panels can recover from a stale persisted wsPort after reload. agentBrowserStreamStatus?(session: string, binaryPath?: string): Promise; // The WebSocket URL for a session's stream port. Hosts whose webview origin From 72b5e048bc5a365551f0894b76834453c0f60544 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 18:36:42 -0700 Subject: [PATCH 02/18] Debounce sync-to-pane through the pane observer, not the window resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onWindowResize` issued `set viewport` on every window `resize` event while sync was engaged — about 60 per second per synced pane during a window or VS Code sash drag, one host spawn each — although the pane's ResizeObserver already delivers the same size change debounced by 200ms. The window listener exists only for display-scale changes, which resize nothing; it now re-syncs only when devicePixelRatio differs from the last issued viewport, and still refreshes the cached pane size and screen snapshot on every event. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 4 +- .../agent-browser-surface-controller.test.ts | 53 +++++++++++++++++++ .../wall/agent-browser-surface-controller.ts | 8 ++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 296ac02c1..2eb102860 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -174,7 +174,9 @@ its presentation glyph. Resolution controls apply to both screencast providers, as GUI wrappers around native commands: **Resize with pane** is Dormouse-owned sync issuing -`set viewport ` on resize; **Fixed** issues +`set viewport ` once a pane resize settles (200ms); +**only a DPR change re-syncs at once**, off the window `resize` event, which +fires every frame of a drag. **Fixed** issues `set viewport ` or `set device ` from the modal's registry. **Only `syncEngaged` persists** — device/custom viewport state lives in 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 a2dd166c4..cbcca2e7e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -253,6 +253,59 @@ describe('provisional stream paint', () => { }); }); +describe('sync-to-pane on window resize', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('leaves a size change to the debounced pane observer and re-syncs a DPR change at once', async () => { + const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserCommand = command; + setPlatform(platform); + // The pane observer fires whenever the test resizes the pane. + const observers: ResizeObserverCallback[] = []; + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { observers.push(callback); } + observe() {} + disconnect() {} + }); + let dpr = 1; + vi.spyOn(window, 'devicePixelRatio', 'get').mockImplementation(() => dpr); + const sink = makeSink(); + let size = { width: 800, height: 600 }; + sink.viewport.getBoundingClientRect = () => ({ ...size }) as DOMRect; + const resizePane = (width: number, height: number) => { + size = { width, height }; + observers.at(-1)?.([{ contentRect: { width, height } } as ResizeObserverEntry], {} as ResizeObserver); + window.dispatchEvent(new Event('resize')); + }; + const viewports = () => command.mock.calls + .map((call) => (call as unknown as [string, string[]])[1]) + .filter((args) => args[0] === 'set' && args[1] === 'viewport'); + + const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 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']); + const issued = viewports().length; + + // A window drag: `resize` every frame, one settled viewport after the debounce. + for (let w = 801; w <= 860; w++) { + resizePane(w, 600); + await vi.advanceTimersByTimeAsync(16); + } + expect(viewports()).toHaveLength(issued); + await vi.advanceTimersByTimeAsync(200); + expect(viewports().slice(issued)).toEqual([['set', 'viewport', '860', '600', '1']]); + + // A display-scale change resizes nothing, so only the window signal sees it. + dpr = 2; + window.dispatchEvent(new Event('resize')); + expect(viewports().at(-1)).toEqual(['set', 'viewport', '860', '600', '2']); + }); +}); + describe('parking', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index aa4a8eec7..86b22f3fe 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -484,7 +484,13 @@ export class AgentBrowserSurfaceController { // A display-scale (DPR) change doesn't resize the pane, so ResizeObserver // misses it; refresh the cache off the window-resize signal too. this.refreshPaneSize(); - if (this.syncEngaged) this.issueSyncToPane(); + // Only a DPR change is this listener's to sync. A size change also reaches + // the pane's debounced ResizeObserver, and a window drag fires `resize` + // every frame — one `set viewport` spawn each, per synced pane. + const issued = this.lastIssued; + if (this.syncEngaged && issued && Math.abs(issued.dpr - (window.devicePixelRatio || 1)) > 0.001) { + this.issueSyncToPane(); + } this.publishScreen(); }; From 47f861784ef92d4c98d911a2965a40e216734247 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 18:38:29 -0700 Subject: [PATCH 03/18] Paint the stream provisionally after keyboard input, not only pointer input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `send()` opened the provisional (stream-paint) window only for `input_mouse`, so each keystroke's echo waited a whole crisp capture round trip (~120ms plus up to ~180ms of loop pacing in a burst) while a hover repainted from the stream in ~50ms. Every input message now opens the window — keys, pasted text replayed as keys, and the host-routed select-all/copy/cut chords — and the crisp capture still sharpens the frame 250ms after the last input. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 4 +- docs/specs/dor-browser.rationale.md | 4 +- .../wall/agent-browser-screenshot-loop.ts | 6 +-- .../agent-browser-surface-controller.test.ts | 49 +++++++++++++++++++ .../wall/agent-browser-surface-controller.ts | 16 ++++-- 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 2eb102860..79993c3b6 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -308,8 +308,8 @@ refreshes only when the driving command completes** until `tabs` refreshes, even at the same URL. **Two-stage paint.** A changed stream JPEG paints at once as a CSS-resolution -**provisional frame** — the first image, and 250ms after pointer input -(continuous movement extends the window) — then a crisp device-resolution +**provisional frame** — the first image, and 250ms after any input: pointer, +keys, pasted text, editing chords (continuous input extends the window) — then a crisp device-resolution `agentBrowserScreenshot` replaces it (rationale): - **Both paths are latest-only.** diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index df7e53803..4c4beb44b 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -42,7 +42,9 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a **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. -**What the two-stage split buys.** Three things at once: pointer feedback that does not wait on a screenshot child-process round trip, a resting image sharp on HiDPI, and an idle animated page that does not pay to decode the stream continuously. Either path alone gives up one of the three. +**What the two-stage split buys.** Three things at once: input feedback that does not wait on a screenshot child-process round trip, a resting image sharp on HiDPI, and an idle animated page that does not pay to decode the stream continuously. Either path alone gives up one of the three. + +**Why keys open the window too.** A keystroke's echo is the most latency-sensitive paint there is; outside the window it waited a whole crisp capture — ~120ms, plus up to ~180ms of loop pacing in a burst — while a hover repainted from the stream in ~50ms. On HiDPI the typed text is CSS-resolution until 250ms after the last key, then sharpens, as hover already did. **Why no crisp capture starts inside the provisional window.** A host screenshot round trip is ~120ms against a ~20Hz stream, so *every* capture started while provisional frames are still landing is superseded before it resolves; the shots skipped would never have drawn anything. diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 24d81e04d..051a341eb 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -24,7 +24,7 @@ export interface ScreenshotLoopDeps { * predates a newer provisional paint is stale and must not overwrite it. */ getProvisionalGeneration?: () => number; /** `performance.now()` timestamp through which provisional stream paints are - * still expected (continued pointer input pushes it out). While it is in the + * still expected (continued input pushes it out). While it is in the * future, a capture is certain to be superseded mid-flight and dropped, so the * loop waits it out and takes one settled shot at the end instead of burning a * host round trip per pulse. Absent ⇒ no window; capture on the pacing rule @@ -135,7 +135,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { // A provisional stream frame painted during this capture is visibly newer. // Do not let the stale crisp result overwrite it. Mere `dirty` pulses do not // suppress drawing — an idle animated page still needs periodic crisp frames - // even without pointer input. + // even without input. const stale = (deps.getProvisionalGeneration?.() ?? 0) !== provisionalAtStart; if (res.ok && res.bytes) { if (stale) { @@ -186,7 +186,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { if (timer === undefined) { timer = setTimeout(() => { timer = undefined; - // Re-enter `schedule`, not `take`: continued pointer input pushes the + // Re-enter `schedule`, not `take`: continued input pushes the // provisional window past this timer, and re-checking re-arms for the new // end instead of spending a shot that window would supersede. if (dirty && !inFlight) schedule(); 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 cbcca2e7e..b32ff1ef4 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -200,6 +200,55 @@ describe('provisional stream paint', () => { expect(createImageBitmap).toHaveBeenCalledTimes(1); }); + it('paints the stream frame after keys, pasted text and editing chords, not only after pointer input', async () => { + let now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserScreenshot = vi.fn(() => new Promise(() => {})); + platform.agentBrowserEdit = vi.fn(async () => ({ ok: true })); + platform.readClipboardText = vi.fn(async () => 'pasted'); + setPlatform(platform); + vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 40, height: 30, close: vi.fn() }))); + const sink = makeSink(); + sink.canvas.getContext = vi.fn(() => ({ drawImage: vi.fn() })) as unknown as typeof sink.canvas.getContext; + + const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); + controller.attachView(sink); + await flushMicrotasks(); + const frame = async (label: string) => { + streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa(label) })); + await flushMicrotasks(); + }; + await frame('first'); + expect(controller.snapshot().hasFrame).toBe(true); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + + // At rest, a changed frame only pulses the crisp loop. + now += PROVISIONAL_INPUT_WINDOW_MS + 1; + await frame('idle'); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + + // A keystroke's echo paints straight from the stream. + controller.handleKeyDownLike({ key: 'a', code: 'KeyA', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }); + await frame('typed'); + expect(createImageBitmap).toHaveBeenCalledTimes(2); + + // So does a paste, replayed as key input once the clipboard read resolves. + now += PROVISIONAL_INPUT_WINDOW_MS + 1; + controller.handleKeyDownLike({ key: 'v', code: 'KeyV', ctrlKey: true, metaKey: false, altKey: false, shiftKey: false }); + await flushMicrotasks(); + expect(platform.readClipboardText).toHaveBeenCalled(); + await frame('pasted'); + expect(createImageBitmap).toHaveBeenCalledTimes(3); + + // And a select-all, which runs through the host rather than the stream. + 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); + await frame('selected'); + expect(createImageBitmap).toHaveBeenCalledTimes(4); + }); + it('repaints a byte-identical crisp capture over a provisional paint', async () => { // A provisional paint puts blurry pixels on the canvas without going through // the screenshot loop, so the loop's byte-dedup no longer describes what is on diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 86b22f3fe..bcc0eb788 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -51,8 +51,9 @@ import { // immediately, so quick visibility flips — or a StrictMode unmount→remount — // don't tear down and rebuild the stream connection. export const HIDDEN_PARK_DELAY_MS = 1000; -/** Keep low-latency stream painting active briefly after pointer input. Continuous - * movement extends the window; idle animated pages stay on the cheaper crisp path. */ +/** Keep low-latency stream painting active briefly after input — pointer, keys, + * pasted text, editing chords. Continuous input extends the window; idle + * animated pages stay on the cheaper crisp path. */ export const PROVISIONAL_INPUT_WINDOW_MS = 250; // The high-rate `[ab-panel]` stream/screenshot diagnostics fire per frame @@ -1478,12 +1479,16 @@ export class AgentBrowserSurfaceController { } send(payload: Record): void { - if (payload.type === 'input_mouse') { - this.provisionalUntil = performance.now() + PROVISIONAL_INPUT_WINDOW_MS; - } + // Typing is the most latency-sensitive input there is: its echo must not + // wait a crisp capture round trip any more than a hover does. + if (typeof payload.type === 'string' && payload.type.startsWith('input_')) this.openProvisionalWindow(); this.connection?.send(payload); } + private openProvisionalWindow(): void { + this.provisionalUntil = performance.now() + PROVISIONAL_INPUT_WINDOW_MS; + } + selectTab(tab: StreamTab): void { if (!tab.active) this.runAgentBrowser(['tab', tab.tabId]); } @@ -1556,6 +1561,7 @@ export class AgentBrowserSurfaceController { const platform = this.platform; const session = this.session; if (op && platform.agentBrowserEdit && session) { + this.openProvisionalWindow(); platform.agentBrowserEdit(session, op, this.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)); From c26f119aec712f798f516064ff6b169dbc3ae855 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 18:44:08 -0700 Subject: [PATCH 04/18] Paint the loading page while a crisp capture waits behind a blocking open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-browser open` holds the daemon's queue until the page loads (up to 25s). A crisp capture issued meanwhile waits behind it, and once the pane had a frame the stream only pulsed the loop, so the canvas stayed on the previous page for the whole load. The 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — stacking blocked CLI processes and, on VS Code, racing the session's one capture file (the first reply's unlink could delete the second capture). - The screenshot loop reports `captureOverdue()` once a capture has been out for twice the average round trip (at least 400ms), and the controller paints stream frames provisionally meanwhile; the existing stale guards keep the crisp shot owed until `open` returns. - The loop never re-issues a capture while the host call is unresolved; the watchdog only warns. An overdue round trip is clamped before it enters the pacing average, so the next slow load is overdue just as soon. - The agent-browser host keeps one capture per session in flight and joins a concurrent request to it, which also covers the webview adapters re-asking after their own reply timeouts (VS Code 10s, standalone 30s). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 11 ++- docs/specs/dor-browser.rationale.md | 2 + .../agent-browser-screenshot-loop.test.ts | 52 ++++++++++ .../wall/agent-browser-screenshot-loop.ts | 52 ++++++---- .../agent-browser-surface-controller.test.ts | 35 +++++++ .../wall/agent-browser-surface-controller.ts | 5 +- lib/src/host/agent-browser-host.test.ts | 42 ++++++++ lib/src/host/agent-browser-host.ts | 95 +++++++++++-------- scripts/spec-word-budgets.json | 2 +- 9 files changed, 235 insertions(+), 61 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 79993c3b6..bb4e599c5 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -308,12 +308,17 @@ refreshes only when the driving command completes** until `tabs` refreshes, even at the same URL. **Two-stage paint.** A changed stream JPEG paints at once as a CSS-resolution -**provisional frame** — the first image, and 250ms after any input: pointer, -keys, pasted text, editing chords (continuous input extends the window) — then a crisp device-resolution +**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): - **Both paths are latest-only.** - **No capture may start inside the provisional window** (rationale). +- **A capture is overdue past twice the average round trip, at least 400ms**, + and **is never re-issued while its host call is unresolved**: it is queued + behind a blocking daemon command such as a page-loading `open`, which would + otherwise hold the previous page on screen for the whole load (rationale). - **Must leave the loop dirty when capture or bitmap decode becomes stale** (rationale). Pinned by `agent-browser-screenshot-loop.test.ts`. - **Any canvas writer but the crisp loop must bump the draw generation** in its @@ -390,7 +395,7 @@ sidecar/Rust adapter. | Method | Contract | | --- | --- | | `agentBrowserCommand` | One fixed argv shape per verb: `open `, `back`/`forward`/`reload`, bare `close`, `get cdp-url`, `tab `, `tab close `, `set viewport `, `set device `. | -| `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). | +| `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 in flight**: a request made meanwhile joins it, since the webview re-asks when its adapter times out. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `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. | diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 4c4beb44b..e2aa014fe 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -48,6 +48,8 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a **Why no crisp capture starts inside the provisional window.** A host screenshot round trip is ~120ms against a ~20Hz stream, so *every* capture started while provisional frames are still landing is superseded before it resolves; the shots skipped would never have drawn anything. +**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. The adapters' own reply timeouts (VS Code 10s, standalone 30s) re-ask the same way, which is why the host, not the loop, joins concurrent captures. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. + **Why a stale-dropped capture must leave the loop dirty.** A provisional frame can supersede the host capture or its pending bitmap decode. Nothing is guaranteed to pulse the loop again: a single pointer move over a static page pulses exactly once, and that one pulse is consumed by the very capture the provisional paint supersedes — leaving the pane on the blurry provisional frame until the page happens to change on its own. **Why every non-crisp painter must bump the draw generation.** The byte-dedup compares an incoming capture against the last crisp draw. A resting page whose crisp bytes match that draw dedups to a no-op and strands the pane on the blurry provisional frame; a freshly re-attached canvas mounts blank and has the same problem. 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 516108615..817093368 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts @@ -237,3 +237,55 @@ describe('screenshot loop backpressure', () => { loop.dispose(); }); }); + +describe('screenshot loop behind a blocking command', () => { + // `open` holds the daemon's queue until the page loads (up to 25s), so a + // capture issued meanwhile waits behind it. That is not a wedged host. + it('reports the capture overdue and never issues a second one while it waits', async () => { + const releases: Array<(res: AgentBrowserScreenshotResult) => void> = []; + const screenshot = vi.fn(() => new Promise((resolve) => { releases.push(resolve); })); + setScreenshot(screenshot as unknown as PlatformAdapter['agentBrowserScreenshot']); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const draw = vi.fn(); + const loop = createScreenshotLoop({ + getSession: () => 'sess', + getBinaryPath: () => undefined, + isCapable: () => true, + draw, + }); + + loop.pulse(); + await vi.advanceTimersByTimeAsync(300); + expect(screenshot).toHaveBeenCalledTimes(1); + expect(loop.captureOverdue()).toBe(false); + + // Past twice the usual round trip (floored at 400ms): the stream is all the + // pane has to show the page loading. + await vi.advanceTimersByTimeAsync(500); + expect(loop.captureOverdue()).toBe(true); + + // The page keeps changing for the rest of the load; a second capture would + // only queue behind the first. + for (let i = 0; i < 20; i++) { + loop.pulse(); + await vi.advanceTimersByTimeAsync(1000); + } + expect(screenshot).toHaveBeenCalledTimes(1); + + // `open` returns: the capture lands, and the owed follow-up starts at once. + releases[0]({ ok: true, bytes: new Uint8Array([1]), mime: 'image/jpeg' }); + await vi.advanceTimersByTimeAsync(0); + expect(screenshot).toHaveBeenCalledTimes(2); + expect(loop.captureOverdue()).toBe(false); + + // The 20s wait timed the page load, not a capture, so a second slow load is + // overdue just as soon. + await vi.advanceTimersByTimeAsync(500); + expect(loop.captureOverdue()).toBe(true); + releases[1]({ ok: true, bytes: new Uint8Array([2]), mime: 'image/jpeg' }); + await vi.advanceTimersByTimeAsync(10); + expect(draw).toHaveBeenCalledTimes(1); + + loop.dispose(); + }); +}); diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 051a341eb..0a9cee4c3 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -38,9 +38,17 @@ export interface ScreenshotLoopDeps { export interface ScreenshotLoop { /** A "page changed" signal — schedule a fresh shot (coalesced + throttled). */ pulse(): void; + /** Whether the capture in flight has been outstanding past twice the usual + * round trip (and at least 400ms): queued behind a blocking daemon command, + * such as an `open` waiting on a page load. Until it answers, only the + * stream shows what the page is doing. */ + captureOverdue(): boolean; dispose(): void; } +const OVERDUE_FLOOR_MS = 400; +const STALL_WARNING_MS = 8000; + /** * Display crisp HiDPI screenshots, paced by stream-frame "pulses". The * screencast is CSS-resolution only (Chromium's `Page.startScreencast` ignores @@ -59,6 +67,11 @@ export interface ScreenshotLoop { * settled shot at its end rather than one per pulse. Whatever the loop drops — a * deferred shot or one superseded mid-flight — it stays `dirty`, because the canvas * is left on a CSS-resolution frame and only a later crisp shot can sharpen it. + * + * A shot that stays in flight is never re-issued: it is queued behind a blocking + * daemon command, and a second one would only queue behind it too. The panel + * paints the stream meanwhile (`captureOverdue`), and every host adapter bounds + * the wait with its own timeout. */ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { let inFlight = false; @@ -67,11 +80,14 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { let lastStart = 0; let avgMs = 120; let timer: ReturnType | undefined; + let stallWarning: ReturnType | undefined; let disposed = false; // The `bytes:generation` of the frame currently on the canvas. Skips decoding a // capture we've already displayed onto this same draw target. let lastDrawnKey: string | null = null; + const overdueAfterMs = () => Math.max(2 * avgMs, OVERDUE_FLOOR_MS); + const display = (bytes: Uint8Array, mime: string, mySeq: number, provisionalAtStart: number) => { // Identical bytes drawn onto the same canvas generation → nothing to repaint; // skip the decode. A fresh canvas bumps the generation, so the same bytes @@ -113,25 +129,24 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { const provisionalAtStart = deps.getProvisionalGeneration?.() ?? 0; lastStart = performance.now(); deps.log?.(`[agent-browser] screenshot start ${JSON.stringify({ session, seq: mySeq })}`); - // Watchdog: a capture that never resolves (a wedged host round-trip) must not - // pin `inFlight` forever and silently freeze the screencast. Free the slot and - // retry after a generous bound; a late resolve is dropped by the seq guard. - let settled = false; - const watchdog = setTimeout(() => { - if (settled) return; - settled = true; + // Diagnostic only: the slot stays held until the host answers (see above). + stallWarning = setTimeout(() => { + stallWarning = undefined; + console.warn(`[agent-browser] screenshot capture stalled (>${STALL_WARNING_MS / 1000}s) ${JSON.stringify({ session, seq: mySeq, dirty })}`); + }, STALL_WARNING_MS); + const settle = () => { + clearTimeout(stallWarning); + stallWarning = undefined; inFlight = false; - console.warn(`[agent-browser] screenshot capture stalled (>8s) ${JSON.stringify({ session, seq: mySeq, dirty, willRetry: dirty })}`); - if (dirty) schedule(); - }, 8000); + }; platform.agentBrowserScreenshot(session, { format: 'jpeg', quality: 85 }, deps.getBinaryPath()).then((res) => { - if (settled) return; - settled = true; - clearTimeout(watchdog); const elapsedMs = performance.now() - lastStart; deps.log?.(`[agent-browser] screenshot done ${JSON.stringify({ session, seq: mySeq, ok: res.ok, bytes: res.bytes?.byteLength ?? 0, elapsedMs: Math.round(elapsedMs), dirty })}`); - avgMs = avgMs * 0.6 + elapsedMs * 0.4; - inFlight = false; + // A capture held behind a blocking command timed that command, not a + // capture: clamp the sample so one slow page load stretches neither the + // pacing nor the overdue threshold of the shots after it. + avgMs = avgMs * 0.6 + Math.min(elapsedMs, overdueAfterMs()) * 0.4; + settle(); // A provisional stream frame painted during this capture is visibly newer. // Do not let the stale crisp result overwrite it. Mere `dirty` pulses do not // suppress drawing — an idle animated page still needs periodic crisp frames @@ -152,11 +167,8 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { } if (dirty) schedule(); }).catch((err) => { - if (settled) return; - settled = true; - clearTimeout(watchdog); console.warn(`[agent-browser] screenshot error ${JSON.stringify({ session, seq: mySeq })}:`, err); - inFlight = false; + settle(); if (dirty) schedule(); }); }; @@ -203,9 +215,11 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { dirty = true; schedule(); }, + captureOverdue: () => inFlight && performance.now() - lastStart > overdueAfterMs(), dispose: () => { disposed = true; if (timer !== undefined) clearTimeout(timer); + clearTimeout(stallWarning); }, }; } 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 b32ff1ef4..2212c36f3 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -249,6 +249,41 @@ describe('provisional stream paint', () => { expect(createImageBitmap).toHaveBeenCalledTimes(4); }); + it('paints the stream while a crisp capture waits behind a blocking command', async () => { + let now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + // Every capture queues behind a page-loading `open` and never answers here. + const screenshot = vi.fn(() => new Promise(() => {})); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserScreenshot = screenshot; + setPlatform(platform); + vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 40, height: 30, close: vi.fn() }))); + const sink = makeSink(); + sink.canvas.getContext = vi.fn(() => ({ drawImage: vi.fn() })) as unknown as typeof sink.canvas.getContext; + + const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); + controller.attachView(sink); + await flushMicrotasks(); + const frame = async (label: string) => { + streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa(label) })); + await flushMicrotasks(); + }; + await frame('previous page'); + expect(screenshot).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + + // Soon after the capture started, a changed frame only pulses the loop. + now += PROVISIONAL_INPUT_WINDOW_MS + 1; + await frame('loading'); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + + // Once the capture is overdue, the loading page paints from the stream. + now += 400; + await frame('still loading'); + expect(createImageBitmap).toHaveBeenCalledTimes(2); + expect(screenshot).toHaveBeenCalledTimes(1); + }); + it('repaints a byte-identical crisp capture over a provisional paint', async () => { // A provisional paint puts blurry pixels on the canvas without going through // the screenshot loop, so the loop's byte-dedup no longer describes what is on diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index bcc0eb788..80b7ee66e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -900,7 +900,10 @@ export class AgentBrowserSurfaceController { } private wantsProvisionalFrame(): boolean { - return !this.hasFrame || !this.platform.agentBrowserScreenshot || performance.now() <= this.provisionalUntil; + return !this.hasFrame || !this.platform.agentBrowserScreenshot || performance.now() <= this.provisionalUntil + // A crisp capture queued behind a blocking `open` would otherwise leave + // the previous page on screen for the whole load. + || !!this.screenshotLoop?.captureOverdue(); } // agent-browser's stream publishes the initial headed tab list but not every diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index 217723a8b..43e34495e 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -444,6 +444,48 @@ describe('agent-browser host screenshot transport', () => { await host.closePoppedOut(); }); + // A capture queued behind a page-loading `open` outlives the webview's reply + // timeout, and the webview asks again. A second spawn would only queue behind + // the first, then race it for the session's one capture file. + it('joins a capture already in flight for the session instead of spawning another', async () => { + const release = deferred(); + let file = ''; + spawnMock.mockImplementation(async (_binary: string, args: string[]) => { + file = args[3]; + const result = await release.promise; + writeFileSync(file, Uint8Array.from([0xff, 0xd8, 0x01])); + return spawnResult(result); + }); + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + + const paths = [ + host.screenshotToFile('queued', { format: 'jpeg' }), + host.screenshotToFile('queued', { format: 'jpeg' }), + ]; + const bytes = [ + host.screenshot('queued-bytes', { format: 'jpeg' }), + host.screenshot('queued-bytes', { format: 'jpeg' }), + ]; + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)); // one per session + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(spawnMock).toHaveBeenCalledTimes(2); + + release.resolve({}); + const [first, second] = await Promise.all(paths); + expect(second).toEqual(first); + // Both callers get the frame from the one read, which removed the file. + 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' }); + expect(spawnMock).toHaveBeenCalledTimes(1); + await host.closePoppedOut(); + }); + 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. diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index c539a56fe..f75c9331a 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -423,8 +423,8 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser await screenshotDir.remove(); } - // Reused per session so we don't litter with one file per frame; the panel - // guarantees one screenshot in flight per surface, so overwriting is safe. The + // Reused per session so we don't litter with one file per frame; `oneCapture` + // 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(); @@ -437,6 +437,20 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return path.join(await screenshotDir.get(), `shot-${name}.${ext}`); } + // One capture per session and format at a time, whoever asks. A `screenshot` + // queued behind a page-loading `open` blocks for up to 25s, and each webview + // adapter gives up on its reply sooner (VS Code at 10s) and asks again; a + // second spawn would only queue behind the first, then race it for the + // session's one capture file. A caller that asks mid-capture joins it. + const capturesInFlight = new Map>(); + function oneCapture(key: string, capture: () => Promise): Promise { + const pending = capturesInFlight.get(key) as Promise | undefined; + if (pending) return pending; + const started = capture().finally(() => capturesInFlight.delete(key)); + capturesInFlight.set(key, started); + return started; + } + async function command(session: string, args: string[], binaryPath?: string): Promise { if (!isSessionName(session)) { return { exitCode: 1, stdout: '', stderr: 'a valid session name is required' }; @@ -516,25 +530,27 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return { ok: false, error: 'a valid session name is required' }; } const format = opts.format === 'png' ? 'png' : 'jpeg'; - const ext = format === 'png' ? 'png' : 'jpg'; - let out: string; - 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}` }; - } - 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); - 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}` }; - } - return { ok: true, path: out, mime: format === 'png' ? 'image/png' : 'image/jpeg' }; + return oneCapture(`file:${format}:${session}`, async (): Promise => { + const ext = format === 'png' ? 'png' : 'jpg'; + let out: string; + 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}` }; + } + 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); + 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}` }; + } + return { ok: true, path: out, mime: format === 'png' ? 'image/png' : 'image/jpeg' }; + }); } // Byte-returning wrapper over screenshotToFile for the VS Code host (structured @@ -545,22 +561,27 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string, ): Promise { - 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)}` }; - } + // Joined whole, read and unlink included: a caller joining only the capture + // would read a file the first caller has already removed. + const format = opts.format === 'png' ? 'png' : 'jpeg'; + return oneCapture(`bytes:${format}:${session}`, async (): Promise => { + 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)}` }; + } + }); } async function streamStatus(session: string, binaryPath?: string): Promise { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index fde0757ae..c50c9e982 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": 5500, + "docs/specs/dor-browser.md": 5650, "docs/specs/dor-cli.md": 6250, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, From bcb4e4e701f421c063f80f2196ea70039c7628e5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 18:47:40 -0700 Subject: [PATCH 05/18] Keep the proxied HTML body's own charset and encoding when instrumenting Four defects in the iframe proxy's HTML path, each reproduced: - `streamHtml` relabelled every HTML response `text/html; charset=utf-8`, overriding the upstream header and any ``, so Shift_JIS or windows-1252 pages mis-decoded. The upstream `content-type` now passes through as sent. - An upstream that compresses without being asked had the shim prepended to its gzip bytes with `content-encoding` kept (a blank frame with ERR_CONTENT_DECODING_FAILED). Only an identity-encoded, ASCII-compatible body is instrumented; a compressed or UTF-16 one passes through. - A valid document with neither `` nor `` got the shim before ``, switching it to quirks mode. The fallback now inserts after the leading doctype/``/``/`` tags. - `Accept-Encoding` was deleted on every request, so a remote upstream's scripts and styles came back uncompressed. Only document loads (by `Sec-Fetch-Dest`, or none sent) ask for identity now. The head scan also decodes and searches each chunk once plus a short carry, instead of re-decoding the whole buffered prefix on every chunk. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 9 ++- docs/specs/dor-browser.rationale.md | 2 + lib/src/host/iframe-proxy-rewrite.test.ts | 12 ++++ lib/src/host/iframe-proxy-rewrite.ts | 15 +++- lib/src/host/iframe-proxy.test.ts | 87 +++++++++++++++++++++++ lib/src/host/iframe-proxy.ts | 55 ++++++++++---- 6 files changed, 164 insertions(+), 16 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index bb4e599c5..30c477257 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -482,7 +482,7 @@ Header rewriting: | request | `Host` | upstream host | | request | `Origin` | upstream origin **only** when it is the proxy's own; else forwarded untouched (absent stays absent) | | request | `Referer` | proxy origin replaced with the upstream origin | -| request | `Accept-Encoding` | deleted, so HTML comes back identity for rewriting | +| request | `Accept-Encoding` | deleted on a document load (`Sec-Fetch-Dest` `document`, `iframe`, `frame`, `embed`, `object`, or none sent), so its HTML comes back identity; kept on every other request | | request | `Cookie` | dropped, including WebSocket handshakes | | response | `Set-Cookie` | dropped, including successful and refused WebSocket handshakes | | response | `X-Frame-Options`, CSP headers | with validated chain, replaced by `frame-ancestors 'self' `; opted-in CSP policies remain alongside it (rationale) | @@ -493,6 +493,13 @@ Header rewriting: **Must update this table whenever header rewriting changes.** +**Must instrument only an identity-encoded, ASCII-compatible HTML body, and keep +its `content-type` as sent**, charset included; a compressed or UTF-16 body +passes through uninstrumented (rationale). **Never place the shim ahead of the +doctype or a ``**: it goes before ``, else after ``, +else after the document's leading doctype/``/``/`` +tags. + **Must preserve enforced and report-only CSP verbatim when the upstream response sends `X-Dormouse-Preserve-CSP: 1`.** Add the validated ancestor policy separately, for every MIME type; preserve meta policies during HTML instrumentation. Never infer this opt-in from request headers. Additional upstream restrictions may prevent framing or shim execution. (rationale) **One dedicated `127.0.0.1:0` server per grant, with no token in the path** — the diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index e2aa014fe..53e3c05c0 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -98,6 +98,8 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli The built-in local-file viewer supplies its own content boundary and permits the inline shim, so removing its CSP would expand active documents' resource access. Its response opts into preservation without new renderer or host-bridge state. The proxy adds an independent ancestor policy: CSP policies intersect, so no directive parser or partial reconstruction can accidentally weaken the upstream. An opt-in upstream with stricter framing or script restrictions keeps those restrictions even if the shim cannot run. +**Why the HTML path keeps the body's own encoding.** Each was reproduced against the proxy (2026-09-23): relabelling every HTML response `charset=utf-8` overrode both the upstream header and any ``, so a Shift_JIS or windows-1252 page mis-decoded; an upstream that compresses without being asked had the shim prepended to its gzip bytes with `content-encoding: gzip` kept, and the frame failed with `ERR_CONTENT_DECODING_FAILED`; and a valid document with neither `` nor `` got the shim before ``, switching it to quirks mode. Deleting `Accept-Encoding` on every request sent a remote upstream's scripts and styles uncompressed, typically 3-5x the bytes. + **Why a grant gets its own origin instead of a path token.** A dedicated origin keeps root-relative resources and client-side routers working with no body URL rewriting; a path token would have to survive every link, redirect and `fetch` the page makes. ## Iframe Shim diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 4920848e1..401e62254 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -59,6 +59,18 @@ describe('instrumentHtml', () => { expect(out).toMatch(/\s*`; if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${shimTag}`); if (/]*>/i.test(html)) return html.replace(/(]*>)/i, `$1${shimTag}`); - return shimTag + html; + // Both tags are optional in valid HTML. Never ahead of the doctype, which + // would switch the page to quirks mode, nor of a ``, which + // counts only within the first 1024 bytes — the same window searched here. + const prologue = html.slice(0, 1024); + let at = 0; + for (const tag of [/]*>/i, /]*)?>/i, /]*)?>/i, /]*\bcharset\b[^>]*>/i]) { + const match = tag.exec(prologue); + if (match) at = Math.max(at, match.index + match[0].length); + } + return html.slice(0, at) + shimTag + html.slice(at); } +/** The end of the document prefix `instrumentHtml` places the shim before + * (``) or after (``). The proxy buffers until it sees one. */ +export const HEAD_MARKER = /<\/head>|]*>/i; + // 169.254.0.0/16 — IPv4 link-local, incl. the 169.254.169.254 cloud-metadata // endpoint — as a numeric range so every equivalent encoding is caught. const LINK_LOCAL_V4_START = 0xa9fe0000; // 169.254.0.0 diff --git a/lib/src/host/iframe-proxy.test.ts b/lib/src/host/iframe-proxy.test.ts index 4b6de5186..14694df73 100644 --- a/lib/src/host/iframe-proxy.test.ts +++ b/lib/src/host/iframe-proxy.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as http from 'node:http'; import * as net from 'node:net'; +import { gunzipSync, gzipSync } from 'node:zlib'; import { createIframeProxyUrl } from './iframe-proxy'; // The app's own ancestor chain, as `lib/src/lib/embedder-origins.ts` reports it @@ -91,6 +92,17 @@ function request(url: string, init: { method?: string; headers?: Record request(url); +function requestBytes(url: string): Promise<{ headers: http.IncomingHttpHeaders; body: Buffer }> { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks: Buffer[] = []; + res.on('error', reject); + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) })); + }).on('error', reject); + }); +} + async function frame(target: string, opts = NO_LOG): Promise { const r = await createIframeProxyUrl(target, opts); if (!r.ok) throw new Error(`expected ok, got ${JSON.stringify(r)}`); @@ -203,6 +215,81 @@ describe('iframe proxy — serving', () => { expect(res.headers['transfer-encoding']).toBe('chunked'); }); + it('keeps the upstream content-type, charset included', async () => { + const body = Buffer.concat([Buffer.from(''), Buffer.from([0x93, 0xfa, 0x96, 0x7b]), Buffer.from('x')]); + const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'text/html; charset=Shift_JIS' }); s.end(body); }); + const res = await requestBytes(await frame(`http://127.0.0.1:${port}/`)); + + expect(res.headers['content-type']).toBe('text/html; charset=Shift_JIS'); + expect(res.body.toString('latin1')).toContain('__dormouse'); + // The Shift_JIS bytes pass through as sent. + expect(res.body.includes(Buffer.from([0x93, 0xfa, 0x96, 0x7b]))).toBe(true); + }); + + it('passes a compressed or UTF-16 HTML body through rather than splicing the shim into it', async () => { + const html = 'thello'; + const gzipped = gzipSync(html); + const port = await upstream((q, s) => { + if (q.url === '/utf16') { + s.writeHead(200, { 'content-type': 'text/html; charset=UTF-16LE' }); + s.end(Buffer.from(html, 'utf16le')); + return; + } + // Compresses whatever the request asked for. + s.writeHead(200, { 'content-type': 'text/html', 'content-encoding': 'gzip' }); + s.end(gzipped); + }); + const url = await frame(`http://127.0.0.1:${port}/`); + + const compressed = await requestBytes(url); + expect(compressed.headers['content-encoding']).toBe('gzip'); + expect(gunzipSync(compressed.body).toString('utf8')).toBe(html); + // Still framed on the proxy's terms, only uninstrumented. + expect(compressed.headers['content-security-policy']) + .toBe("frame-ancestors 'self' vscode-webview://abc-123 vscode-file://vscode-app"); + + const wide = await requestBytes(`${new URL(url).origin}/utf16`); + expect(wide.body.toString('utf16le')).toBe(html); + }); + + it('asks for an identity body only when loading a document', async () => { + const port = await upstream((q, s) => s.end(q.headers['accept-encoding'] ?? '(none)')); + const url = await frame(`http://127.0.0.1:${port}/`); + const ask = (dest?: string) => request(url, { + headers: { 'accept-encoding': 'gzip, br', ...(dest ? { 'sec-fetch-dest': dest } : {}) }, + }); + + expect((await ask('script')).body).toBe('gzip, br'); + expect((await ask('style')).body).toBe('gzip, br'); + expect((await ask('iframe')).body).toBe('(none)'); + expect((await ask('document')).body).toBe('(none)'); + // An engine that sends no Sec-Fetch-Dest cannot say, so it gets identity. + expect((await ask()).body).toBe('(none)'); + }); + + it('streams the instrumented head as soon as a marker split across chunks completes', async () => { + let finish: () => void = () => {}; + const port = await upstream(async (_q, s) => { + s.writeHead(200, { 'content-type': 'text/html' }); + for (const part of ['x</ti', 'tle></he', 'ad>']) { s.write(part); await delay(5); } + // The body is slow to come; the head must not wait for it. + await new Promise<void>((resolve) => { finish = resolve; }); + s.end('<body>late</body></html>'); + }); + const u = new URL(await frame(`http://127.0.0.1:${port}/`)); + const first = await new Promise<string>((resolve, reject) => { + const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname }, (res) => { + res.once('data', (c: Buffer) => resolve(c.toString('latin1'))); + }); + req.on('error', reject); + req.end(); + }); + finish(); + + expect(first).toContain('__dormouse'); + expect(first).toMatch(/<\/script><\/head>$/); + }); + it('passes non-HTML through untouched (no shim), still stripping framing headers', async () => { const port = await upstream((_q, s) => { s.writeHead(200, { 'content-type': 'application/javascript', 'x-frame-options': 'SAMEORIGIN' }); diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 04c8b8d10..c799ad2b4 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -57,6 +57,7 @@ import type { IframeProxyResult } from '../lib/platform/iframe-proxy-types'; import { isForeignOrigin, isLoopbackHost, isOwnOrigin } from './loopback-guard'; import { FRAMING_RESPONSE_HEADERS, + HEAD_MARKER, PRESERVE_CSP_HEADER, HOP_BY_HOP_RESPONSE_HEADERS, errorPageHtml, @@ -77,8 +78,16 @@ const GRANT_SWEEP_MS = 60_000; // Backstop against unbounded server accumulation if sweeps never run. const MAX_GRANTS = 32; // We only buffer the <head> region (to find the shim insertion point); if no -// </head>/<body> shows up within this many bytes, inject at the front and pipe. +// </head>/<body> shows up within this many bytes, instrument what we have and pipe. const HEAD_STREAM_CAP = 512 * 1024; +// How much already-scanned text a chunk's scan re-reads, so a marker split +// across chunks is still found. A longer `<body …>` split mid-tag is found by +// the full-prefix instrumentation at the cap or the end instead. +const MARKER_CARRY = 1024; +// Fetch destinations that load a document the shim goes into. Only their +// requests ask for an identity body, so every other request keeps its +// compression; a request without the header (an older engine) is treated as one. +const DOCUMENT_DESTINATIONS = new Set(['document', 'iframe', 'frame', 'embed', 'object']); // Idle timeout on the upstream socket (no bytes flowing). Generous so a slow or // streaming dev server isn't cut off, but bounded so a hung upstream becomes a // visible error page instead of an indefinitely blank frame. @@ -240,8 +249,9 @@ function handleRequest(grant: Grant, req: http.IncomingMessage, res: http.Server if (typeof headers.referer === 'string') { headers.referer = rewriteOrigin(headers.referer, grant.proxyOrigin, grant.upstream.origin); } - // Drop Accept-Encoding so HTML comes back identity — we rewrite it. - delete headers['accept-encoding']; + // Documents come back identity, so their HTML can be instrumented. + const dest = req.headers['sec-fetch-dest']; + if (typeof dest !== 'string' || DOCUMENT_DESTINATIONS.has(dest)) delete headers['accept-encoding']; const upstreamReq = http.request({ protocol: 'http:', @@ -252,8 +262,14 @@ function handleRequest(grant: Grant, req: http.IncomingMessage, res: http.Server headers, }, (upstreamRes) => { const contentType = String(upstreamRes.headers['content-type'] ?? ''); + const encoding = String(upstreamRes.headers['content-encoding'] ?? 'identity').trim().toLowerCase(); const embedder = grant.embedderOrigins?.[0]; - if (!/text\/html/i.test(contentType) || embedder === undefined) { + // The shim is ASCII spliced into the body's own bytes, so it needs an + // identity, ASCII-compatible body: a compressed one (an upstream that + // ignores Accept-Encoding, or a non-document fetch) or a UTF-16 one would + // be corrupted, not instrumented. + if (!/text\/html/i.test(contentType) || encoding !== 'identity' + || /charset\s*=\s*["']?utf-?16/i.test(contentType) || embedder === undefined) { passThrough(grant, upstreamRes, res); return; } @@ -303,8 +319,10 @@ function passThrough(grant: Grant, upstreamRes: http.IncomingMessage, res: http. // document: accumulate only until the insertion point (</head>, else <body>, // else the cap), instrument that prefix, then pipe the rest through untouched. // latin1 is byte-preserving, so searching/rewriting ASCII tags and re-encoding -// can't corrupt multibyte bytes in <head> (e.g. an em-dash in <title>). The -// response is chunked (no content-length) since instrumentation changes length. +// can't corrupt multibyte bytes in <head> (e.g. an em-dash in <title>), in any +// ASCII-compatible charset — which is why the upstream's `content-type`, charset +// included, is kept as sent. The response is chunked (no content-length) since +// instrumentation changes length. function streamHtml( grant: Grant, // Taken as an argument rather than read off the grant so the type carries the @@ -316,23 +334,32 @@ function streamHtml( ): void { const preserveCsp = upstreamRes.headers[PRESERVE_CSP_HEADER] === '1'; const outHeaders = sanitizeResponseHeaders(grant, upstreamRes.headers); - outHeaders['content-type'] = 'text/html; charset=utf-8'; delete outHeaders['content-length']; res.writeHead(upstreamRes.statusCode ?? 200, outHeaders); - let pending = Buffer.alloc(0); + const pending: Buffer[] = []; + let buffered = 0; + // The scanned text a marker split across chunks could still begin in. + let carry = ''; let handled = false; + const prefix = () => Buffer.concat(pending).toString('latin1'); + // Each chunk is decoded and searched once, plus the carry, so a large head + // costs linear rather than quadratic time. const onData = (chunk: Buffer) => { - pending = Buffer.concat([pending, chunk]); - const text = pending.toString('latin1'); - if (pending.length <= HEAD_STREAM_CAP && !/<\/head>/i.test(text) && !/<body[^>]*>/i.test(text)) return; + pending.push(chunk); + buffered += chunk.length; + const scan = carry + chunk.toString('latin1'); + if (buffered <= HEAD_STREAM_CAP && !HEAD_MARKER.test(scan)) { + carry = scan.slice(-MARKER_CARRY); + return; + } // Found the insertion point (or hit the cap): instrument the buffered // prefix, then hand the remainder to a raw pipe (backpressure + end). handled = true; upstreamRes.off('data', onData); - res.write(Buffer.from(instrumentHtml(text, embedderOrigin, preserveCsp), 'latin1')); - pending = Buffer.alloc(0); + res.write(Buffer.from(instrumentHtml(prefix(), embedderOrigin, preserveCsp), 'latin1')); + pending.length = 0; upstreamRes.pipe(res); }; @@ -340,7 +367,7 @@ function streamHtml( upstreamRes.on('end', () => { if (handled) return; // the pipe ends `res` // Whole document arrived before any head marker — instrument and finish. - res.end(Buffer.from(instrumentHtml(pending.toString('latin1'), embedderOrigin, preserveCsp), 'latin1')); + res.end(Buffer.from(instrumentHtml(prefix(), embedderOrigin, preserveCsp), 'latin1')); }); upstreamRes.on('error', () => { if (!res.writableEnded) res.destroy(); }); } From 396c47edd2280969981b52dbb18b2e697d8a8bc5 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 18:58:26 -0700 Subject: [PATCH 06/18] Make iframe failures visible and one click from agent-browser Pages that fail in the iframe renderer failed silently or at a dead end, and the pane never offered the swap to agent-browser that sat one call away. - A proxied frame `load` with no shim `location` report within 1s marks the document uninstrumented (it left the proxy, was refused framing, or its grant was swept), and a slim banner offers Reload and Open in agent-browser. Only a report naming the proxy origin counts, so a clicked off-proxy link does not. - The panel's proxy errors (https://, link-local, unreachable) get an Open in agent-browser button where the host can launch one, keeping `dor ab open` as fallback text; the two scheme messages now say the same thing. - The new-tab prompt opens an https:// URL as an agent-browser pane on a proxy host, bound to its launch like a render swap and closed if the launch fails, instead of another iframe that would refuse it. - `surface.iframe` refuses an https:// target on a host with the proxy, naming `dor ab open <url>`, so `dor iframe https://...` no longer prints "created" and dead-ends. `dor iframe --help` and the `dor split` example follow. - The Display modal lists that the embed keeps no logins/cookies and disables it, with the reason, for an https:// page on a proxying host. - The proxy's served error page follows the system color scheme, and only a loopback upstream is asked about its dev server. The optional `cookies-dropped` shim notice is left out: most server-rendered frameworks set a session or CSRF cookie on every response, so the banner could not tell a dropped login from routine traffic. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 35 +++- dor/src/commands/iframe.ts | 7 +- dor/src/commands/split.ts | 2 +- dor/test/snapshots/help/iframe.md | 7 +- dor/test/snapshots/help/split.md | 2 +- lib/src/components/Wall.test.tsx | 69 +++++++ lib/src/components/Wall.tsx | 27 ++- .../wall/AgentBrowserScreenModal.test.tsx | 25 +++ .../wall/AgentBrowserScreenModal.tsx | 22 ++- lib/src/components/wall/IframePanel.test.tsx | 130 ++++++++++++- lib/src/components/wall/IframePanel.tsx | 182 +++++++++++++----- lib/src/components/wall/browser-url.ts | 10 + lib/src/components/wall/use-dor-control.ts | 9 +- lib/src/components/wall/wall-context.tsx | 5 +- lib/src/host/iframe-proxy-rewrite.test.ts | 17 ++ lib/src/host/iframe-proxy-rewrite.ts | 28 ++- lib/src/host/iframe-proxy.ts | 2 +- scripts/spec-word-budgets.json | 2 +- 18 files changed, 493 insertions(+), 88 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 30c477257..7aea9ca6c 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -22,9 +22,9 @@ handle (`docs/specs/dor-cli.md` → Browser Open Target Resolution): - `dor ab ...` / `dor agent-browser ...` forwards to the user's own `agent-browser` binary and binds that session to a browser pane. - `dor pw ...` / `dor playwright ...` binds the user-installed Playwright CLI. -- `dor iframe <url>` uses the iframe renderer. The CLI accepts `https://`, but - the proxy instruments `http://` upstreams only, so the pane reports an - unproxyable scheme. +- `dor iframe <url>` uses the iframe renderer, for `http://` pages: a host with + the iframe proxy refuses an `https://` target at `surface.iframe`, naming + `dor ab open <url>`. Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, `lib/src/components/wall/browser-surface.ts` (`resolveRenderMode`, @@ -171,6 +171,8 @@ robot rides each provider’s screencast parent, each nested resolution row carr 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 disable the iframe option, naming why, for an `https://` page on a host +with the iframe proxy**; it lists that the embed keeps no logins or cookies. Resolution controls apply to both screencast providers, as GUI wrappers around native commands: **Resize with pane** is Dormouse-owned sync issuing @@ -466,9 +468,12 @@ The proxy instruments any `http://` upstream, loopback and remote alike: overridden, not obeyed** (rationale); JS framebusting is neutralized separately, by the sandbox. - Unreachable / timed-out upstream: served Dormouse error page, distinct for - "couldn't connect" and "didn't respond in 30s of socket idle". -- HTTPS: synchronous `scheme` failure with a `dor ab` hint — agent-browser is the - path for real HTTPS or a login. + "couldn't connect" and "didn't respond in 30s of socket idle", in the system + color scheme; **only a loopback upstream is called a dev server**. +- HTTPS: synchronous `scheme` failure. **Every panel error the URL itself does + not cause offers Open in agent-browser** (a swap to `ab-screencast`) where the + host can launch one, with `dor ab open <url>` as the fallback text — + agent-browser is the path for real HTTPS or a login. - **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 @@ -515,7 +520,8 @@ keyboard and pointer interaction inside the frame by design. Source of truth: `lib/src/components/wall/IframePanel.tsx`, `lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts` (`FRAMING_RESPONSE_HEADERS`, `HOP_BY_HOP_RESPONSE_HEADERS`, `instrumentHtml`, -`isBlockedAddress`), `lib/src/lib/platform/iframe-proxy-types.ts`. +`isBlockedAddress`, `errorPageHtml`), `lib/src/lib/platform/iframe-proxy-types.ts`, +`surface.iframe` in `lib/src/components/wall/use-dor-control.ts`. ### Iframe Shim @@ -534,11 +540,19 @@ Leader messages feed the same Wall command-mode exit path as in-document dual-tap; `IframePanel` maps proxy-origin `location` URLs back to upstream URLs for chrome/history without reloading the frame. -New-tab requests show an overlay: accept opens an adjacent browser pane; cancel drops it. -Neither switches to agent-browser. +New-tab requests show an overlay: accept opens an adjacent browser pane — +**an `https://` URL opens as an `ab-screencast` pane** on a proxy host that can +launch agent-browser, bound to its launch like a render swap and closed if it +fails; cancel drops it. + +**A proxied frame `load` with no `location` report within 1s marks the document +uninstrumented** — it left the proxy, was refused, or its grant is gone — and a +banner offers Reload and Open in agent-browser. Only a report naming the proxy +origin counts, including one up to 250ms before the load. Source of truth: `lib/src/host/iframe-proxy-rewrite.ts` (`iframeShim`), `lib/src/components/wall/browser-url.ts` (`browserSurfaceUrl`), +`lib/src/components/Wall.tsx` (`onOpenBrowserPane`), `lib/src/lib/iframe-proxy-registry.ts`, `lib/src/components/wall/use-wall-keyboard.ts`, `lib/src/components/wall/IframePanel.tsx`. @@ -566,7 +580,8 @@ focuses/blurs the iframe element like other surfaces). The optional `PlatformAdapter.createIframeProxyUrl` method and the `IframeProxyResult` union are canonical in the platform types. Reachability is diagnosed lazily by served error pages after the iframe loads the proxy URL, and -frame refusal is not diagnosed at all, so v1 mostly returns `ok` or `scheme`. +frame refusal only as an uninstrumented load ([Iframe Shim](#iframe-shim)), so +v1 mostly returns `ok` or `scheme`. **The webview passes its own ancestor chain with every request for a proxy URL** — `location.origin` plus `location.ancestorOrigins`, knowable only in the diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index e806d55c9..832188ef8 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -33,13 +33,14 @@ export const iframeCommand: Command = { command: buildCommand<IframeFlags, [string], DorCommandContext>({ docs: { brief: 'Open a target in an iframe surface.', - fullDescription: `Opens a target in a high-fidelity iframe surface for human inspection. + fullDescription: `Opens an http:// page in a high-fidelity iframe surface for a human to look at. + +Agents cannot read or drive an iframe surface, and it drops the page's cookies, so logins do not work in it. For those, and for any https:// page, which the iframe refuses, use \`dor ab open <url>\`. If the caller surface is an untouched terminal, Dormouse replaces that terminal with the iframe. Otherwise Dormouse creates a split next to the caller/focused surface. The target is one of: - <url> An absolute http:// or https:// URL (an explicit scheme is - always honored). + <url> An absolute http:// URL. host:port A schemeless host:port, defaulted to http:// (e.g. localhost:5173, box.ts.net:3000). The explicit port marks a dev/infra server, which is http far more often than not. diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index 598e85d9d..4abffa56e 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -92,7 +92,7 @@ Focus depends only on whether you pass --. A bare "dor split" (no --) moves focu split creates terminal Surfaces. Compose browser content commands through the initial command: - dor split --right -- dor iframe https://example.com + dor split --right -- dor iframe :5173 dor split --auto -- dor agent-browser open https://example.com Text output: diff --git a/dor/test/snapshots/help/iframe.md b/dor/test/snapshots/help/iframe.md index 9125ffb1f..68c7b4739 100644 --- a/dor/test/snapshots/help/iframe.md +++ b/dor/test/snapshots/help/iframe.md @@ -7,13 +7,14 @@ USAGE dor iframe [--json] [--minimize] [--surface id|ref] [--workspace ref] <target> dor iframe --help -Opens a target in a high-fidelity iframe surface for human inspection. +Opens an http:// page in a high-fidelity iframe surface for a human to look at. + +Agents cannot read or drive an iframe surface, and it drops the page's cookies, so logins do not work in it. For those, and for any https:// page, which the iframe refuses, use `dor ab open <url>`. If the caller surface is an untouched terminal, Dormouse replaces that terminal with the iframe. Otherwise Dormouse creates a split next to the caller/focused surface. The target is one of: - <url> An absolute http:// or https:// URL (an explicit scheme is - always honored). + <url> An absolute http:// URL. host:port A schemeless host:port, defaulted to http:// (e.g. localhost:5173, box.ts.net:3000). The explicit port marks a dev/infra server, which is http far more often than not. diff --git a/dor/test/snapshots/help/split.md b/dor/test/snapshots/help/split.md index 61cc52407..59eafc96b 100644 --- a/dor/test/snapshots/help/split.md +++ b/dor/test/snapshots/help/split.md @@ -19,7 +19,7 @@ Focus depends only on whether you pass --. A bare "dor split" (no --) moves focu split creates terminal Surfaces. Compose browser content commands through the initial command: - dor split --right -- dor iframe https://example.com + dor split --right -- dor iframe :5173 dor split --auto -- dor agent-browser open https://example.com Text output: diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index eb8c94842..5f572874a 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3837,6 +3837,75 @@ describe('Wall on the Lath engine', () => { expect(getWallHandle(DEFAULT_WORKSPACE_ID)).toBeNull(); }); + it('refuses an https:// surface.iframe on a host that proxies, naming dor ab open', async () => { + const respond = async (url: string) => { + let response: { ok: boolean; error?: string } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { method: SURFACE_CONTROL_METHODS.iframe, params: { url }, respond: (r: typeof response) => { response = r; } }, + })); + }); + await flush(); + return response; + }; + await act(async () => root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />)); + await flush(); + + // Without the proxy the raw frame shows it, so only a proxying host refuses. + expect((await respond('https://example.com/'))?.ok).toBe(true); + (fake as PlatformAdapter).createIframeProxyUrl = vi.fn(async () => ({ ok: true as const, url: 'http://127.0.0.1:61234/' })); + expect(await respond('https://example.com/')).toEqual({ + ok: false, + error: 'iframe panes show http:// pages only; open https://example.com/ with `dor ab open https://example.com/`', + }); + expect((await respond('http://localhost:5173/'))?.ok).toBe(true); + }); + + 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> = []; + (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" />)); + await flush(); + const iframe = await dispatchIframe('http://localhost:5173/'); + const leafIds = () => Array.from(container.querySelectorAll<HTMLElement>('[data-lath-leaf]')).map((leaf) => leaf.dataset.lathLeaf!); + const openTab = async (url: string) => { + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: 'http://127.0.0.1:61234', data: { __dormouse: 'open-window', url } })); + }); + const button = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((b) => b.textContent === 'Open in agent-browser')!; + await act(async () => { button.click(); }); + await flush(); + }; + + const before = leafIds(); + 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', {}, undefined); + // 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 }); }); + await flush(); + expect(await dispatchResolveAgentBrowser(tab)).toMatchObject({ ok: true, result: { session: 'dormouse.1.gui-abc' } }); + + // A launch that fails takes its pane with it. + const beforeFailure = leafIds(); + await openTab('https://other.example/'); + const [failed] = leafIds().filter((id) => !beforeFailure.includes(id)); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await act(async () => { launches[1]({ ok: false, error: 'agent-browser binary not found' }); }); + await flush(); + expect(leafIds()).not.toContain(failed); + expect(leafIds()).toContain(iframe.id); + } finally { + untouchedSpy.mockRestore(); + } + }); + it('names the Window that answered `dor list`, once the host has named it', async () => { // A caller needs a ref it can hand back, and with several Windows open // `window:1` names none of them (docs/specs/dor-cli.md -> "Handle Model"). diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 1fcfcb338..3cf4c6ebf 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2146,18 +2146,33 @@ export function Wall({ }); } }, - onOpenBrowserPane: (id, url) => { - // A new-tab request from the iframe shim → open the URL as a new iframe - // browser pane, split next to the source (docs/specs/dor-browser.md → - // "Iframe Shim"). + onOpenBrowserPane: (id, url, renderMode = 'iframe') => { + // A new-tab request from the iframe shim → open the URL as a new browser + // pane, split next to the source (docs/specs/dor-browser.md → "Iframe + // Shim"). An agent-browser pane lands at once, session-less and inert, + // and binds the session its launch returns, like a render swap. const reference = buildDorSurfaces().find((s) => s.id === id); if (!reference) return; - createContentSurface({ + const agentBrowser = renderMode === 'ab-screencast'; + const created = createContentSurface({ minimized: false, - params: { surfaceType: 'browser', renderMode: 'iframe', url }, + params: { surfaceType: 'browser', renderMode, url, ...(agentBrowser ? { syncEngaged: true } : {}) }, reference, title: hostPathDisplay(url, true), }); + const open = getPlatform().agentBrowserOpen; + if (!agentBrowser || !created.ok || !open) return; + const eagerId = created.value.id; + open(url, {}, launchBinaryPath.get('agent-browser')).then((res) => { + if (!res.ok || !res.session) throw new Error(res.error ?? '(no session)'); + launchBinaryPath.remember('agent-browser', res.binaryPath); + const bound = boundBrowserParams(res.session, res, undefined); + if (!lath.getMeta(eagerId) || lath.isDying(eagerId)) closeAgentBrowserSession({ renderMode, ...bound }); + else updateSurfaceParams(eagerId, bound); + }).catch((error) => { + console.warn(`[dormouse] could not open ${url} in agent-browser:`, error); + if (lath.getMeta(eagerId) && !lath.isDying(eagerId)) void closeSurfaceRef.current(eagerId, 'silent'); + }); }, resolveSurfaceRef: surfaceRefForId, onResolveToolApproval: (id: string, choice: 'upstream' | 'folder' | 'decline' | 'retry') => { diff --git a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx index f142121d9..85b5acf83 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx @@ -104,6 +104,31 @@ describe('AgentBrowserScreenModal', () => { registration.dispose(); }); + it('tells the user the embed drops logins, and refuses it for an https:// page on a proxying host', () => { + const platform: PlatformAdapter = new FakePtyAdapter(); + platform.createIframeProxyUrl = async () => ({ ok: true, url: 'http://127.0.0.1:61234/' }); + setPlatform(platform); + const iframeRow = () => [...document.body.querySelectorAll('label')].find((label) => label.textContent?.includes('iframe embed'))!; + + const secure = registerStubScreen('secure', { + snapshot: { ...STUB_SCREEN, renderMode: 'ab-screencast' }, + chrome: { url: 'https://example.com/account', displayUrl: 'example.com/account', title: null, key: null }, + }); + act(() => root.render(<AgentBrowserScreenModal controller={getAgentBrowserScreenController('secure')!} label="surface:5" onClose={() => {}} />)); + expect(document.body.textContent).toContain('no logins/cookies'); + expect(iframeRow().querySelector('input')!.disabled).toBe(true); + expect(iframeRow().textContent).toContain('https:// pages can’t be embedded'); + secure.dispose(); + + const local = registerStubScreen('local', { + snapshot: { ...STUB_SCREEN, renderMode: 'ab-screencast' }, + chrome: { url: 'http://localhost:5173/', displayUrl: 'localhost:5173/', title: null, key: null }, + }); + act(() => root.render(<AgentBrowserScreenModal controller={getAgentBrowserScreenController('local')!} label="surface:6" onClose={() => {}} />)); + expect(iframeRow().querySelector('input')!.disabled).toBe(false); + local.dispose(); + }); + it('keeps resize selected while an engaged sync is transiently scaled', () => { const registration = registerStubScreen('browser-transient', { snapshot: { diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index a8357d8e5..9da44c515 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -28,8 +28,10 @@ import { OVERLAY_MAX_HEIGHT, } from '../design'; import type { RenderMode, ScreenController, ScreenSnapshot } from './agent-browser-screen'; -import { browserDisplayMode, useAgentBrowserScreenSnapshot } from './agent-browser-screen'; +import { browserDisplayMode, useAgentBrowserChromeSnapshot, useAgentBrowserScreenSnapshot } from './agent-browser-screen'; import { AUTOMATION_PROVIDERS, automationMode, automationProvider, isScreencast, PROVIDER_LABEL } from './browser-automation'; +import { getPlatformOrNull } from '../../lib/platform'; +import { isHttpsUrl } from './browser-url'; import { AgentRobotIcon, BROWSER_DISPLAY_LABEL, @@ -64,6 +66,7 @@ export function AgentBrowserScreenModal({ onClose: () => void; }) { const live = useAgentBrowserScreenSnapshot(controller); + const chrome = useAgentBrowserChromeSnapshot(controller); // Snapshot the state the modal opened with for pre-selection; the live one // still tracks the current render mode so external changes update whether // Apply is swapping backends. @@ -92,6 +95,11 @@ export function AgentBrowserScreenModal({ // 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. const offered = (mode: RenderMode) => mode === currentMode || controller.renderModes.includes(mode); + // A host with the iframe proxy frames http:// only, so swapping an https:// + // page to the embed would land on its refusal. + const iframeRefusal = currentMode !== 'iframe' && isHttpsUrl(chrome?.url ?? '') && !!getPlatformOrNull()?.createIframeProxyUrl + ? 'https:// pages can’t be embedded' + : undefined; // 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); @@ -262,7 +270,8 @@ export function AgentBrowserScreenModal({ onSelect={() => setRenderMode('iframe')} icon={<BrowserDisplayIcon mode="iframe" size={14} className="text-muted" />} label={BROWSER_DISPLAY_LABEL.iframe} - features={[[false, 'agents cannot read/write'], [false, 'http only'], [true, 'native human experience']]} + features={[[false, 'agents cannot read/write'], [false, 'http only'], [false, 'no logins/cookies'], [true, 'native human experience']]} + disabledReason={iframeRefusal} /> )} </div> @@ -309,6 +318,7 @@ function RenderOption({ icon, label, features, + disabledReason, children, }: { checked: boolean; @@ -316,14 +326,18 @@ function RenderOption({ icon?: ReactNode; label: string; features: [boolean, string][]; + /** Why this option cannot be chosen here; absent ⇒ enabled. */ + disabledReason?: string; children?: ReactNode; }) { + const disabled = disabledReason !== undefined; return ( <div className="flex flex-col gap-1.5 text-sm"> - <label className="flex cursor-pointer items-center gap-2"> - <input type="radio" name="render-mode" checked={checked} onChange={onSelect} /> + <label className={`flex items-center gap-2 ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer'}`}> + <input type="radio" name="render-mode" checked={checked} disabled={disabled} onChange={onSelect} /> {icon} <span className="text-foreground">{label}</span> + {disabled && <span className="text-xs text-muted">— {disabledReason}</span>} </label> <div className="ml-6 flex flex-col gap-0.5 text-xs"> {features.map(([ok, text]) => <Feature key={text} ok={ok}>{text}</Feature>)} diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index e45f5a17e..ab40d7efa 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -122,9 +122,10 @@ describe('IframePanel', () => { }); expect(container.querySelector('iframe')).toBeNull(); - expect(container.textContent).toContain('only frames'); + expect(container.textContent).toContain('frames http:// pages only'); // `dor ab open` refuses a non-http(s) target too, so it is not the remedy here. expect(container.textContent).not.toContain('dor ab open'); + expect(container.textContent).not.toContain('Open in agent-browser'); }); // The panel frames the string it checked, not the one it was handed: a @@ -281,6 +282,133 @@ 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(); + platform.createIframeProxyUrl = vi.fn(async () => result); + setPlatform(platform); + return platform; + } + const report = async (path = '/app') => { + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'location', url: `${PROXY}${path}` } })); + }); + }; + const button = (label: string) => Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label); + const banner = () => container.querySelector('[role="status"]'); + + it('flags a proxied document the shim never reported from, and clears it when one reports', async () => { + vi.useFakeTimers(); + try { + const onSwapRenderMode = vi.fn(); + const platform = proxyPlatform(); + const iframe = await renderPanel(stubActions({ onSwapRenderMode }), paneProps('iframe-uninstrumented')); + + // An instrumented document reports its location, so its load is fine. + await report(); + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()).toBeNull(); + + // It navigates off the proxy: a load with no report. + await act(async () => { vi.advanceTimersByTime(2000); }); + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()?.textContent).toContain('isn’t running through Dormouse'); + + await act(async () => { button('Open in agent-browser')!.click(); }); + expect(onSwapRenderMode).toHaveBeenCalledWith('iframe-uninstrumented', 'ab-screencast'); + + const resolved = vi.mocked(platform.createIframeProxyUrl).mock.calls.length; + await act(async () => { button('Reload')!.click(); }); + expect(vi.mocked(platform.createIframeProxyUrl).mock.calls.length).toBeGreaterThan(resolved); + expect(banner()).toBeNull(); + + // Flagged again, then a later report from the shim clears it. + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()).not.toBeNull(); + await report('/back-on-the-proxy'); + expect(banner()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not count a clicked link that leaves the proxy as the shim reporting', async () => { + vi.useFakeTimers(); + try { + proxyPlatform(); + const iframe = await renderPanel(stubActions(), paneProps('iframe-offproxy-link')); + // The shim posts a clicked link's href just before the frame navigates. + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'location', url: 'https://elsewhere.example/' } })); + }); + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('offers agent-browser on an unproxyable url, with the command as the fallback', async () => { + const onSwapRenderMode = vi.fn(); + proxyPlatform({ ok: false, reason: 'scheme', detail: 'the embedded view frames http:// pages only' }); + await act(async () => { + root.render( + <PaneWriteContext.Provider value={{ updateParams: () => {}, setTitle: () => {} }}> + <WallActionsContext.Provider value={stubActions({ onSwapRenderMode })}> + <IframePanel id="iframe-https" title="t" params={{ url: 'https://example.com/' }} /> + </WallActionsContext.Provider> + </PaneWriteContext.Provider>, + ); + }); + + expect(container.textContent).toContain('Can’t frame this URL — the embedded view frames http:// pages only.'); + expect(container.textContent).toContain('dor ab open https://example.com/'); + await act(async () => { button('Open in agent-browser')!.click(); }); + expect(onSwapRenderMode).toHaveBeenCalledWith('iframe-https', 'ab-screencast'); + + // A host that cannot launch one keeps only the command. + proxyPlatform({ ok: false, reason: 'scheme' }, false); + await act(async () => { root.render(<></>); }); + await act(async () => { + root.render( + <PaneWriteContext.Provider value={{ updateParams: () => {}, setTitle: () => {} }}> + <WallActionsContext.Provider value={stubActions()}> + <IframePanel id="iframe-https-2" title="t" params={{ url: 'https://example.com/' }} /> + </WallActionsContext.Provider> + </PaneWriteContext.Provider>, + ); + }); + expect(button('Open in agent-browser')).toBeUndefined(); + expect(container.textContent).toContain('dor ab open https://example.com/'); + }); + + it('opens a new https:// tab in agent-browser instead of an iframe that would refuse it', async () => { + const onOpenBrowserPane = vi.fn(); + proxyPlatform(); + await renderPanel(stubActions({ onOpenBrowserPane }), paneProps('iframe-newtab')); + const openWindow = async (url: string) => { + await act(async () => { + window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'open-window', url } })); + }); + }; + + await openWindow('https://accounts.example/login'); + expect(button('Open in new pane')).toBeUndefined(); + await act(async () => { button('Open in agent-browser')!.click(); }); + expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'https://accounts.example/login', 'ab-screencast'); + + await openWindow(`${PROXY}/docs`); + await act(async () => { button('Open in new pane')!.click(); }); + expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'http://example.test/docs', 'iframe'); + }); +}); + describe('the render modes a tool is offered (regression: PR #493 review)', () => { // A tool's `render` is `iframe` or `ab-screencast`, so pop-out has no // renderer to land in: offering it tears the browser down and re-derives the diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index 3b264ac91..f565fd909 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { PaneMessage, TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; +import { modalActionButton, PaneMessage, PopupButtonRow, popupButton, TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; import { getPlatform } from '../../lib/platform'; import { registerProxyOrigin } from '../../lib/iframe-proxy-registry'; import { registerSurfaceFocusHandle } from '../../lib/terminal-registry'; @@ -16,7 +16,7 @@ import { } from './agent-browser-screen'; import { isToolParams } from './browser-surface'; import { offeredRenderModes } from './browser-automation'; -import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; +import { browserSurfaceUrl, hostPathDisplay, isHttpsUrl } from './browser-url'; // Sandbox every framed page, proxied or raw, so a tool's // `if (top !== self) top.location = …` framebust cannot navigate the Wall away — @@ -36,6 +36,16 @@ const IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups // `clipboard-read` most pointedly, since a terminal's clipboard is where users // paste secrets. Writing to the clipboard needs a user gesture and cannot read. const IFRAME_ALLOW = 'autoplay; clipboard-write; fullscreen'; +// The injected shim reports the document's location on DOMContentLoaded and +// again on pageshow, just after load. A proxied frame whose `load` brings no +// report within this window is showing a document the shim is not in: it left +// the proxy, was refused, or the proxy's grant is gone. +const SHIM_REPORT_TIMEOUT_MS = 1000; +// A report this shortly before `load` still counts: the pageshow report and the +// frame's load event race each other to the parent. +const SHIM_REPORT_LEAD_MS = 250; +// On hosts with the proxy, which is every host that runs `dor`. +const HTTP_ONLY = 'the embedded view frames http:// pages only'; type Resolution = | { kind: 'empty' } @@ -112,6 +122,11 @@ export function IframePanel({ id, title, params }: PaneProps) { // A new-tab/window request from the proxy shim, pending the user's choice to // open it as a new pane (docs/specs/dor-browser.md → "Iframe Shim"). const [pendingOpenUrl, setPendingOpenUrl] = useState<string | null>(null); + // The proxied frame loaded a document with no shim in it (docs/specs/dor-browser.md + // → "Iframe Shim"). + const [uninstrumented, setUninstrumented] = useState(false); + const lastShimReportRef = useRef(Number.NEGATIVE_INFINITY); + const shimCheckRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const [history, setHistory] = useState<IframeHistory>(() => ( sourceUrl ? { entries: [sourceUrl], index: 0 } : { entries: [], index: -1 } )); @@ -225,6 +240,11 @@ export function IframePanel({ id, title, params }: PaneProps) { // other registration site is `agent-browser-surface-controller.ts`. const renderModes = useMemo(() => offeredRenderModes(isTool, null), [isTool]); const swapCapable = renderModes.some((mode) => mode !== 'iframe'); + const agentBrowserCapable = renderModes.includes('ab-screencast'); + const openInAgentBrowser = useMemo( + () => (agentBrowserCapable ? () => actionsRef.current.onSwapRenderMode(id, 'ab-screencast') : undefined), + [id, agentBrowserCapable], + ); const screenActions = useMemo<ScreenActions>(() => ({ engageSync() {}, applyDevice() {}, @@ -281,6 +301,20 @@ export function IframePanel({ id, title, params }: PaneProps) { return registerProxyOrigin(proxyOrigin); }, [proxyOrigin]); + // A new frame source starts over: no verdict until its document loads. + useEffect(() => { + setUninstrumented(false); + return () => clearTimeout(shimCheckRef.current); + }, [resolution]); + const onFrameLoad = useCallback(() => { + if (!proxyOrigin) return; + const loadedAt = performance.now(); + clearTimeout(shimCheckRef.current); + shimCheckRef.current = setTimeout(() => { + if (lastShimReportRef.current < loadedAt - SHIM_REPORT_LEAD_MS) setUninstrumented(true); + }, SHIM_REPORT_TIMEOUT_MS); + }, [proxyOrigin]); + // A cross-origin click reaches only the frame, so the Wall never sees the // mousedown — and on WebKit the iframe element's own `focus` event doesn't // fire for it either. The shim posts `pointerdown` from inside the frame; @@ -309,8 +343,13 @@ export function IframePanel({ id, title, params }: PaneProps) { return; } if (data?.__dormouse === 'location') { + // Only a location on the proxy origin is the shim reporting its own + // document; a clicked link's href can name anywhere. const nextUrl = upstreamUrlFromFrameLocation(data.url, liveUrl || sourceUrl, proxyOrigin); - if (nextUrl) observeFrameUrl(nextUrl); + if (!nextUrl) return; + lastShimReportRef.current = performance.now(); + setUninstrumented(false); + observeFrameUrl(nextUrl); } }; window.addEventListener('message', onMessage); @@ -382,49 +421,87 @@ export function IframePanel({ id, title, params }: PaneProps) { sandbox={IFRAME_SANDBOX} {...(resolution.kind === 'proxied' ? { 'data-dormouse-proxy': 'true' } : {})} referrerPolicy="strict-origin-when-cross-origin" + onLoad={onFrameLoad} /> ) : ( - <PanelMessage resolution={resolution} url={sourceUrl} /> + <PanelMessage resolution={resolution} url={sourceUrl} onOpenInAgentBrowser={openInAgentBrowser} /> + )} + {uninstrumented && ( + <PopupButtonRow + className="absolute inset-x-1 top-1 z-10 flex-wrap" + role="status" + onMouseDown={(e) => e.stopPropagation()} + > + <span className="min-w-0 flex-1 px-1.5 py-0.5 text-muted"> + This page isn’t running through Dormouse — it left the proxy, was blocked, or the proxy expired. + </span> + <button type="button" className={popupButton()} onClick={() => chromeActions.reload()}>Reload</button> + {openInAgentBrowser && ( + <button type="button" className={popupButton()} onClick={openInAgentBrowser}>Open in agent-browser</button> + )} + <button type="button" className={popupButton()} aria-label="Dismiss" onClick={() => setUninstrumented(false)}>✕</button> + </PopupButtonRow> )} {pendingOpenUrl && ( - <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal-bg/95 px-6 text-center"> - <div className="max-w-sm text-sm text-foreground"> - This page wants to open a new tab: - <div className="mt-1 break-all font-mono text-xs text-muted">{pendingOpenUrl}</div> - </div> - <div className="flex gap-2"> - <button - type="button" - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - const u = pendingOpenUrl; - setPendingOpenUrl(null); - if (u) actions.onOpenBrowserPane?.(id, u); - }} - className="rounded border border-border px-2.5 py-1 text-sm text-foreground transition-colors hover:border-foreground" - > - Open in new pane - </button> - <button - type="button" - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => { e.stopPropagation(); setPendingOpenUrl(null); }} - className="rounded border border-border px-2.5 py-1 text-sm text-muted transition-colors hover:text-foreground" - > - Cancel - </button> - </div> - <div className="text-xs text-muted/80"> - Pages that open many tabs work better in agent-browser — open the chip → Render. - </div> - </div> + <NewTabPrompt + url={pendingOpenUrl} + // On a proxy host an https:// pane would only land on the scheme refusal. + inAgentBrowser={agentBrowserCapable && isHttpsUrl(pendingOpenUrl) && !!getPlatform().createIframeProxyUrl} + onOpen={(renderMode) => { + setPendingOpenUrl(null); + actions.onOpenBrowserPane?.(id, pendingOpenUrl, renderMode); + }} + onCancel={() => setPendingOpenUrl(null)} + /> )} </div> ); } -function PanelMessage({ resolution, url }: { resolution: Resolution; url: string }) { +function NewTabPrompt({ url, inAgentBrowser, onOpen, onCancel }: { + url: string; + inAgentBrowser: boolean; + onOpen: (renderMode: 'iframe' | 'ab-screencast') => void; + onCancel: () => void; +}) { + return ( + <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal-bg/95 px-6 text-center"> + <div className="max-w-sm text-sm text-foreground"> + This page wants to open a new tab: + <div className="mt-1 break-all font-mono text-xs text-muted">{url}</div> + </div> + <div className="flex gap-2"> + <button + type="button" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); onOpen(inAgentBrowser ? 'ab-screencast' : 'iframe'); }} + className="rounded border border-border px-2.5 py-1 text-sm text-foreground transition-colors hover:border-foreground" + > + {inAgentBrowser ? 'Open in agent-browser' : 'Open in new pane'} + </button> + <button + type="button" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); onCancel(); }} + className="rounded border border-border px-2.5 py-1 text-sm text-muted transition-colors hover:text-foreground" + > + Cancel + </button> + </div> + <div className="text-xs text-muted/80"> + {inAgentBrowser + ? `It is an https:// page, and ${HTTP_ONLY}.` + : 'Pages that open many tabs work better in agent-browser — open the chip → Display.'} + </div> + </div> + ); +} + +function PanelMessage({ resolution, url, onOpenInAgentBrowser }: { + resolution: Resolution; + url: string; + onOpenInAgentBrowser?: () => void; +}) { if (resolution.kind === 'resolving') { return <PaneMessage className="text-muted">Connecting to <span className="ml-1 font-semibold">{url}</span>…</PaneMessage>; } @@ -436,17 +513,30 @@ function PanelMessage({ resolution, url }: { resolution: Resolution; url: string // 'error' — the proxy turned a dead end into something actionable. (Unreachable // cases are served as a page inside the frame; this covers the synchronous // ones, chiefly an unproxyable scheme such as https://.) - // `dor ab open` is the remedy only where the URL itself is fine and the proxy + // agent-browser is the remedy only where the URL itself is fine and the proxy // can't front it. It refuses a non-http(s) target too (`normalizeConcreteOpenUrl`), // so pointing a refused scheme at it would be a dead end. + const command = <code className="rounded bg-app-bg px-1 py-0.5">dor ab open {url}</code>; return ( <PaneMessage className="text-muted" contentClassName="flex flex-col gap-2"> <div>{messageFor(resolution)}</div> - <div className="text-xs text-muted/80"> - {resolution.reason === 'non-http' - ? 'Enter an http:// or https:// address in the URL bar above.' - : <>For arbitrary web pages, use <code className="rounded bg-app-bg px-1 py-0.5">dor ab open {url}</code></>} - </div> + {resolution.reason === 'non-http' ? ( + <div className="text-xs text-muted/80">Enter an http:// address in the URL bar above.</div> + ) : onOpenInAgentBrowser ? ( + <div className="flex flex-col items-start gap-1.5 text-xs text-muted/80"> + <button + type="button" + className={modalActionButton({ tone: 'primary' })} + onMouseDown={(e) => e.stopPropagation()} + onClick={onOpenInAgentBrowser} + > + Open in agent-browser + </button> + <span>or run {command}</span> + </div> + ) : ( + <div className="text-xs text-muted/80">Open it in agent-browser: {command}</div> + )} </PaneMessage> ); } @@ -454,11 +544,9 @@ function PanelMessage({ resolution, url }: { resolution: Resolution; url: string function messageFor(resolution: Extract<Resolution, { kind: 'error' }>): string { switch (resolution.reason) { case 'non-http': - return 'The iframe surface only frames http:// and https:// URLs.'; + return `Can’t frame this URL — ${HTTP_ONLY}.`; case 'scheme': - return resolution.detail - ? `Can’t frame this URL — ${resolution.detail}.` - : 'The iframe surface only frames http:// servers.'; + return `Can’t frame this URL — ${resolution.detail ?? HTTP_ONLY}.`; case 'unreachable': default: return resolution.detail ? `Couldn’t reach the server — ${resolution.detail}.` : 'Couldn’t reach the server.'; diff --git a/lib/src/components/wall/browser-url.ts b/lib/src/components/wall/browser-url.ts index 27ce3ac34..45fffe765 100644 --- a/lib/src/components/wall/browser-url.ts +++ b/lib/src/components/wall/browser-url.ts @@ -87,6 +87,16 @@ export function browserSurfaceUrl(raw: string): string | null { } } +/** Whether `url` parses as an https:// URL — the scheme a host with the iframe + * proxy cannot embed (docs/specs/dor-browser.md → "Iframe Renderer"). */ +export function isHttpsUrl(url: string): boolean { + try { + return new URL(url).protocol === 'https:'; + } catch { + return false; + } +} + /** The host part of a schemeless authority, minus any `:port`. An IPv6 literal * is bracketed and full of colons, so splitting on the first `:` would yield * `[` — take everything through the closing bracket instead, which keeps the diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 0f2dd9434..a31cc48d4 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -43,7 +43,7 @@ import { type ToolTakeoverGate, } from './tool-takeover'; import { attachSurfacePorts } from './surface-ports'; -import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; +import { browserSurfaceUrl, hostPathDisplay, isHttpsUrl } from './browser-url'; import { automationMode, automationProvider, browserPlatform, type LaunchBinaryPath } from './browser-automation'; import { BrowserBindingReservations } from './browser-binding-reservations'; import { @@ -1573,6 +1573,13 @@ export function useDorControl({ detail.respond({ ok: false, error: 'url must be an http:// or https:// URL' }); return; } + // A host with the iframe proxy frames http:// only, so an https:// pane + // would open straight onto its refusal (docs/specs/dor-browser.md → + // "Iframe Renderer"). Say so here, where the caller can act on it. + if (getPlatform().createIframeProxyUrl && isHttpsUrl(url)) { + detail.respond({ ok: false, error: `iframe panes show http:// pages only; open ${url} with \`dor ab open ${url}\`` }); + return; + } const target = resolveVisibleSurface(stringParam(params.surface), detail.surfaceId); if (!target.ok) { detail.respond({ ok: false, error: target.message }); diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index bf7624f30..e608e3cb0 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -64,11 +64,12 @@ export interface WallActions { * surface-type replacement; screencast ↔ popout is handled inside the * agent-browser panel and does not route here. */ onSwapRenderMode: (id: string, mode: RenderMode) => void; - /** Open a URL as a new iframe browser pane, split next to `id`. The iframe + /** Open a URL as a new browser pane, split next to `id` — an iframe, or an + * agent-browser screencast for a page the iframe cannot show. The iframe * renderer is single-frame, so a page's new-tab request (target=_blank / * window.open, surfaced by the proxy shim) becomes a new pane * (docs/specs/dor-browser.md → "Iframe Shim"). */ - onOpenBrowserPane?: (id: string, url: string) => void; + onOpenBrowserPane?: (id: string, url: string, renderMode?: 'iframe' | 'ab-screencast') => void; /** The stable `surface:N` ref for a pane/door id (minted lazily, exactly as * `dor list` assigns refs). Used by the pane context menu to show the handle. */ resolveSurfaceRef: (id: string) => string; diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 401e62254..023720d64 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -235,4 +235,21 @@ describe('errorPageHtml', () => { expect(html).toContain('isn’t responding'); expect(html).toMatch(/reload/i); }); + + it('follows the system color scheme instead of hardcoding a dark page', () => { + const html = errorPageHtml(unreachablePage(new URL('http://localhost:5173/'), 'ECONNREFUSED')); + expect(html).toContain('color-scheme: light dark'); + expect(html).not.toMatch(/#[0-9a-f]{6}\b/i); + }); + + it('asks about a dev server only when the upstream is this machine', () => { + for (const local of ['http://localhost:5173/', 'http://127.0.0.2:8000/', 'http://app.localhost:3000/', 'http://[::1]:8080/']) { + expect(unreachablePage(new URL(local), 'ECONNREFUSED').message, local).toContain('dev server'); + expect(timedOutPage(new URL(local)).message, local).toContain('dev server'); + } + const remote = new URL('http://box.ts.net:3000/'); + expect(unreachablePage(remote, 'ETIMEDOUT').message).not.toContain('dev server'); + expect(unreachablePage(remote, 'ETIMEDOUT').message).toContain('reachable from this machine'); + expect(timedOutPage(remote).message).not.toContain('dev server'); + }); }); diff --git a/lib/src/host/iframe-proxy-rewrite.ts b/lib/src/host/iframe-proxy-rewrite.ts index bb6a3b217..f1b2de70d 100644 --- a/lib/src/host/iframe-proxy-rewrite.ts +++ b/lib/src/host/iframe-proxy-rewrite.ts @@ -295,17 +295,30 @@ export interface ErrorPage { message: string; } +/** Whether the upstream is this machine — the one case where "the dev server" + * is a fair guess at what the user pointed the pane at. */ +function isLoopbackUpstream(upstream: URL): boolean { + const host = upstream.hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.localhost') || host === '[::1]') return true; + const v4 = parseIPv4(host); + return v4 !== null && v4 >>> 24 === 127; +} + export function unreachablePage(upstream: URL, detail: string): ErrorPage { return { title: `Nothing responding at ${upstream.host}`, - message: `Dormouse couldn’t reach ${upstream.href} (${detail}). Is the dev server running?`, + message: `Dormouse couldn’t reach ${upstream.href} (${detail}). ${isLoopbackUpstream(upstream) + ? 'Is the dev server running?' + : 'Check that the server is up and reachable from this machine.'}`, }; } export function timedOutPage(upstream: URL): ErrorPage { return { title: `${upstream.host} isn’t responding`, - message: `Dormouse connected to ${upstream.host} but it didn’t respond in time — the dev server may be busy (e.g. optimizing dependencies). Try reloading.`, + message: `Dormouse connected to ${upstream.host} but it didn’t respond in time — ${isLoopbackUpstream(upstream) + ? 'the dev server may be busy (e.g. optimizing dependencies)' + : 'the server may be busy or slow'}. Try reloading.`, }; } @@ -315,19 +328,20 @@ export function escapeHtml(value: string): string { .replace(/"/g, '"'); } +// The page is a separate document on the proxy origin, out of reach of the +// app's theme tokens, so it follows the system scheme through CSS system +// colors rather than hardcoding either one. export function errorPageHtml(page: ErrorPage): string { return `<!doctype html><html><head><meta charset="utf-8"> <style> - :root { color-scheme: dark; } + :root { color-scheme: light dark; } html, body { height: 100%; margin: 0; } body { display: flex; align-items: center; justify-content: center; - background: #14161a; color: #c9ced6; + background: Canvas; color: color-mix(in srgb, CanvasText 75%, Canvas); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .card { max-width: 34rem; padding: 1.5rem 2rem; text-align: center; } - h1 { margin: 0 0 .5rem; font-size: 1.05rem; font-weight: 600; color: #e7ebf1; } + h1 { margin: 0 0 .5rem; font-size: 1.05rem; font-weight: 600; color: CanvasText; } p { margin: .5rem 0; } - code { background: #20242b; border-radius: 4px; padding: .15rem .4rem; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #e7ebf1; } </style></head> <body><div class="card"> <h1>${escapeHtml(page.title)}</h1> diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index c799ad2b4..878e92740 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -145,7 +145,7 @@ export async function createIframeProxyUrl( // plain http, and rewriting authenticated https pages is the agent-browser's // job (spec → Target policy). if (upstream.protocol !== 'http:') { - return { ok: false, reason: 'scheme', detail: `${upstream.protocol.replace(':', '')} upstreams are not proxied yet` }; + return { ok: false, reason: 'scheme', detail: 'the embedded view frames http:// pages only' }; } // SSRF guard: the proxy fetches a user-supplied URL, so refuse the link-local // / cloud-metadata ranges (169.254.169.254 and friends). Other private ranges diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c50c9e982..3e5e9cefd 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": 5650, + "docs/specs/dor-browser.md": 5800, "docs/specs/dor-cli.md": 6250, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, From afd4d8e7b40ff92562ab5c12006aeba7b5c60058 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 18:59:29 -0700 Subject: [PATCH 07/18] Point dor-cli's addressing note at the host's argv shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-browser host no longer keeps a subcommand allowlist; its webview channel accepts fixed argv shapes (docs/specs/dor-browser.md → Agent-Browser Host Capabilities). The sweep for the retired term missed this line-wrapped mention. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-cli.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 5a8f8e8a0..4b433185c 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -582,8 +582,9 @@ 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-side subcommand -allowlist is untouched. +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. From 14bd4b571f000abd2ef74220dcdbd4b99402da8f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:05:07 -0700 Subject: [PATCH 08/18] Tell agents which browser command to use, and name the fix in every refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two automation providers the agent-facing text still read as if there were one, and nothing said when to reach for `dor pw` or `dor iframe`. - `dor/skill.md` gains a "Which browser command" rule after the two hard rules (`dor ab` by default, `dor pw` for a Playwright user/project or a `pw-*` browser, `dor iframe` only to show a human a local http page) and the `render_mode` → `--surface` pairing; the pw section leads with "launch once with `open`, then navigate with `goto`", since Playwright's `open` restarts the browser. Stale single-provider lines are fixed: the `--json` exception list, the `--workspace` command list, and where browser keys live. - `dor pw --help` leads its examples with `open` once then `goto`, and says the first command, not the first launch, pins the cwd. - `dor ab --help` no longer promises one session is always exactly one surface (dor-browser.md → Managed identity says it is not an invariant), and points a Playwright browser at `dor pw --surface`. - Provider-mismatch refusals append the command that works: `— drive it with dor pw --surface surface:4`, or for an iframe, `dor ab open <its url>`. - Automated-browser placeholders address their own pane (`dor ab --surface surface:N open <url>`) rather than a bare `dor ab open` that drives a different browser, and stop printing internal session names. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-cli.md | 16 +++--- dor/skill.md | 42 ++++++++++----- dor/src/commands/agent-browser.ts | 5 +- dor/src/commands/playwright.ts | 14 ++--- dor/test/snapshots/help/agent-browser.md | 5 +- dor/test/snapshots/help/playwright.md | 14 ++--- lib/src/components/Wall.test.tsx | 53 +++++++++++++++++-- .../wall/AgentBrowserPanel.test.tsx | 24 +++++++++ lib/src/components/wall/AgentBrowserPanel.tsx | 15 +++--- lib/src/components/wall/browser-automation.ts | 5 ++ lib/src/components/wall/use-dor-control.ts | 13 +++-- scripts/spec-word-budgets.json | 2 +- 12 files changed, 161 insertions(+), 47 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 4b433185c..bffbc4cb3 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -598,8 +598,11 @@ and the host applies two gates in order: 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: `surface 'surface:2' is not - agent-browser rendered (render_mode: 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 <its url>`. Neither gate covers an agent-browser Surface the context menu created eagerly, whose daemon boot has not yet named it ([dor-browser.md](dor-browser.md) → Pane @@ -611,8 +614,8 @@ 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` and the -`surface.resolveAgentBrowser` handler 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`. @@ -628,8 +631,9 @@ stays unsupported. named by its Workspace-stable `surface:N` ref, or rediscovered after layout churn by `--command` / `--cwd` / `--port`, and `dor ensure`'s command+cwd match is an implicit key that also lets an agent adopt a command the user started by -hand. Only browser Surfaces carry an explicit join key (`dor ab --key <name>`), -because their session is held externally by `agent-browser`. +hand. Only browser Surfaces carry an explicit join key (`dor ab --key <name>`, +`dor pw --key <name>`), because their session is held externally by the browser +CLI. The worked examples — dev-server sharing, sub-agent launch and await, paired browser keys, multi-worktree, minimized watchers, port-owner handoff, safe diff --git a/dor/skill.md b/dor/skill.md index 0fee91bc6..e33e06301 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -21,6 +21,19 @@ These override your usual defaults. They matter more than anything else here: The rest of this guide is how to do everything well. +## Which browser command + +- **`dor ab`** — the default: a browser you read and drive, in a pane the user + watches. +- **`dor pw`** — when the user or the project uses Playwright, or the browser + you are handed already runs it (`render_mode` `pw-*`). +- **`dor iframe`** — only to show the human a local `http://` page. You cannot + read or drive it, it keeps no logins, and it refuses `https://`. + +`dor list` shows each browser's `render_mode`: drive an `ab-*` browser with +`dor ab --surface <ref>`, a `pw-*` one with `dor pw --surface <ref>`, and an +`iframe` one with neither — open its page with `dor ab open <url>`. + ## Targeting: three ways to name a surface Action commands (`read`, `send`, `await`, `kill`) take a surface handle — there is @@ -41,8 +54,8 @@ three ways: (`--command`, `--cwd`, `--port`) into a handle. Text output is designed for you to read: it is terse and carries the same -refs. Reach for `--json` (every command except `dor ab` supports it) only -when a shell script or pipeline using `jq` consumes the output. +refs. Reach for `--json` (every command except `dor ab` and `dor pw` supports it) +only when a shell script or pipeline using `jq` consumes the output. ## Surface handles @@ -181,7 +194,7 @@ A Window holds several Workspaces, each with its own surfaces and its own you were started in, and creating one is a change the user sees. When you do, name one as `workspace:<n>` (positional) or `workspace:<name>`, and pass `--workspace <ref>` to any command — `split`, `ensure`, `read`, `send`, -`await`, `kill`, `iframe`, `ab` — to act in another one. A surface's stable id +`await`, `kill`, `iframe`, `ab`, `pw` — to act in another one. A surface's stable id finds it in any Workspace without that flag; `surface:N` does not, since every Workspace has one. `close` refuses a Workspace holding your running work unless you pass `--force`. @@ -209,19 +222,25 @@ independent browsers at once. `dor list` works here exactly as it does for `read` / `send` / `await` / `kill`. Prefer it whenever you hold a ref rather than a key — it is the only way to reach a browser the *user* opened from the GUI, which has no key. It fails on a -terminal (no browser), and on an `iframe`-rendered surface (nothing to drive — -open it with `dor ab` instead). The three identity flags are mutually exclusive. +terminal (no browser), on a Playwright browser (drive it with `dor pw +--surface`), and on an `iframe`-rendered surface (nothing to drive — open it +with `dor ab` instead). The three identity flags are mutually exclusive. `dor ab` has no `--json` of its own; any JSON flags belong to `agent-browser`. ### `dor pw` / `dor playwright` — Playwright browser pane -Use your installed `@playwright/cli` (`npm i -g @playwright/cli`). Override its -path with `DORMOUSE_PLAYWRIGHT_BIN`. Chromium panes share the Display, viewport, -input and popout controls of `dor ab`. +For a user or project that uses Playwright, or a `pw-*` browser. Forwards to +your installed `@playwright/cli` (`npm i -g @playwright/cli`; override its path +with `DORMOUSE_PLAYWRIGHT_BIN`). + +**Launch once with `open`, then navigate with `goto`.** Playwright's `open` +restarts the browser, dropping every tab and cookie, where `dor ab open` only +navigates. ```sh dor pw --key app open :5173 +dor pw --key app goto http://localhost:5173/settings dor pw --key app snapshot dor pw --key app click e15 dor pw --surface surface:4 goto :8080 @@ -230,8 +249,7 @@ dor pw --surface surface:4 goto :8080 `--key` defaults to `default` and is separate from agent-browser keys. The first command fixes the native project cwd; later commands and relative paths use it. `--session` (or `-s`) uses a raw native session in the caller's project instead. -These identities and `--surface` are mutually exclusive. Native Playwright -`open` restarts the browser; `goto` navigates its current tab. Other arguments +These identities and `--surface` are mutually exclusive. Other arguments belong to `playwright-cli`; `dor pw --help` describes the wrapper. ## Recipes @@ -313,5 +331,5 @@ dor kill surface:N --confirm-if-read "npm run dev" just read the surface yourself. - **Scope:** `dor` sees the current workspace only. Terminals ring bells and carry todo flags (`[ringing]`/`[todo]` in `dor list`); browser surfaces are - the only ones with explicit keys, because their sessions live in - `agent-browser`. + the only ones with explicit keys, because their sessions live in the browser + CLI (`agent-browser` or `playwright-cli`). diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index b75bf4f8f..c7e52935e 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -92,8 +92,9 @@ command surface. The binary is resolved from PATH (override with DORMOUSE_AGENT_BROWSER_BIN) and is never bundled; install it with: ${INSTALL_HINT} -After a successful command, dor opens (or reuses) the browser surface bound to -the session: one session is always exactly one surface. +After a successful command, dor opens the browser surface bound to the session, +or reuses the one it already has. A Playwright browser (render_mode pw-*) is +driven with dor pw --surface instead. In an "open" command, dor also resolves a Dormouse target in place of a URL: a schemeless host:port (and the ":<port>" localhost shorthand) defaults to diff --git a/dor/src/commands/playwright.ts b/dor/src/commands/playwright.ts index 926e843c3..64fc49fd4 100644 --- a/dor/src/commands/playwright.ts +++ b/dor/src/commands/playwright.ts @@ -45,20 +45,22 @@ Install: npm i -g @playwright/cli Override the executable with DORMOUSE_PLAYWRIGHT_BIN. --key names one Playwright browser in this Dormouse workspace (default: default). -The first launch fixes its working directory; later commands, including relative +The first command fixes its working directory; later commands, including relative file paths, run there. --session (or -s) selects a native session instead. --surface drives an existing Playwright pane, including one opened from the GUI. These three identities are mutually exclusive. Other flags belong to Playwright. open and goto accept URLs, host:port, :port, or a terminal surface handle. -Playwright's open restarts the browser; goto navigates the current tab. +Launch once with open, then navigate with goto: Playwright's open restarts the +browser, dropping its tabs and cookies, while goto navigates the current tab. Chromium sessions can be viewed and controlled in Dormouse. Examples: - dor pw open http://localhost:5173 - dor playwright --key app open surface:3 - dor pw snapshot - dor pw click e15 + dor pw --key app open :5173 + dor pw --key app goto http://localhost:5173/settings + dor pw --key app snapshot + dor pw --key app click e15 + dor playwright --key docs open surface:3 dor pw --surface surface:4 goto :5173`, }, parameters: { diff --git a/dor/test/snapshots/help/agent-browser.md b/dor/test/snapshots/help/agent-browser.md index 45fd3ce19..1db16f412 100644 --- a/dor/test/snapshots/help/agent-browser.md +++ b/dor/test/snapshots/help/agent-browser.md @@ -28,8 +28,9 @@ command surface. The binary is resolved from PATH (override with DORMOUSE_AGENT_BROWSER_BIN) and is never bundled; install it with: npm i -g agent-browser -After a successful command, dor opens (or reuses) the browser surface bound to -the session: one session is always exactly one surface. +After a successful command, dor opens the browser surface bound to the session, +or reuses the one it already has. A Playwright browser (render_mode pw-*) is +driven with dor pw --surface instead. In an "open" command, dor also resolves a Dormouse target in place of a URL: a schemeless host:port (and the ":<port>" localhost shorthand) defaults to diff --git a/dor/test/snapshots/help/playwright.md b/dor/test/snapshots/help/playwright.md index 691f9d348..9387a33c5 100644 --- a/dor/test/snapshots/help/playwright.md +++ b/dor/test/snapshots/help/playwright.md @@ -12,20 +12,22 @@ Install: npm i -g @playwright/cli Override the executable with DORMOUSE_PLAYWRIGHT_BIN. --key names one Playwright browser in this Dormouse workspace (default: default). -The first launch fixes its working directory; later commands, including relative +The first command fixes its working directory; later commands, including relative file paths, run there. --session (or -s) selects a native session instead. --surface drives an existing Playwright pane, including one opened from the GUI. These three identities are mutually exclusive. Other flags belong to Playwright. open and goto accept URLs, host:port, :port, or a terminal surface handle. -Playwright's open restarts the browser; goto navigates the current tab. +Launch once with open, then navigate with goto: Playwright's open restarts the +browser, dropping its tabs and cookies, while goto navigates the current tab. Chromium sessions can be viewed and controlled in Dormouse. Examples: - dor pw open http://localhost:5173 - dor playwright --key app open surface:3 - dor pw snapshot - dor pw click e15 + dor pw --key app open :5173 + dor pw --key app goto http://localhost:5173/settings + dor pw --key app snapshot + dor pw --key app click e15 + dor playwright --key docs open surface:3 dor pw --surface surface:4 goto :5173 FLAGS diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 5f572874a..b1b7574e7 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1033,7 +1033,7 @@ describe('Wall on the Lath engine', () => { expect(container.querySelector(`[data-door-id="${eagerId}"]`)).not.toBeNull(); expect(await dispatchResolveAgentBrowser(eagerId)).toEqual({ ok: false, - error: "surface 'surface:1' is not agent-browser rendered (render_mode: iframe)", + error: "surface 'surface:1' is not agent-browser rendered (render_mode: iframe) — an iframe cannot be driven; open its page with dor ab open http://localhost:5173/", }); } finally { untouchedSpy.mockRestore(); @@ -1063,7 +1063,7 @@ describe('Wall on the Lath engine', () => { expect(getAgentBrowserScreenController(restoredId)?.snapshot().renderMode).toBe('iframe'); expect(await dispatchResolveAgentBrowser('surface:1')).toEqual({ ok: false, - error: "surface 'surface:1' is not agent-browser rendered (render_mode: iframe)", + error: "surface 'surface:1' is not agent-browser rendered (render_mode: iframe) — an iframe cannot be driven; open its page with dor ab open http://localhost:5173/", }); } finally { untouchedSpy.mockRestore(); @@ -1214,7 +1214,7 @@ describe('Wall on the Lath engine', () => { // Gate 2: an iframe renderer has a browser but no agent-browser session. expect(await dispatchResolveAgentBrowser(iframeRef)).toEqual({ ok: false, - error: `surface '${iframeRef}' is not agent-browser rendered (render_mode: iframe)`, + 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 @@ -3837,6 +3837,53 @@ describe('Wall on the Lath engine', () => { expect(getWallHandle(DEFAULT_WORKSPACE_ID)).toBeNull(); }); + 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 === 'streamStatus' ? { ok: true, wsPort: 4555 } : { ok: true } + )); + (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" />)); + await flush(); + let created: { ok: boolean; result?: { surfaceRef: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.browser, + params: { provider: 'playwright', session: 'dormouse.pw.abc', cwd: '/tmp/project' }, + respond: (r: typeof created) => { created = r; }, + }, + })); + }); + await flush(); + const pwRef = created!.result!.surfaceRef; + expect(await dispatchResolveAgentBrowser(pwRef)).toEqual({ + ok: false, + error: `surface '${pwRef}' is not agent-browser rendered (render_mode: pw-screencast) — drive it with dor pw --surface ${pwRef}`, + }); + + const abId = await dispatchAgentBrowser({ session: 'dormouse.1.default', wsPort: 4321 }); + let resolved: unknown; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.resolveBrowser, + params: { provider: 'playwright', surface: abId }, + respond: (r: unknown) => { resolved = r; }, + }, + })); + }); + await flush(); + expect(resolved).toEqual({ + ok: false, + error: expect.stringMatching(/^surface '(surface:\d+)' is not playwright rendered \(render_mode: ab-screencast\) — drive it with dor ab --surface \1$/), + }); + } finally { + untouchedSpy.mockRestore(); + } + }); + it('refuses an https:// surface.iframe on a host that proxies, naming dor ab open', async () => { const respond = async (url: string) => { let response: { ok: boolean; error?: string } | undefined; diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index fb67fdd1b..c5374ca16 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -118,6 +118,30 @@ async function renderPanel( }); } +describe('AgentBrowserPanel placeholders', () => { + // A bare `dor ab open` drives the caller's default key, which for a keyed or + // GUI-launched pane is a different browser; the internal session name means + // nothing to the person reading it. + it.each([ + ['ab-screencast', 'dor ab'], + ['pw-screencast', 'dor pw'], + ])('names this pane in its command, never the raw session (%s)', async (renderMode, cli) => { + setPlatform(new FakePtyAdapter()); + await act(async () => { + root.render( + <PaneWriteContext.Provider value={paneWriteFor(() => {})}> + <WallActionsContext.Provider value={stubActions({ resolveSurfaceRef: () => 'surface:7' })}> + <AgentBrowserPanel {...paneProps('ab-placeholder', { surfaceType: 'browser', renderMode, session: 'dormouse.1.gui-5f3a' })} /> + </WallActionsContext.Provider> + </PaneWriteContext.Provider>, + ); + }); + + expect(container.textContent).toContain(`run ${cli} --surface surface:7 open <url>`); + expect(container.textContent).not.toContain('dormouse.1.gui-5f3a'); + }); +}); + describe('AgentBrowserPanel render mode controller', () => { it('relaunches screencast sessions as popout and publishes the mode immediately', async () => { const updateParameters = vi.fn(); diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 65904a88e..77a9d3b8c 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 { automationProvider } from './browser-automation'; +import { automationCli, automationProvider } 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 = automationProvider(seededMode) ?? 'agent-browser'; - const cli = provider === 'playwright' ? 'dor pw' : 'dor ab'; + const cli = automationCli(provider); // The surface-scoped controller: get-or-create, keyed by surface id. Survives // this component's unmount (minimize, layout churn, StrictMode). Keyed by @@ -330,14 +330,17 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // Mid pop-in: the headed browser is closed by design and the headless one // is booting — not a session that ended. if (relaunching) return 'Relaunching browser…'; - if (!streamPort) return `Waiting for browser session ${session} — run ${cli} open <url>`; + // Addressed to this pane: a bare `dor ab open` drives the caller's default + // key, which for a keyed or GUI-launched pane is some other browser. + const command = `${cli} --surface ${actions.resolveSurfaceRef(id)} open <url>`; + if (!streamPort) return `Waiting for the browser — run ${command}`; if (connectionLost || status?.connected === false) { - return `Browser session ${session ?? ''} ended — run ${cli} open <url> to restart it, or close this surface.`; + return `The browser session ended — run ${command} to restart it, or close this surface.`; } if (!hasFrame) { return status && !status.screencasting - ? `No page is open — run ${cli} open <url>` - : `Connecting to ${session ?? 'browser session'}…`; + ? `No page is open — run ${command}` + : 'Connecting to the browser…'; } return null; })(); diff --git a/lib/src/components/wall/browser-automation.ts b/lib/src/components/wall/browser-automation.ts index cfd073b0e..374538ed9 100644 --- a/lib/src/components/wall/browser-automation.ts +++ b/lib/src/components/wall/browser-automation.ts @@ -36,6 +36,11 @@ 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'; +} + export function automationMode(provider: BrowserAutomationProvider, headed: boolean): AutomationRenderMode { if (provider === 'playwright') return headed ? 'pw-popout' : 'pw-screencast'; return headed ? 'ab-popout' : 'ab-screencast'; diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index a31cc48d4..d374bb333 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -44,11 +44,12 @@ import { } from './tool-takeover'; import { attachSurfacePorts } from './surface-ports'; import { browserSurfaceUrl, hostPathDisplay, isHttpsUrl } from './browser-url'; -import { automationMode, automationProvider, browserPlatform, type LaunchBinaryPath } from './browser-automation'; +import { automationCli, automationMode, automationProvider, browserPlatform, type LaunchBinaryPath } from './browser-automation'; import { BrowserBindingReservations } from './browser-binding-reservations'; import { agentBrowserSessionFromParams, browserBindingFromParams, + browserUrlFromParams, namespacedToolKey, surfaceKindFromParams, toolKeysEqual, @@ -693,10 +694,16 @@ export function useDorControl({ provider: BrowserAutomationProvider, detail: DorControlRequest, ): string | null => { - if (automationProvider(target.renderMode) !== provider) { + const rendering = automationProvider(target.renderMode); + if (rendering !== provider) { + // Name the command that does work on it, so the caller's next try lands. + const url = rendering ? undefined : browserUrlFromParams(lath.getMeta(target.id)?.params); + const remedy = rendering + ? `drive it with ${automationCli(rendering)} --surface ${target.ref}` + : `an iframe cannot be driven; open its page with ${automationCli(provider)} open ${url ?? '<url>'}`; detail.respond({ ok: false, - error: `surface '${target.ref}' is not ${provider} rendered (render_mode: ${target.renderMode})`, + error: `surface '${target.ref}' is not ${provider} rendered (render_mode: ${target.renderMode}) — ${remedy}`, }); return null; } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 3e5e9cefd..a1f68d958 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1150, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 5800, - "docs/specs/dor-cli.md": 6250, + "docs/specs/dor-cli.md": 6300, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, "docs/specs/hosted.md": 1100, From 332c262928b88290f23cdda51d8b1ebc252dd124 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:06:13 -0700 Subject: [PATCH 09/18] Create no agent-browser new-tab pane on a host that cannot launch one `onOpenBrowserPane` created the eager, session-less pane before checking for `agentBrowserOpen`, so a caller on a host without it would leave a pane waiting on a launch that never starts. The IframePanel offers the option only where the host can launch, so this guards the Wall action itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- lib/src/components/Wall.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 3cf4c6ebf..9148f13cb 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2154,13 +2154,14 @@ export function Wall({ const reference = buildDorSurfaces().find((s) => s.id === id); if (!reference) return; const agentBrowser = renderMode === 'ab-screencast'; + const open = getPlatform().agentBrowserOpen; + if (agentBrowser && !open) return; const created = createContentSurface({ minimized: false, params: { surfaceType: 'browser', renderMode, url, ...(agentBrowser ? { syncEngaged: true } : {}) }, reference, title: hostPathDisplay(url, true), }); - const open = getPlatform().agentBrowserOpen; if (!agentBrowser || !created.ok || !open) return; const eagerId = created.value.id; open(url, {}, launchBinaryPath.get('agent-browser')).then((res) => { From 4c9a8b6a211f810c32893dc22c8d364d61627a32 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:23:49 -0700 Subject: [PATCH 10/18] Keep the webview argv check a plain boolean As a type predicate on an already-`string[]` parameter, `isWebviewCommand` narrowed `args` to `never` in the refusal branch, which the VS Code extension's typecheck (which compiles the shared host) rejects. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- lib/src/host/agent-browser-host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index f75c9331a..950912457 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -117,7 +117,7 @@ const WEBVIEW_COMMANDS: Record<string, (args: string[]) => boolean> = { /** Whether `args` is exactly one of the `WEBVIEW_COMMANDS` shapes. `args` is * typed but arrives from webview IPC unvalidated. */ -function isWebviewCommand(args: unknown): args is string[] { +function isWebviewCommand(args: unknown): boolean { if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return false; const [verb, ...rest] = args as string[]; // Own keys only: a verb of `constructor` must not find the prototype's. From 063ba84dbd9cbaf4f2590a07a990a9bef9210372 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:25:19 -0700 Subject: [PATCH 11/18] Word the iframe panel-error rule plainly Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 7aea9ca6c..235ec7658 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -470,10 +470,10 @@ The proxy instruments any `http://` upstream, loopback and remote alike: - Unreachable / timed-out upstream: served Dormouse error page, distinct for "couldn't connect" and "didn't respond in 30s of socket idle", in the system color scheme; **only a loopback upstream is called a dev server**. -- HTTPS: synchronous `scheme` failure. **Every panel error the URL itself does - not cause offers Open in agent-browser** (a swap to `ab-screencast`) where the - host can launch one, with `dor ab open <url>` as the fallback text — - agent-browser is the path for real HTTPS or a login. +- HTTPS: synchronous `scheme` failure. **Every panel error but a non-http(s) + URL offers Open in agent-browser** (a swap to `ab-screencast`) where the host + can launch one, with `dor ab open <url>` as the fallback text — agent-browser + is the path for real HTTPS or a login. - **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 From e00d689ee3cfaa480bf4a1ad9aebff30d7efc10d Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:37:24 -0700 Subject: [PATCH 12/18] Parse webview browser commands once, for both provider hosts The agent-browser host's `WEBVIEW_COMMANDS` table and the Playwright host's own re-parse of the same argv had already drifted: agent-browser accepted a URL of any scheme (file:, javascript:) and any DPR, Playwright http(s) and a DPR of at most 10, and only Playwright took `tab list`. `parseWebviewCommand` in browser-host-shared.ts now turns the webview's argv (the wire stays `string[]`) into a typed `WebviewCommand`. The agent-browser host rebuilds its argv from the parsed value; the Playwright host switches on it, before connecting. Launch and navigation URLs are http(s) only on both hosts (`isBrowsableUrl`), which tightens agent-browser: a URL-bar entry or relaunch target of another scheme is refused or relaunches at about:blank. The two session-name checks sit side by side there, as does the capture format normalization three sites repeated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 22 ++-- docs/specs/dor-browser.rationale.md | 2 +- .../wall/agent-browser-surface-controller.ts | 3 +- .../components/wall/tool-browser-session.ts | 3 +- lib/src/host/agent-browser-host.test.ts | 20 +-- lib/src/host/agent-browser-host.ts | 124 +++++++----------- lib/src/host/browser-host-shared.ts | 90 ++++++++++++- lib/src/host/playwright-host.ts | 88 +++++++------ lib/src/lib/platform/types.ts | 9 +- scripts/spec-word-budgets.json | 2 +- 10 files changed, 214 insertions(+), 149 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 235ec7658..855f20a06 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -396,7 +396,7 @@ sidecar/Rust adapter. | Method | Contract | | --- | --- | -| `agentBrowserCommand` | One fixed argv shape per verb: `open <absolute url>`, `back`/`forward`/`reload`, bare `close`, `get cdp-url`, `tab <ref>`, `tab close <ref>`, `set viewport <w> <h> <dpr>`, `set device <name>`. | +| `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 in flight**: a request made meanwhile joins it, since the webview re-asks when its adapter times out. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `agentBrowserEdit` | select-all/copy/cut via fixed host-owned JS plus an OS clipboard write. | @@ -404,12 +404,11 @@ sidecar/Rust adapter. | `agentBrowserOpen` | Spawn a GUI-owned session for iframe -> agent-browser; resolves when the daemon is up, not when the page loads ([Pop-Out](#pop-out)). | | `agentBrowserPopOut` / `agentBrowserPopIn` | Headed/headless relaunch. | -**Host-side validation is the security boundary, and it checks every token, -never only the verb** — agent-browser reads launch options anywhere on its -command line (rationale). **Must refuse an argv that is not exactly one of those -shapes, an option- or path-shaped session name, and a launch URL that is not -absolute**, on every entry point; the webview is not trusted to pre-filter. -Pinned by `lib/src/host/agent-browser-host.test.ts`. +**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 +`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 @@ -428,10 +427,11 @@ 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` (`WEBVIEW_COMMANDS`, -`isSessionName`, `runWithBinaryFallback`), +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-host-shared.ts`, `lib/src/host/browser-stream-guard.ts`, +`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`, `standalone/sidecar/main.js`. @@ -446,7 +446,7 @@ Source of truth: `lib/src/host/agent-browser-host.ts` (`WEBVIEW_COMMANDS`, **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. 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; ignore it mid-relaunch, and until params show the controller's own last mode write. -**Must expose only fixed host operations.** Navigation, tabs, viewport/device, screenshots, editing and close are validated host-side; 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). +**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). **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. diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 53e3c05c0..0c6e53471 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -78,7 +78,7 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **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 <url>`, and `screenshot <path>`, which writes an image over any file the user can write. A session name becomes `<socket dir>/<session>.pid`, whose pid a relaunch SIGTERMs, so a `/` in it reaches outside that directory. +**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 <url>`, and `screenshot <path>`, which writes an image over any file the user can write. A session name becomes `<socket dir>/<session>.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". diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 80b7ee66e..abca7a573 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -397,8 +397,7 @@ export class AgentBrowserSurfaceController { }, }; - // Native history nav — issued like tab actions (fixed argv shapes the - // host's agentBrowserCommand accepts). + // Native history nav — issued like tab actions, through agentBrowserCommand. this.chromeActions = { navigate: (url) => { if (url) this.runAgentBrowser(['open', url]); }, back: () => this.runAgentBrowser(['back']), diff --git a/lib/src/components/wall/tool-browser-session.ts b/lib/src/components/wall/tool-browser-session.ts index bc2cdd130..f3404cea5 100644 --- a/lib/src/components/wall/tool-browser-session.ts +++ b/lib/src/components/wall/tool-browser-session.ts @@ -26,8 +26,7 @@ export async function attachAgentBrowserSession({ refreshSurface: (surfaceId: string, patch: Record<string, unknown>) => void; }): Promise<void> { if (!platform.agentBrowserCommand) return; - // `open <url>` is one of the host's fixed webview argv shapes; the CLI boots - // the daemon/browser if it isn't already running. + // The CLI boots the daemon/browser if it isn't already running. const opened = await platform.agentBrowserCommand(session, ['open', url], binaryPath); if (opened.exitCode !== 0) { refreshSurface(surfaceId, { session }); diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index 43e34495e..a052d5756 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -530,10 +530,7 @@ describe('agent-browser host screenshot transport', () => { }); }); -// Every webview token lands on agent-browser's command line, and agent-browser -// reads launch options anywhere on it (`open <url> --executable-path x` launches -// x — checked against 0.31.1), so matching only the verb would let an -// allowlisted command walk around the `binaryPath` gate. +// `parseWebviewCommand` in browser-host-shared.ts says why every token is checked. describe('agent-browser host webview argv', () => { const originalSocketDir = process.env.AGENT_BROWSER_SOCKET_DIR; @@ -550,7 +547,7 @@ describe('agent-browser host webview argv', () => { it('runs exactly the argv shapes the webview sends', async () => { const shapes = [ ['open', 'https://example.com/path?q=1'], - ['open', 'about:blank'], + ['open', 'http://localhost:5173/'], ['back'], ['forward'], ['reload'], @@ -558,6 +555,7 @@ describe('agent-browser host webview argv', () => { ['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'], @@ -567,7 +565,9 @@ describe('agent-browser host webview argv', () => { for (const args of shapes) { spawnMock.mockReset(); enqueueSpawnResults([{}]); - expect(await host.command('dormouse.1.gui-abc', args)).toEqual({ exitCode: 0, stdout: '', stderr: '' }); + // 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]); } }); @@ -577,6 +577,9 @@ describe('agent-browser host webview argv', () => { ['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'], @@ -617,9 +620,10 @@ describe('agent-browser host webview argv', () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it('refuses a launch URL that is not an absolute URL, and relaunches at about:blank instead', async () => { + 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 absolute url is required' }); + 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' }); expect(spawnMock).not.toHaveBeenCalled(); const calls = mockSpawnByCommand({ diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 950912457..a8452685a 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -15,8 +15,8 @@ * 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 fixed argv shapes pass - * (`WEBVIEW_COMMANDS`); not a general exec channel. + * 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. @@ -64,66 +64,32 @@ import type { AgentBrowserStreamStatusResult, } from '../lib/platform/types'; import { privateCaptureDir } from './private-capture-dir'; -import { editScript, generateGuiSession, jpegQuality } from './browser-host-shared'; - -// Every token the webview hands this host — a session, a command's arguments, a -// launch URL — lands on agent-browser's command line, and agent-browser reads -// its launch options anywhere on that line (`open <url> --executable-path x` -// launches x; `close --all` closes every session). So each token is checked -// against the one shape its position takes, never the verb alone -// (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). - -/** A session name the host will put after `--session` and into a state-file - * path: never option-shaped, never a path separator or control character. */ -function isSessionName(value: unknown): value is string { - return typeof value === 'string' && /^(?!-)[^/\\\x00-\x1f\x7f]{1,200}$/.test(value); -} - -/** An absolute URL, untrimmed — a scheme must start with a letter, so it can - * never be read as an option. */ -function isAbsoluteUrl(value: unknown): value is string { - if (typeof value !== 'string' || value !== value.trim()) return false; - try { - new URL(value); - return true; - } catch { - return false; +import { + captureFormat, + editScript, + generateGuiSession, + isAgentBrowserSession, + isBrowsableUrl, + 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]; } } -const TAB_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -// `tab <ref>` selects; these words are the verb's own operations instead. -const TAB_OPERATIONS = new Set(['list', 'new', 'close']); -const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9 ()._-]{0,63}$/; -const isPositiveNumber = (value: string) => /^\d{1,6}(\.\d{1,20})?$/.test(value) && Number(value) > 0; - -/** The argv the webview sends through `command`, one shape per verb: the - * controller's chrome, tab, and Display actions, and a kill/swap `close`. */ -const WEBVIEW_COMMANDS: Record<string, (args: string[]) => boolean> = { - open: (args) => args.length === 1 && isAbsoluteUrl(args[0]), - back: (args) => args.length === 0, - forward: (args) => args.length === 0, - reload: (args) => args.length === 0, - close: (args) => args.length === 0, - // The popped-out pane's CDP observer. - get: (args) => args.length === 1 && args[0] === 'cdp-url', - tab: (args) => args.length === 1 - ? TAB_REF.test(args[0]) && !TAB_OPERATIONS.has(args[0]) - : args.length === 2 && args[0] === 'close' && TAB_REF.test(args[1]), - set: (args) => args[0] === 'viewport' - ? args.length === 4 && args.slice(1).every(isPositiveNumber) - : args[0] === 'device' && args.length === 2 && DEVICE_NAME.test(args[1]), -}; - -/** Whether `args` is exactly one of the `WEBVIEW_COMMANDS` shapes. `args` is - * typed but arrives from webview IPC unvalidated. */ -function isWebviewCommand(args: unknown): boolean { - if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return false; - const [verb, ...rest] = args as string[]; - // Own keys only: a verb of `constructor` must not find the prototype's. - return Object.prototype.hasOwnProperty.call(WEBVIEW_COMMANDS, verb) && WEBVIEW_COMMANDS[verb](rest); -} - 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 @@ -241,11 +207,10 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return undefined; } - function usableRelaunchUrl(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - if (!isAbsoluteUrl(trimmed) || trimmed === 'about:blank') return undefined; - return trimmed; + /** 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 @@ -269,7 +234,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // `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 usableRelaunchUrl(requestedUrl) ?? 'about:blank'; + return isBrowsableUrl(requestedUrl) ? requestedUrl : 'about:blank'; } // agent-browser keeps a long-lived per-session daemon whose headed/headless @@ -402,9 +367,9 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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) => usableRelaunchUrl(t.url))) return; + if (tabs.length < 2 || !tabs.some((t) => isRealTab(t.url))) return; for (const tab of tabs) { - if (!usableRelaunchUrl(tab.url)) { + 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); @@ -452,10 +417,11 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } async function command(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { - if (!isSessionName(session)) { + if (!isAgentBrowserSession(session)) { return { exitCode: 1, stdout: '', stderr: 'a valid session name is required' }; } - if (!isWebviewCommand(args)) { + 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` }; } @@ -463,15 +429,15 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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 (args[0] === 'close') { + if (parsed.kind === 'close') { poppedOutSessions.delete(session); relaunchGenerations.delete(session); } - return runWithBinaryFallback(['--session', session, ...args], binaryPath); + return runWithBinaryFallback(['--session', session, ...webviewArgv(parsed)], binaryPath); } async function edit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise<AgentBrowserEditResult> { - if (!isSessionName(session)) { + if (!isAgentBrowserSession(session)) { return { ok: false, error: 'a valid session name is required' }; } const script = editScript(op); @@ -526,10 +492,10 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string, ): Promise<AgentBrowserScreenshotFileResult> { - if (!isSessionName(session)) { + if (!isAgentBrowserSession(session)) { return { ok: false, error: 'a valid session name is required' }; } - const format = opts.format === 'png' ? 'png' : 'jpeg'; + const format = captureFormat(opts.format); return oneCapture(`file:${format}:${session}`, async (): Promise<AgentBrowserScreenshotFileResult> => { const ext = format === 'png' ? 'png' : 'jpg'; let out: string; @@ -563,7 +529,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser ): 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 = opts.format === 'png' ? 'png' : 'jpeg'; + const format = captureFormat(opts.format); return oneCapture(`bytes:${format}:${session}`, async (): Promise<AgentBrowserScreenshotResult> => { const shot = await screenshotToFile(session, opts, binaryPath); if (!shot.ok) return { ok: false, error: shot.error }; @@ -585,7 +551,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } async function streamStatus(session: string, binaryPath?: string): Promise<AgentBrowserStreamStatusResult> { - if (!isSessionName(session)) return { ok: false, error: 'a valid session name is required' }; + if (!isAgentBrowserSession(session)) return { ok: false, error: 'a valid session name is required' }; const wsPort = await readStreamPort(session, binaryPath); if (!wsPort) return { ok: false, error: 'stream port unavailable' }; return { ok: true, wsPort }; @@ -596,7 +562,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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 }, binaryPath?: string): Promise<AgentBrowserOpenResult> { - if (!isAbsoluteUrl(url)) return { ok: false, error: 'an absolute url is required' }; + if (!isBrowsableUrl(url)) return { ok: false, error: 'an http(s) url is required' }; const session = generateGuiSession(); const args = ['--session', session, ...(opts?.headed ? ['--headed'] : []), 'open', url]; // A headed spawn is a real OS window — track it before the launch so a @@ -627,7 +593,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string, ): Promise<AgentBrowserPopResult> { - if (!isSessionName(session)) return { ok: false, error: 'a valid session name is required' }; + 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}`); @@ -679,7 +645,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser opts: { url?: string }, binaryPath?: string, ): Promise<AgentBrowserPopResult> { - if (!isSessionName(session)) return { ok: false, error: 'a valid session name is required' }; + 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}`); diff --git a/lib/src/host/browser-host-shared.ts b/lib/src/host/browser-host-shared.ts index 3f4979a35..baa41f9a7 100644 --- a/lib/src/host/browser-host-shared.ts +++ b/lib/src/host/browser-host-shared.ts @@ -1,6 +1,7 @@ /** Policy both browser-provider hosts share (`agent-browser-host.ts`, - * `playwright-host.ts`): the fixed editing scripts, GUI session minting, and - * capture quality. See docs/specs/dor-browser.md. */ + * `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'; @@ -36,8 +37,93 @@ 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))); } + +/** A URL a provider may launch or navigate to: http(s) only, untrimmed. */ +export function isBrowsableUrl(value: unknown): value is string { + if (typeof value !== 'string' || value !== value.trim()) return false; + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +/** 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/playwright-host.ts b/lib/src/host/playwright-host.ts index 53c43b1fd..1880cdf9a 100644 --- a/lib/src/host/playwright-host.ts +++ b/lib/src/host/playwright-host.ts @@ -14,7 +14,15 @@ import { type PlaywrightRequest, type PlaywrightResult, } from '../lib/platform/browser-automation'; -import { editScript, generateGuiSession, jpegQuality } from './browser-host-shared'; +import { + captureFormat, + editScript, + generateGuiSession, + isBrowsableUrl, + isPlaywrightSession, + jpegQuality, + parseWebviewCommand, +} from './browser-host-shared'; import { resolvePlaywrightInstall, playwrightWorkspace, type PlaywrightInstall } from './playwright-install'; import { isLoopbackHost } from './loopback-guard'; import { BrowserStreamGrants } from './browser-stream-guard'; @@ -39,9 +47,6 @@ 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)); -const validSession = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9._-]+$/.test(value) && value.length <= 200; -const validUrl = (value: unknown): value is string => { try { return typeof value === 'string' && ['http:', 'https:'].includes(new URL(value).protocol); } catch { return false; } }; -const positive = (n: unknown) => typeof n === 'number' && Number.isFinite(n) && n > 0 && n <= 16384; function realpathOrUndefined(file: string): string | undefined { try { @@ -377,7 +382,7 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v void opening.then(async () => { if (closed || generation !== generations.get(key) || v.disposed) return; const pages = pagesOf(v); - if (!pages.some(p => validUrl(p.url()))) return; + 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)]); @@ -411,22 +416,25 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v const install = resolvePlaywrightInstall(request.binaryPath); const cwd = typeof request.cwd === 'string' && path.isAbsolute(request.cwd) ? request.cwd : process.cwd(); const session = request.op === 'open' ? generateGuiSession() : request.session; - if (!validSession(session)) throw new Error('Invalid Playwright session name'); + if (!isPlaywrightSession(session)) throw new Error('Invalid Playwright session name'); const b = bind(session, cwd, install); 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' && !validUrl(request.url)) throw new Error('Browser navigation requires an http(s) URL'); - const url = validUrl(request.url) ? request.url : undefined; + 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'; const deadline = Date.now() + REQUEST_BUDGET_MS; const v = await serialize(b, () => launch(b, url, isHeaded, fresh, deadline)); return { ok: true, session, cwd, binaryPath: install.binary, wsPort: v.port, nativeIdentity: b.key }; } - if (request.op === 'command' && request.args?.[0] === 'close') { + // 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); @@ -443,7 +451,7 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v const page = v.page; if (!page) throw new Error('No Playwright page is open'); if (request.op === 'screenshot') { - const format = request.format === 'png' ? 'png' : 'jpeg'; + const format = captureFormat(request.format); const cdp = await control(v, page); const { data } = await cdp.send('Page.captureScreenshot', { format, ...(format === 'jpeg' ? { quality: jpegQuality(request.quality) } : {}), captureBeyondViewport: false }); // Keep the cross-host contract a plain typed array, including VS Code's message transport. @@ -459,34 +467,38 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v if (request.edit !== 'selectAll' && text) await deps.writeClipboardText(text); return { ok: true, text }; } - if (request.op !== 'command' || !Array.isArray(request.args) || !request.args.every(a => typeof a === 'string')) throw new Error('Invalid browser operation'); - const [cmd, sub, ...args] = request.args; - if (cmd === 'open' && request.args.length === 2 && validUrl(sub)) await page.goto(sub, { waitUntil: 'commit' }); - else if (cmd === 'reload' && !sub) await page.reload({ waitUntil: 'commit' }); - else if (cmd === 'back' && !sub) await page.goBack({ waitUntil: 'commit' }); - else if (cmd === 'forward' && !sub) await page.goForward({ waitUntil: 'commit' }); - else if (cmd === 'tab') { - if (sub === 'list') return { ok: true, exitCode: 0, stdout: JSON.stringify({ tabs: await tabsOf(v) }), stderr: '' }; - let nativeArgs: string[]; - if (sub === 'close' && args.length === 1 && /^\d+$/.test(args[0])) nativeArgs = ['tab-close', args[0]]; - else if (request.args.length === 2 && /^\d+$/.test(sub ?? '')) nativeArgs = ['tab-select', sub]; - else throw new Error('Unsupported tab operation'); - const r = await cli(b, nativeArgs); - await refresh(v); - return { ok: r.exitCode === 0, ...r }; - } else if (cmd === 'set') { - const device = sub === 'device' && args.length === 1 ? install.library.devices[args[0]] : undefined; - if (!device && !(sub === 'viewport' && args.length === 3)) throw new Error('Invalid viewport/device'); - const [width, height, dpr] = device - ? [device.viewport.width, device.viewport.height, device.deviceScaleFactor] - : args.map(Number); - if (!positive(width) || !positive(height) || !positive(dpr) || dpr > 10) throw new Error('Invalid viewport/device'); - 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 }); - } else throw new Error('Unsupported Playwright host command'); + 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: '' }; } diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index cf4bcccc4..a8ba2d488 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -358,10 +358,9 @@ export interface PlatformAdapter { 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 the fixed - // argv shapes the host's `WEBVIEW_COMMANDS` table accepts (tab select/close, - // `set viewport`/`set device`, `open <url>`, back/forward/reload, `close`, - // `get cdp-url`), never a general exec path. `binaryPath` is the + // 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>; @@ -379,7 +378,7 @@ export interface PlatformAdapter { // changed stream frame as its final, lower-resolution image. agentBrowserScreenshot?(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise<AgentBrowserScreenshotResult>; // Reads the current stream port for an already-running session. This is a - // purpose-built status channel, not one of agentBrowserCommand's argv shapes, + // purpose-built status channel, not an agentBrowserCommand, // so restored panels can recover from a stale persisted wsPort after reload. agentBrowserStreamStatus?(session: string, binaryPath?: string): Promise<AgentBrowserStreamStatusResult>; // The WebSocket URL for a session's stream port. Hosts whose webview origin diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index a1f68d958..d0e9353f2 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": 5800, + "docs/specs/dor-browser.md": 5850, "docs/specs/dor-cli.md": 6300, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, From 829c2be821d9b29d0c000af1907683d79e99891c Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 19:51:45 -0700 Subject: [PATCH 13/18] Consolidate the iframe refusal, launch binding, and capture paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup after review (quality only, except the three tightenings noted): - One iframe refusal: `iframeRefusal(url)` in browser-url.ts and one `IFRAME_HTTP_ONLY` wording in iframe-proxy-types.ts serve the proxy, `surface.iframe`, the panel, the Display modal and the new-tab prompt. The Wall's automation→iframe swap now refuses an https page too (before, only the modal's disabled radio stopped it), and `onOpenBrowserPane` picks the renderer itself instead of taking one. The iframe remedy always says `dor ab open <url>`. - One launch-then-bind helper, `bindLaunch`, for the new-tab agent-browser pane and the pane context menu's connect, through `browserPlatform`. - A paint made only because a capture is overdue no longer supersedes it, and the pulses of that wait leave no shot owed: the held capture is drawn when `open` releases it, instead of being discarded and taken again. - VS Code waits 30s, not 10s, for an agent-browser command, edit or capture reply, past the CLI's 25s action timeout, so the webview never re-asks while the extension host is still working (Tauri already waited 30s). - DPR changes come from a `(resolution)` media query re-armed on each change, replacing the per-frame window `resize` listener and its forced layout; the pane observer publishes size changes itself. `dprMatch` joins `dimsMatch`. - One loopback-hostname predicate, `isLoopbackHostname` in lib/src/lib/ ip-literal.ts with the IPv4-mapped normalization the SSRF guard uses, for both the proxy's error pages and the browser URL helpers. - IframePanel reuses `modalActionButton` and phosphor `XIcon`, and opens agent-browser through its own `setRenderMode`. - Prose: one mechanism comment per story at the owning code; the https rule is one entry-point table in dor-browser.md with pointers elsewhere; the skill routes `pw-*` once. The stall timer becomes a `stalled` log field, the screenshot format normalization is shared, `HEAD_MARKER` reuses `instrumentHtml`'s regexes, and the provisional-paint tests share a fixture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 55 +++-- docs/specs/dor-browser.rationale.md | 2 +- dor/skill.md | 20 +- lib/src/components/Wall.test.tsx | 13 +- lib/src/components/Wall.tsx | 74 +++--- .../wall/AgentBrowserScreenModal.test.tsx | 2 +- .../wall/AgentBrowserScreenModal.tsx | 11 +- lib/src/components/wall/IframePanel.test.tsx | 4 +- lib/src/components/wall/IframePanel.tsx | 55 ++--- .../agent-browser-screenshot-loop.test.ts | 27 +-- .../wall/agent-browser-screenshot-loop.ts | 48 ++-- .../agent-browser-surface-controller.test.ts | 223 ++++++++---------- .../wall/agent-browser-surface-controller.ts | 59 +++-- lib/src/components/wall/browser-url.ts | 28 +-- lib/src/components/wall/use-dor-control.ts | 14 +- lib/src/components/wall/wall-context.tsx | 2 +- lib/src/host/agent-browser-host.test.ts | 4 +- lib/src/host/agent-browser-host.ts | 9 +- lib/src/host/iframe-proxy-rewrite.test.ts | 2 +- lib/src/host/iframe-proxy-rewrite.ts | 85 ++----- lib/src/host/iframe-proxy.ts | 4 +- lib/src/lib/ip-literal.ts | 73 ++++++ lib/src/lib/platform/iframe-proxy-types.ts | 5 + lib/src/lib/platform/vscode-adapter.test.ts | 28 +++ lib/src/lib/platform/vscode-adapter.ts | 11 +- scripts/spec-word-budgets.json | 2 +- 26 files changed, 442 insertions(+), 418 deletions(-) create mode 100644 lib/src/lib/ip-literal.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 855f20a06..ad8b3840d 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -22,9 +22,8 @@ handle (`docs/specs/dor-cli.md` → Browser Open Target Resolution): - `dor ab ...` / `dor agent-browser ...` forwards to the user's own `agent-browser` binary and binds that session to a browser pane. - `dor pw ...` / `dor playwright ...` binds the user-installed Playwright CLI. -- `dor iframe <url>` uses the iframe renderer, for `http://` pages: a host with - the iframe proxy refuses an `https://` target at `surface.iframe`, naming - `dor ab open <url>`. +- `dor iframe <url>` uses the iframe renderer, for `http://` pages + ([Iframe Renderer](#iframe-renderer)). Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, `lib/src/components/wall/browser-surface.ts` (`resolveRenderMode`, @@ -171,8 +170,8 @@ robot rides each provider’s screencast parent, each nested resolution row carr 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 disable the iframe option, naming why, for an `https://` page on a host -with the iframe proxy**; it lists that the embed keeps no logins or cookies. +The iframe option lists that the embed keeps no logins or cookies (for +`https://`, see [Iframe Renderer](#iframe-renderer)). Resolution controls apply to both screencast providers, as GUI wrappers around native commands: **Resize with pane** is Dormouse-owned sync issuing @@ -317,10 +316,9 @@ capture is **overdue** — then a crisp device-resolution - **Both paths are latest-only.** - **No capture may start inside the provisional window** (rationale). -- **A capture is overdue past twice the average round trip, at least 400ms**, - and **is never re-issued while its host call is unresolved**: it is queued - behind a blocking daemon command such as a page-loading `open`, which would - otherwise hold the previous page on screen for the whole load (rationale). +- **A capture is overdue past twice the average round trip, at least 400ms. It + is never re-issued while its host call is unresolved, and a paint made only + because it is overdue does not supersede it** (rationale). - **Must leave the loop dirty when capture or bitmap decode becomes stale** (rationale). Pinned by `agent-browser-screenshot-loop.test.ts`. - **Any canvas writer but the crisp loop must bump the draw generation** in its @@ -397,13 +395,17 @@ sidecar/Rust adapter. | Method | 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 in flight**: a request made meanwhile joins it, since the webview re-asks when its adapter times out. | +| `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 in flight**: a request made meanwhile joins it. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `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` | Spawn a GUI-owned session for iframe -> agent-browser; 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 @@ -470,16 +472,26 @@ The proxy instruments any `http://` upstream, loopback and remote alike: - Unreachable / timed-out upstream: served Dormouse error page, distinct for "couldn't connect" and "didn't respond in 30s of socket idle", in the system color scheme; **only a loopback upstream is called a dev server**. -- HTTPS: synchronous `scheme` failure. **Every panel error but a non-http(s) +- HTTPS: refused, per the table below. **Every panel error but a non-http(s) URL offers Open in agent-browser** (a swap to `ab-screencast`) where the host - can launch one, with `dor ab open <url>` as the fallback text — agent-browser - is the path for real HTTPS or a login. + can launch one, with `dor ab open <url>` 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; pinned by `lib/src/host/iframe-proxy-rewrite.test.ts`. +**Must refuse `https://` at every entry to the iframe renderer on a host with +the proxy, in the one `IFRAME_HTTP_ONLY` wording, naming `dor ab open <url>`** +(a host without it frames https raw): + +| Entry | Outcome | +| --- | --- | +| `surface.iframe` (`dor iframe`) | refused before any pane opens | +| Display modal / render swap to `iframe` | option disabled with the reason; the Wall's swap refuses too | +| New-tab request from a framed page | an `ab-screencast` pane, bound to its launch like a render swap, closed if the launch fails | +| A pane already holding one | synchronous `scheme` panel error | + Header rewriting: | Direction | Header | Treatment | @@ -520,8 +532,9 @@ keyboard and pointer interaction inside the frame by design. Source of truth: `lib/src/components/wall/IframePanel.tsx`, `lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts` (`FRAMING_RESPONSE_HEADERS`, `HOP_BY_HOP_RESPONSE_HEADERS`, `instrumentHtml`, -`isBlockedAddress`, `errorPageHtml`), `lib/src/lib/platform/iframe-proxy-types.ts`, -`surface.iframe` in `lib/src/components/wall/use-dor-control.ts`. +`isBlockedAddress`, `errorPageHtml`), `lib/src/lib/platform/iframe-proxy-types.ts` +(`IFRAME_HTTP_ONLY`), `iframeRefusal` in `lib/src/components/wall/browser-url.ts`, +`isLoopbackHostname` in `lib/src/lib/ip-literal.ts`. ### Iframe Shim @@ -540,15 +553,13 @@ Leader messages feed the same Wall command-mode exit path as in-document dual-tap; `IframePanel` maps proxy-origin `location` URLs back to upstream URLs for chrome/history without reloading the frame. -New-tab requests show an overlay: accept opens an adjacent browser pane — -**an `https://` URL opens as an `ab-screencast` pane** on a proxy host that can -launch agent-browser, bound to its launch like a render swap and closed if it -fails; cancel drops it. +New-tab requests show an overlay: accept opens an adjacent browser pane (for +`https://`, see [Iframe Renderer](#iframe-renderer)); cancel drops it. **A proxied frame `load` with no `location` report within 1s marks the document -uninstrumented** — it left the proxy, was refused, or its grant is gone — and a -banner offers Reload and Open in agent-browser. Only a report naming the proxy -origin counts, including one up to 250ms before the load. +uninstrumented** (off the proxy, refused, or its grant gone), and a banner +offers Reload and Open in agent-browser. Only a report naming the proxy origin +counts, including one up to 250ms before the load. Source of truth: `lib/src/host/iframe-proxy-rewrite.ts` (`iframeShim`), `lib/src/components/wall/browser-url.ts` (`browserSurfaceUrl`), diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 0c6e53471..3a6156ab2 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -48,7 +48,7 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a **Why no crisp capture starts inside the provisional window.** A host screenshot round trip is ~120ms against a ~20Hz stream, so *every* capture started while provisional frames are still landing is superseded before it resolves; the shots skipped would never have drawn anything. -**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. The adapters' own reply timeouts (VS Code 10s, standalone 30s) re-ask the same way, which is why the host, not the loop, joins concurrent captures. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. +**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. VS Code's adapter gave up on a reply after 10s and the webview re-asked the same way, posting the full JPEG to both requests once it came; every adapter now waits 30s, and the host still joins concurrent captures, since surfaces can share a session. When each overdue paint counted against the held capture, it was discarded on arrival and re-taken — an extra full capture on every slow navigation, the crisp frame a round trip late. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. **Why a stale-dropped capture must leave the loop dirty.** A provisional frame can supersede the host capture or its pending bitmap decode. Nothing is guaranteed to pulse the loop again: a single pointer move over a static page pulses exactly once, and that one pulse is consumed by the very capture the provisional paint supersedes — leaving the pane on the blurry provisional frame until the page happens to change on its own. diff --git a/dor/skill.md b/dor/skill.md index e33e06301..be292806d 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -54,8 +54,8 @@ three ways: (`--command`, `--cwd`, `--port`) into a handle. Text output is designed for you to read: it is terse and carries the same -refs. Reach for `--json` (every command except `dor ab` and `dor pw` supports it) -only when a shell script or pipeline using `jq` consumes the output. +refs. Reach for `--json` (every command except `dor ab` and `dor pw` supports +it) only when a shell script or pipeline using `jq` consumes the output. ## Surface handles @@ -194,9 +194,9 @@ A Window holds several Workspaces, each with its own surfaces and its own you were started in, and creating one is a change the user sees. When you do, name one as `workspace:<n>` (positional) or `workspace:<name>`, and pass `--workspace <ref>` to any command — `split`, `ensure`, `read`, `send`, -`await`, `kill`, `iframe`, `ab`, `pw` — to act in another one. A surface's stable id -finds it in any Workspace without that flag; `surface:N` does not, since every -Workspace has one. `close` refuses a Workspace holding your running work +`await`, `kill`, `iframe`, `ab`, `pw` — to act in another one. A surface's +stable id finds it in any Workspace without that flag; `surface:N` does not, +since every Workspace has one. `close` refuses a Workspace holding your running work unless you pass `--force`. ### `dor ab` / `dor agent-browser` — agent-drivable browser pane @@ -222,17 +222,15 @@ independent browsers at once. `dor list` works here exactly as it does for `read` / `send` / `await` / `kill`. Prefer it whenever you hold a ref rather than a key — it is the only way to reach a browser the *user* opened from the GUI, which has no key. It fails on a -terminal (no browser), on a Playwright browser (drive it with `dor pw ---surface`), and on an `iframe`-rendered surface (nothing to drive — open it -with `dor ab` instead). The three identity flags are mutually exclusive. +terminal (no browser), and on an `iframe`-rendered surface (nothing to drive — +open it with `dor ab` instead). The three identity flags are mutually exclusive. `dor ab` has no `--json` of its own; any JSON flags belong to `agent-browser`. ### `dor pw` / `dor playwright` — Playwright browser pane -For a user or project that uses Playwright, or a `pw-*` browser. Forwards to -your installed `@playwright/cli` (`npm i -g @playwright/cli`; override its path -with `DORMOUSE_PLAYWRIGHT_BIN`). +Forwards to your installed `@playwright/cli` (`npm i -g @playwright/cli`; +override its path with `DORMOUSE_PLAYWRIGHT_BIN`). **Launch once with `open`, then navigate with `goto`.** Playwright's `open` restarts the browser, dropping every tab and cookie, where `dor ab open` only diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index b1b7574e7..d4376de54 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3903,7 +3903,7 @@ describe('Wall on the Lath engine', () => { (fake as PlatformAdapter).createIframeProxyUrl = vi.fn(async () => ({ ok: true as const, url: 'http://127.0.0.1:61234/' })); expect(await respond('https://example.com/')).toEqual({ ok: false, - error: 'iframe panes show http:// pages only; open https://example.com/ with `dor ab open https://example.com/`', + error: 'the embedded view frames http:// pages only — open it with dor ab open https://example.com/', }); expect((await respond('http://localhost:5173/'))?.ok).toBe(true); }); @@ -3932,18 +3932,25 @@ 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', {}, undefined); + expect(fake.agentBrowserOpen).toHaveBeenCalledWith('https://accounts.example/login', { headed: false }, undefined); // 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 }); }); await flush(); expect(await dispatchResolveAgentBrowser(tab)).toMatchObject({ ok: true, result: { session: 'dormouse.1.gui-abc' } }); + // Nor can it be swapped back into an iframe that would refuse it. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await act(async () => { getAgentBrowserScreenController(tab)?.actions.setRenderMode?.('iframe'); }); + await flush(); + expect(leafIds()).toContain(tab); + expect(getAgentBrowserScreenController(tab)?.snapshot().renderMode).toBe('ab-screencast'); + expect(warn).toHaveBeenCalledWith(`[dormouse] cannot swap surface '${tab}' to iframe: the embedded view frames http:// pages only`); + // A launch that fails takes its pane with it. const beforeFailure = leafIds(); await openTab('https://other.example/'); const [failed] = leafIds().filter((id) => !beforeFailure.includes(id)); - vi.spyOn(console, 'warn').mockImplementation(() => {}); await act(async () => { launches[1]({ ok: false, error: 'agent-browser binary not found' }); }); await flush(); expect(leafIds()).not.toContain(failed); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 9148f13cb..1ff066ecd 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -25,7 +25,7 @@ const RemotePairingModalHost = lazy(() => ); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; -import { automationProvider, browserPlatform, browserSessionKey, isPopout, LaunchBinaryPath, PROVIDER_LABEL } from './wall/browser-automation'; +import { automationProvider, browserPlatform, browserSessionKey, isPopout, LaunchBinaryPath, PROVIDER_LABEL, type AutomationRenderMode } from './wall/browser-automation'; import { isAllowedBinaryFor } from '../lib/agent-browser-binary'; import { isToolRender } from '../lib/platform/tool-types'; import { disposeAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller'; @@ -89,7 +89,7 @@ import { surfaceKindFromParams, isToolParams, namespacedToolKey, toolKeysEqual, toolPendingFromParams, toolScopeFromParams, } from './wall/browser-surface'; -import { browserSurfaceUrl, hostPathDisplay } from './wall/browser-url'; +import { browserSurfaceUrl, hostPathDisplay, iframeRefusal } from './wall/browser-url'; import { WorkspaceSelectionOverlay } from './wall/WorkspaceSelectionOverlay'; import { LathHost } from './wall/LathHost'; import { @@ -1913,6 +1913,23 @@ export function Wall({ // --- Wall actions (for tab buttons) --- + /** Launch the browser `mode` names at `url` and bind its session to the eager + * Surface `eagerId` (docs/specs/dor-browser.md → Pane Context Menu Connect), + * or close the session when that Surface is gone by then. Answers the + * launch's error, leaving the eager Surface to the caller. */ + const bindLaunch = useCallback(async (eagerId: string, mode: AutomationRenderMode, url: string, cwd?: string): Promise<string | null> => { + const provider = automationProvider(mode)!; + const open = browserPlatform(provider, cwd).agentBrowserOpen; + if (!open) return `${PROVIDER_LABEL[provider]} is unavailable on this host`; + const result = await open(url, { headed: isPopout(mode) }, launchBinaryPath.get(provider)); + if (!result.ok || !result.session) return result.error ?? `Could not open ${PROVIDER_LABEL[provider]}`; + launchBinaryPath.remember(provider, result.binaryPath); + const binding = boundBrowserParams(result.session, result, cwd); + if (!lath.getMeta(eagerId) || lath.isDying(eagerId)) closeAgentBrowserSession({ renderMode: mode, ...binding }); + else updateSurfaceParams(eagerId, binding); + return null; + }, [launchBinaryPath, lath, updateSurfaceParams]); + const wallActions: WallActions = useMemo(() => ({ onKill: (id: string) => { // The confirm keystroke must reach the Wall, not the pane's xterm. @@ -2007,7 +2024,7 @@ export function Wall({ 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' && !platform.agentBrowserOpen) || (mode === 'iframe' && iframeRefusal(url))) return; closeAgentBrowserSession(params); disposeAgentBrowserSurfaceController(id); lath.store.updateParams(id, { @@ -2039,8 +2056,9 @@ export function Wall({ // Canonical params.url (mirrored from the chrome snapshot) first; fall // back to the live snapshot for a surface that hasn't reported a tab yet. const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; - if (!url) { - console.warn(`[dormouse] cannot swap surface '${id}' to iframe: no URL observed yet`); + const refused = url ? iframeRefusal(url) : 'no URL observed yet'; + if (!url || refused) { + console.warn(`[dormouse] cannot swap surface '${id}' to iframe: ${refused}`); return; } replaceSurface(id, { @@ -2146,32 +2164,26 @@ export function Wall({ }); } }, - onOpenBrowserPane: (id, url, renderMode = 'iframe') => { - // A new-tab request from the iframe shim → open the URL as a new browser - // pane, split next to the source (docs/specs/dor-browser.md → "Iframe - // Shim"). An agent-browser pane lands at once, session-less and inert, - // and binds the session its launch returns, like a render swap. + onOpenBrowserPane: (id, url) => { + // A new-tab request from the iframe shim → a new browser pane split next + // to the source (docs/specs/dor-browser.md → "Iframe Shim"): an iframe, + // 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 = renderMode === 'ab-screencast'; - const open = getPlatform().agentBrowserOpen; - if (agentBrowser && !open) return; + const agentBrowser = !!iframeRefusal(url) && !!getPlatform().agentBrowserOpen; const created = createContentSurface({ minimized: false, - params: { surfaceType: 'browser', renderMode, url, ...(agentBrowser ? { syncEngaged: true } : {}) }, + params: agentBrowser + ? { surfaceType: 'browser', renderMode: 'ab-screencast', url, syncEngaged: true } + : { surfaceType: 'browser', renderMode: 'iframe', url }, reference, title: hostPathDisplay(url, true), }); - if (!agentBrowser || !created.ok || !open) return; + if (!agentBrowser || !created.ok) return; const eagerId = created.value.id; - open(url, {}, launchBinaryPath.get('agent-browser')).then((res) => { - if (!res.ok || !res.session) throw new Error(res.error ?? '(no session)'); - launchBinaryPath.remember('agent-browser', res.binaryPath); - const bound = boundBrowserParams(res.session, res, undefined); - if (!lath.getMeta(eagerId) || lath.isDying(eagerId)) closeAgentBrowserSession({ renderMode, ...bound }); - else updateSurfaceParams(eagerId, bound); - }).catch((error) => { - console.warn(`[dormouse] could not open ${url} in agent-browser:`, error); + void bindLaunch(eagerId, 'ab-screencast', url).catch(messageOf).then((failure) => { + if (!failure) return; + console.warn(`[dormouse] could not open ${url} in agent-browser:`, failure); if (lath.getMeta(eagerId) && !lath.isDying(eagerId)) void closeSurfaceRef.current(eagerId, 'silent'); }); }, @@ -2179,7 +2191,7 @@ export function Wall({ onResolveToolApproval: (id: string, choice: 'upstream' | 'folder' | 'decline' | 'retry') => { void resolveToolApproval(id, choice); }, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, resolveToolApproval, lath, nav]); + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, resolveToolApproval, bindLaunch, lath, nav]); const contextPortLaunches = useRef(new Map<string, Promise<void>>()); const openContextPort = useCallback(async (id: string, entry: PortUrlEntry, mode: PortMode): Promise<void> => { if (mode === 'system') { getPlatform().openExternal?.(entry.url); return; } @@ -2210,20 +2222,16 @@ export function Wall({ params: { surfaceType: 'browser', renderMode: mode, url: entry.url, cwd, syncEngaged: true, contextPortKey: key }, title: hostPathDisplay(entry.url, true) }); if (!created.ok) throw new Error(created.message); enterTerminalMode(created.value.id); - if (!provider || !platform) return; - const result = await platform.agentBrowserOpen!(entry.url, { headed: isPopout(mode) }, launchBinaryPath.get(provider)); - if (!result.ok || !result.session) { + if (mode === 'iframe') return; + const failure = await bindLaunch(created.value.id, mode, entry.url, cwd); + if (failure) { await closeSurface(created.value.id); - throw new Error(result.error ?? `Could not open ${PROVIDER_LABEL[provider]}`); + throw new Error(failure); } - launchBinaryPath.remember(provider, result.binaryPath); - const binding = boundBrowserParams(result.session, result, cwd); - if (!lath.getMeta(created.value.id) || lath.isDying(created.value.id)) { closeAgentBrowserSession({ renderMode: mode, ...binding }); return; } - updateSurfaceParams(created.value.id, binding); })(); contextPortLaunches.current.set(key, operation); try { await operation; } finally { contextPortLaunches.current.delete(key); } - }, [buildDorSurfaces, findSurfaceByParams, createContentSurface, enterTerminalMode, closeSurface, lath, revealSurface, updateSurfaceParams]); + }, [buildDorSurfaces, findSurfaceByParams, createContentSurface, enterTerminalMode, closeSurface, bindLaunch, revealSurface, updateSurfaceParams]); const contextActions = useMemo(() => ({ id: contextSourceId, mounted: terminalContext, diff --git a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx index 85b5acf83..728c666d2 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx @@ -117,7 +117,7 @@ describe('AgentBrowserScreenModal', () => { act(() => root.render(<AgentBrowserScreenModal controller={getAgentBrowserScreenController('secure')!} label="surface:5" onClose={() => {}} />)); expect(document.body.textContent).toContain('no logins/cookies'); expect(iframeRow().querySelector('input')!.disabled).toBe(true); - expect(iframeRow().textContent).toContain('https:// pages can’t be embedded'); + expect(iframeRow().textContent).toContain('the embedded view frames http:// pages only'); secure.dispose(); const local = registerStubScreen('local', { diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index 9da44c515..ea45560c3 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -30,8 +30,7 @@ import { 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 { getPlatformOrNull } from '../../lib/platform'; -import { isHttpsUrl } from './browser-url'; +import { iframeRefusal } from './browser-url'; import { AgentRobotIcon, BROWSER_DISPLAY_LABEL, @@ -95,11 +94,7 @@ export function AgentBrowserScreenModal({ // 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. const offered = (mode: RenderMode) => mode === currentMode || controller.renderModes.includes(mode); - // A host with the iframe proxy frames http:// only, so swapping an https:// - // page to the embed would land on its refusal. - const iframeRefusal = currentMode !== 'iframe' && isHttpsUrl(chrome?.url ?? '') && !!getPlatformOrNull()?.createIframeProxyUrl - ? 'https:// pages can’t be embedded' - : undefined; + 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); @@ -271,7 +266,7 @@ export function AgentBrowserScreenModal({ icon={<BrowserDisplayIcon mode="iframe" size={14} className="text-muted" />} label={BROWSER_DISPLAY_LABEL.iframe} features={[[false, 'agents cannot read/write'], [false, 'http only'], [false, 'no logins/cookies'], [true, 'native human experience']]} - disabledReason={iframeRefusal} + disabledReason={embedRefusal ?? undefined} /> )} </div> diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index ab40d7efa..4b101b394 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -401,11 +401,11 @@ describe('iframe failures offer a way out', () => { await openWindow('https://accounts.example/login'); expect(button('Open in new pane')).toBeUndefined(); await act(async () => { button('Open in agent-browser')!.click(); }); - expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'https://accounts.example/login', 'ab-screencast'); + expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'https://accounts.example/login'); await openWindow(`${PROXY}/docs`); await act(async () => { button('Open in new pane')!.click(); }); - expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'http://example.test/docs', 'iframe'); + expect(onOpenBrowserPane).toHaveBeenLastCalledWith('iframe-newtab', 'http://example.test/docs'); }); }); diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index f565fd909..8b979fb4a 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -1,9 +1,11 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { XIcon } from '@phosphor-icons/react'; import { modalActionButton, PaneMessage, PopupButtonRow, popupButton, TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; import { getPlatform } from '../../lib/platform'; import { registerProxyOrigin } from '../../lib/iframe-proxy-registry'; import { registerSurfaceFocusHandle } from '../../lib/terminal-registry'; import type { IframeProxyResult } from '../../lib/platform/types'; +import { IFRAME_HTTP_ONLY } from '../../lib/platform/iframe-proxy-types'; import type { PaneProps } from './pane-props'; import { usePaneChrome } from './use-pane-chrome'; import { PaneWriteContext, WallActionsContext } from './wall-context'; @@ -16,7 +18,7 @@ import { } from './agent-browser-screen'; import { isToolParams } from './browser-surface'; import { offeredRenderModes } from './browser-automation'; -import { browserSurfaceUrl, hostPathDisplay, isHttpsUrl } from './browser-url'; +import { browserSurfaceUrl, hostPathDisplay, iframeRefusal } from './browser-url'; // Sandbox every framed page, proxied or raw, so a tool's // `if (top !== self) top.location = …` framebust cannot navigate the Wall away — @@ -36,16 +38,11 @@ const IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups // `clipboard-read` most pointedly, since a terminal's clipboard is where users // paste secrets. Writing to the clipboard needs a user gesture and cannot read. const IFRAME_ALLOW = 'autoplay; clipboard-write; fullscreen'; -// The injected shim reports the document's location on DOMContentLoaded and -// again on pageshow, just after load. A proxied frame whose `load` brings no -// report within this window is showing a document the shim is not in: it left -// the proxy, was refused, or the proxy's grant is gone. +// Uninstrumented documents (docs/specs/dor-browser.md → "Iframe Shim"). The +// lead admits the shim's pageshow report, which races the frame's load event +// to the parent. const SHIM_REPORT_TIMEOUT_MS = 1000; -// A report this shortly before `load` still counts: the pageshow report and the -// frame's load event race each other to the parent. const SHIM_REPORT_LEAD_MS = 250; -// On hosts with the proxy, which is every host that runs `dor`. -const HTTP_ONLY = 'the embedded view frames http:// pages only'; type Resolution = | { kind: 'empty' } @@ -241,10 +238,6 @@ export function IframePanel({ id, title, params }: PaneProps) { const renderModes = useMemo(() => offeredRenderModes(isTool, null), [isTool]); const swapCapable = renderModes.some((mode) => mode !== 'iframe'); const agentBrowserCapable = renderModes.includes('ab-screencast'); - const openInAgentBrowser = useMemo( - () => (agentBrowserCapable ? () => actionsRef.current.onSwapRenderMode(id, 'ab-screencast') : undefined), - [id, agentBrowserCapable], - ); const screenActions = useMemo<ScreenActions>(() => ({ engageSync() {}, applyDevice() {}, @@ -257,6 +250,8 @@ export function IframePanel({ id, title, params }: PaneProps) { ? (mode) => { if (mode !== 'iframe' && renderModes.includes(mode)) actionsRef.current.onSwapRenderMode(id, mode); } : undefined, }), [id, swapCapable, renderModes]); + const setRenderMode = screenActions.setRenderMode; + const openInAgentBrowser = setRenderMode && agentBrowserCapable ? () => setRenderMode('ab-screencast') : undefined; const chromeActions = useMemo<ChromeActions>(() => ({ navigate(next) { commitUrl(next); }, back() { goToHistoryIndex(historyIndexRef.current - 1); }, @@ -439,17 +434,19 @@ export function IframePanel({ id, title, params }: PaneProps) { {openInAgentBrowser && ( <button type="button" className={popupButton()} onClick={openInAgentBrowser}>Open in agent-browser</button> )} - <button type="button" className={popupButton()} aria-label="Dismiss" onClick={() => setUninstrumented(false)}>✕</button> + <button type="button" className={popupButton()} aria-label="Dismiss" onClick={() => setUninstrumented(false)}> + <XIcon size={12} weight="bold" /> + </button> </PopupButtonRow> )} {pendingOpenUrl && ( <NewTabPrompt url={pendingOpenUrl} - // On a proxy host an https:// pane would only land on the scheme refusal. - inAgentBrowser={agentBrowserCapable && isHttpsUrl(pendingOpenUrl) && !!getPlatform().createIframeProxyUrl} - onOpen={(renderMode) => { + // Where the Wall will open it (`onOpenBrowserPane` decides the same way). + refusal={agentBrowserCapable ? iframeRefusal(pendingOpenUrl) : null} + onOpen={() => { setPendingOpenUrl(null); - actions.onOpenBrowserPane?.(id, pendingOpenUrl, renderMode); + actions.onOpenBrowserPane?.(id, pendingOpenUrl); }} onCancel={() => setPendingOpenUrl(null)} /> @@ -458,10 +455,11 @@ export function IframePanel({ id, title, params }: PaneProps) { ); } -function NewTabPrompt({ url, inAgentBrowser, onOpen, onCancel }: { +function NewTabPrompt({ url, refusal, onOpen, onCancel }: { url: string; - inAgentBrowser: boolean; - onOpen: (renderMode: 'iframe' | 'ab-screencast') => void; + /** Why an iframe cannot show it, so it opens in agent-browser instead. */ + refusal: string | null; + onOpen: () => void; onCancel: () => void; }) { return ( @@ -474,23 +472,23 @@ function NewTabPrompt({ url, inAgentBrowser, onOpen, onCancel }: { <button type="button" onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => { e.stopPropagation(); onOpen(inAgentBrowser ? 'ab-screencast' : 'iframe'); }} - className="rounded border border-border px-2.5 py-1 text-sm text-foreground transition-colors hover:border-foreground" + onClick={(e) => { e.stopPropagation(); onOpen(); }} + className={modalActionButton({ tone: 'primary' })} > - {inAgentBrowser ? 'Open in agent-browser' : 'Open in new pane'} + {refusal ? 'Open in agent-browser' : 'Open in new pane'} </button> <button type="button" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onCancel(); }} - className="rounded border border-border px-2.5 py-1 text-sm text-muted transition-colors hover:text-foreground" + className={modalActionButton({ tone: 'secondary' })} > Cancel </button> </div> <div className="text-xs text-muted/80"> - {inAgentBrowser - ? `It is an https:// page, and ${HTTP_ONLY}.` + {refusal + ? `It is an https:// page, and ${refusal}.` : 'Pages that open many tabs work better in agent-browser — open the chip → Display.'} </div> </div> @@ -544,9 +542,8 @@ function PanelMessage({ resolution, url, onOpenInAgentBrowser }: { function messageFor(resolution: Extract<Resolution, { kind: 'error' }>): string { switch (resolution.reason) { case 'non-http': - return `Can’t frame this URL — ${HTTP_ONLY}.`; case 'scheme': - return `Can’t frame this URL — ${resolution.detail ?? HTTP_ONLY}.`; + return `Can’t frame this URL — ${resolution.detail ?? IFRAME_HTTP_ONLY}.`; case 'unreachable': default: return resolution.detail ? `Couldn’t reach the server — ${resolution.detail}.` : 'Couldn’t reach the server.'; 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 817093368..73c32f2a9 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts @@ -239,13 +239,10 @@ describe('screenshot loop backpressure', () => { }); describe('screenshot loop behind a blocking command', () => { - // `open` holds the daemon's queue until the page loads (up to 25s), so a - // capture issued meanwhile waits behind it. That is not a wedged host. - it('reports the capture overdue and never issues a second one while it waits', async () => { + it('reports the capture overdue, never re-issues it, and owes a shot only for pulses after its release', 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']); - vi.spyOn(console, 'warn').mockImplementation(() => {}); const draw = vi.fn(); const loop = createScreenshotLoop({ getSession: () => 'sess', @@ -258,33 +255,33 @@ describe('screenshot loop behind a blocking command', () => { await vi.advanceTimersByTimeAsync(300); expect(screenshot).toHaveBeenCalledTimes(1); expect(loop.captureOverdue()).toBe(false); - - // Past twice the usual round trip (floored at 400ms): the stream is all the - // pane has to show the page loading. await vi.advanceTimersByTimeAsync(500); expect(loop.captureOverdue()).toBe(true); - // The page keeps changing for the rest of the load; a second capture would - // only queue behind the first. + // The page keeps changing through the rest of the load. for (let i = 0; i < 20; i++) { loop.pulse(); await vi.advanceTimersByTimeAsync(1000); } expect(screenshot).toHaveBeenCalledTimes(1); - // `open` returns: the capture lands, and the owed follow-up starts at once. + // Those changes are in the capture `open` releases: drawn, nothing owed. releases[0]({ ok: true, bytes: new Uint8Array([1]), mime: 'image/jpeg' }); - await vi.advanceTimersByTimeAsync(0); - expect(screenshot).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(10); expect(loop.captureOverdue()).toBe(false); + expect(draw).toHaveBeenCalledTimes(1); + expect(screenshot).toHaveBeenCalledTimes(1); - // The 20s wait timed the page load, not a capture, so a second slow load is - // overdue just as soon. + // The wait timed the page load, not a capture, so the next slow load is + // overdue just as soon — and a change just before its reply is owed a shot. + loop.pulse(); await vi.advanceTimersByTimeAsync(500); + expect(screenshot).toHaveBeenCalledTimes(2); expect(loop.captureOverdue()).toBe(true); + loop.pulse(); releases[1]({ ok: true, bytes: new Uint8Array([2]), mime: 'image/jpeg' }); await vi.advanceTimersByTimeAsync(10); - expect(draw).toHaveBeenCalledTimes(1); + expect(screenshot).toHaveBeenCalledTimes(3); loop.dispose(); }); diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 0a9cee4c3..5ac08d6fa 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -38,15 +38,13 @@ export interface ScreenshotLoopDeps { export interface ScreenshotLoop { /** A "page changed" signal — schedule a fresh shot (coalesced + throttled). */ pulse(): void; - /** Whether the capture in flight has been outstanding past twice the usual - * round trip (and at least 400ms): queued behind a blocking daemon command, - * such as an `open` waiting on a page load. Until it answers, only the - * stream shows what the page is doing. */ + /** Whether the capture in flight is overdue (see `createScreenshotLoop`). */ captureOverdue(): boolean; dispose(): void; } const OVERDUE_FLOOR_MS = 400; +// A capture this slow is flagged `stalled` in its done/error log. const STALL_WARNING_MS = 8000; /** @@ -68,10 +66,13 @@ const STALL_WARNING_MS = 8000; * deferred shot or one superseded mid-flight — it stays `dirty`, because the canvas * is left on a CSS-resolution frame and only a later crisp shot can sharpen it. * - * A shot that stays in flight is never re-issued: it is queued behind a blocking - * daemon command, and a second one would only queue behind it too. The panel - * paints the stream meanwhile (`captureOverdue`), and every host adapter bounds - * the wait with its own timeout. + * A shot out past twice the usual round trip (at least 400ms) is overdue: queued + * behind a blocking daemon command, such as an `open` waiting on its page load, + * while the panel paints the stream instead. It is never re-issued — a second + * one would only queue behind it, and every host adapter bounds the wait. It is + * taken when the command lets it go, so the pulses of its wait leave nothing + * owed, and its round trip, which timed the command, is clamped before it + * enters the pacing average. */ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { let inFlight = false; @@ -80,7 +81,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { let lastStart = 0; let avgMs = 120; let timer: ReturnType<typeof setTimeout> | undefined; - let stallWarning: ReturnType<typeof setTimeout> | undefined; + let lastPulseAt = 0; let disposed = false; // The `bytes:generation` of the frame currently on the canvas. Skips decoding a // capture we've already displayed onto this same draw target. @@ -129,24 +130,15 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { const provisionalAtStart = deps.getProvisionalGeneration?.() ?? 0; lastStart = performance.now(); deps.log?.(`[agent-browser] screenshot start ${JSON.stringify({ session, seq: mySeq })}`); - // Diagnostic only: the slot stays held until the host answers (see above). - stallWarning = setTimeout(() => { - stallWarning = undefined; - console.warn(`[agent-browser] screenshot capture stalled (>${STALL_WARNING_MS / 1000}s) ${JSON.stringify({ session, seq: mySeq, dirty })}`); - }, STALL_WARNING_MS); - const settle = () => { - clearTimeout(stallWarning); - stallWarning = undefined; - inFlight = false; - }; + const stalled = () => performance.now() - lastStart > STALL_WARNING_MS; platform.agentBrowserScreenshot(session, { format: 'jpeg', quality: 85 }, deps.getBinaryPath()).then((res) => { - const elapsedMs = performance.now() - lastStart; - deps.log?.(`[agent-browser] screenshot done ${JSON.stringify({ session, seq: mySeq, ok: res.ok, bytes: res.bytes?.byteLength ?? 0, elapsedMs: Math.round(elapsedMs), dirty })}`); - // A capture held behind a blocking command timed that command, not a - // capture: clamp the sample so one slow page load stretches neither the - // pacing nor the overdue threshold of the shots after it. + const now = performance.now(); + const elapsedMs = now - lastStart; + deps.log?.(`[agent-browser] screenshot done ${JSON.stringify({ session, seq: mySeq, ok: res.ok, bytes: res.bytes?.byteLength ?? 0, elapsedMs: Math.round(elapsedMs), stalled: stalled(), dirty })}`); + // Overdue (see above): only a pulse after its release is owed a shot. + if (elapsedMs > overdueAfterMs() && lastPulseAt <= now - avgMs) dirty = false; avgMs = avgMs * 0.6 + Math.min(elapsedMs, overdueAfterMs()) * 0.4; - settle(); + inFlight = false; // A provisional stream frame painted during this capture is visibly newer. // Do not let the stale crisp result overwrite it. Mere `dirty` pulses do not // suppress drawing — an idle animated page still needs periodic crisp frames @@ -167,8 +159,8 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { } if (dirty) schedule(); }).catch((err) => { - console.warn(`[agent-browser] screenshot error ${JSON.stringify({ session, seq: mySeq })}:`, err); - settle(); + console.warn(`[agent-browser] screenshot error ${JSON.stringify({ session, seq: mySeq, stalled: stalled() })}:`, err); + inFlight = false; if (dirty) schedule(); }); }; @@ -212,6 +204,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { return { pulse: () => { if (disposed || !deps.isCapable()) return; + lastPulseAt = performance.now(); dirty = true; schedule(); }, @@ -219,7 +212,6 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { dispose: () => { disposed = true; if (timer !== undefined) clearTimeout(timer); - clearTimeout(stallWarning); }, }; } 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 2212c36f3..cd0d3e1d8 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -156,33 +156,39 @@ describe('view attachment', () => { }); describe('provisional stream paint', () => { - it('draws the native stream frame before the crisp screenshot resolves', async () => { - let now = 1000; - vi.spyOn(performance, 'now').mockImplementation(() => now); - const screenshot = vi.fn(() => new Promise<never>(() => {})); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; + type Shot = { ok: true; bytes: Uint8Array; mime: string }; + /** An attached pane on `sess:4321` with the clock, the host capture and the + * 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'>> = {}, + ) { + 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 bitmap = { width: 40, height: 30, close: vi.fn() } as unknown as ImageBitmap; vi.stubGlobal('createImageBitmap', vi.fn(async () => bitmap)); const sink = makeSink(); const drawImage = vi.fn(); sink.canvas.getContext = vi.fn(() => ({ drawImage })) as unknown as typeof sink.canvas.getContext; - const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); controller.attachView(sink); await flushMicrotasks(); + const frame = async (label: string) => { + streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa(label), metadata: { deviceWidth: 40, deviceHeight: 30 } })); + await flushMicrotasks(); + }; + const decodes = () => vi.mocked(createImageBitmap).mock.calls.length; + return { clock, platform, sink, bitmap, drawImage, controller, frame, decodes }; + } - controller.send({ type: 'input_mouse', eventType: 'mouseMoved', x: 1, y: 1 }); - streamSocket(4321)?.emitMessage(JSON.stringify({ - type: 'frame', - data: btoa('low-latency-frame'), - metadata: { deviceWidth: 40, deviceHeight: 30 }, - })); - await flushMicrotasks(); + it('draws the native stream frame before the crisp screenshot resolves', async () => { + const { clock, platform, sink, bitmap, drawImage, controller, frame, decodes } = await paintFixture(); - expect(screenshot).toHaveBeenCalled(); + controller.send({ type: 'input_mouse', eventType: 'mouseMoved', x: 1, y: 1 }); + await frame('low-latency-frame'); + expect(platform.agentBrowserScreenshot).toHaveBeenCalled(); expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 0); expect(sink.canvas.width).toBe(40); expect(sink.canvas.height).toBe(30); @@ -190,158 +196,106 @@ describe('provisional stream paint', () => { // Once pointer activity is old, an animated page must not keep decoding its // CSS-resolution stream at frame rate; the throttled crisp path remains. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; - streamSocket(4321)?.emitMessage(JSON.stringify({ - type: 'frame', - data: btoa('idle-animation-frame'), - metadata: { deviceWidth: 40, deviceHeight: 30 }, - })); - await flushMicrotasks(); - expect(createImageBitmap).toHaveBeenCalledTimes(1); + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; + await frame('idle-animation-frame'); + expect(decodes()).toBe(1); }); it('paints the stream frame after keys, pasted text and editing chords, not only after pointer input', async () => { - let now = 1000; - vi.spyOn(performance, 'now').mockImplementation(() => now); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot' | 'agentBrowserEdit' | 'readClipboardText'>; - platform.agentBrowserScreenshot = vi.fn(() => new Promise<never>(() => {})); - platform.agentBrowserEdit = vi.fn(async () => ({ ok: true })); - platform.readClipboardText = vi.fn(async () => 'pasted'); - setPlatform(platform); - vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 40, height: 30, close: vi.fn() }))); - const sink = makeSink(); - sink.canvas.getContext = vi.fn(() => ({ drawImage: vi.fn() })) as unknown as typeof sink.canvas.getContext; - - const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); - controller.attachView(sink); - await flushMicrotasks(); - const frame = async (label: string) => { - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa(label) })); - await flushMicrotasks(); - }; + const { clock, platform, controller, frame, decodes } = await paintFixture(undefined, { + agentBrowserEdit: vi.fn(async () => ({ ok: true })), + readClipboardText: vi.fn(async () => 'pasted'), + }); await frame('first'); - expect(controller.snapshot().hasFrame).toBe(true); - expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(decodes()).toBe(1); // At rest, a changed frame only pulses the crisp loop. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; await frame('idle'); - expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(decodes()).toBe(1); - // A keystroke's echo paints straight from the stream. controller.handleKeyDownLike({ key: 'a', code: 'KeyA', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }); await frame('typed'); - expect(createImageBitmap).toHaveBeenCalledTimes(2); + expect(decodes()).toBe(2); - // So does a paste, replayed as key input once the clipboard read resolves. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; + // A paste is replayed as key input once the clipboard read resolves. + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; controller.handleKeyDownLike({ key: 'v', code: 'KeyV', ctrlKey: true, metaKey: false, altKey: false, shiftKey: false }); await flushMicrotasks(); expect(platform.readClipboardText).toHaveBeenCalled(); await frame('pasted'); - expect(createImageBitmap).toHaveBeenCalledTimes(3); + expect(decodes()).toBe(3); - // And a select-all, which runs through the host rather than the stream. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; + // 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); await frame('selected'); - expect(createImageBitmap).toHaveBeenCalledTimes(4); + expect(decodes()).toBe(4); }); - it('paints the stream while a crisp capture waits behind a blocking command', async () => { - let now = 1000; - vi.spyOn(performance, 'now').mockImplementation(() => now); - // Every capture queues behind a page-loading `open` and never answers here. - const screenshot = vi.fn(() => new Promise<never>(() => {})); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); - vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 40, height: 30, close: vi.fn() }))); - const sink = makeSink(); - sink.canvas.getContext = vi.fn(() => ({ drawImage: vi.fn() })) as unknown as typeof sink.canvas.getContext; - - const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); - controller.attachView(sink); - await flushMicrotasks(); - const frame = async (label: string) => { - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa(label) })); - await flushMicrotasks(); - }; + 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); })); + // 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'); - expect(screenshot).toHaveBeenCalledTimes(1); - expect(createImageBitmap).toHaveBeenCalledTimes(1); + clock.now += 300; + releases[0]({ ok: true, bytes: new Uint8Array([1]), mime: 'image/jpeg' }); + await flushMicrotasks(); + expect(platform.agentBrowserScreenshot).toHaveBeenCalledTimes(2); + const decoded = decodes(); - // Soon after the capture started, a changed frame only pulses the loop. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; await frame('loading'); - expect(createImageBitmap).toHaveBeenCalledTimes(1); - - // Once the capture is overdue, the loading page paints from the stream. - now += 400; + expect(decodes()).toBe(decoded); + // Overdue now: the loading page paints from the stream. + clock.now += 400; await frame('still loading'); - expect(createImageBitmap).toHaveBeenCalledTimes(2); - expect(screenshot).toHaveBeenCalledTimes(1); - }); - - it('repaints a byte-identical crisp capture over a provisional paint', async () => { - // A provisional paint puts blurry pixels on the canvas without going through - // the screenshot loop, so the loop's byte-dedup no longer describes what is on - // screen. On a resting page the next crisp capture is byte-identical to the - // last crisp draw — it must still repaint, or the pane stays blurry until the - // page happens to change. - let now = 1000; - vi.spyOn(performance, 'now').mockImplementation(() => now); - // A static page: every capture returns the same pixels. - const screenshot = vi.fn(async () => ({ ok: true as const, bytes: new Uint8Array([9, 9, 9]), mime: 'image/jpeg' })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserScreenshot'>; - platform.agentBrowserScreenshot = screenshot; - setPlatform(platform); + expect(decodes()).toBe(decoded + 1); - vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ width: 40, height: 30, close: vi.fn() }))); - const sink = makeSink(); - const drawImage = vi.fn(); - sink.canvas.getContext = vi.fn(() => ({ drawImage })) as unknown as typeof sink.canvas.getContext; - - const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 4321 }); - controller.attachView(sink); + // `open` returns: the held capture is the newer image — drawn, not re-taken. + const drawn = drawImage.mock.calls.length; + clock.now += 1000; + releases[1]({ ok: true, bytes: new Uint8Array([2]), mime: 'image/jpeg' }); await flushMicrotasks(); - - // The first stream frame paints provisionally (nothing on the canvas yet), so - // hasFrame flips and the provisional window can be closed from here on. - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa('first') })); await flushMicrotasks(); + expect(platform.agentBrowserScreenshot).toHaveBeenCalledTimes(2); + expect(drawImage.mock.calls.length).toBe(drawn + 1); + }); + + 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( + async () => ({ ok: true, bytes: new Uint8Array([9, 9, 9]), mime: 'image/jpeg' }), + ); + await frame('first'); expect(controller.snapshot().hasFrame).toBe(true); - // Past the input window a frame is a bare pulse — no provisional paint — so - // this capture lands as the crisp resting frame the loop records. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa('rest') })); - await flushMicrotasks(); + // Past the input window a frame is a bare pulse, so this capture lands as + // the crisp resting frame the loop records. + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; + await frame('rest'); const afterCrisp = drawImage.mock.calls.length; - expect(screenshot).toHaveBeenCalled(); + expect(platform.agentBrowserScreenshot).toHaveBeenCalled(); - // Pointer input reopens the window; this frame paints blurry over the crisp one. controller.send({ type: 'input_mouse', eventType: 'mouseMoved', x: 1, y: 1 }); - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa('hover') })); - await flushMicrotasks(); + await frame('hover'); const afterProvisional = drawImage.mock.calls.length; expect(afterProvisional).toBeGreaterThan(afterCrisp); - // Back at rest: the capture's bytes match the earlier crisp draw exactly, and - // it must still repaint over the blur. - now += PROVISIONAL_INPUT_WINDOW_MS + 1; - streamSocket(4321)?.emitMessage(JSON.stringify({ type: 'frame', data: btoa('settled') })); - await flushMicrotasks(); + clock.now += PROVISIONAL_INPUT_WINDOW_MS + 1; + await frame('settled'); expect(drawImage.mock.calls.length).toBeGreaterThan(afterProvisional); }); }); -describe('sync-to-pane on window resize', () => { +describe('sync-to-pane', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); - it('leaves a size change to the debounced pane observer and re-syncs a DPR change at once', async () => { + 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; @@ -353,6 +307,16 @@ describe('sync-to-pane on window resize', () => { observe() {} disconnect() {} }); + // A display-scale change fires the `(resolution)` query armed for the old scale. + const queries: Array<{ media: string; onChange?: () => void }> = []; + vi.stubGlobal('matchMedia', (media: string) => { + const query: { media: string; onChange?: () => void } = { media }; + queries.push(query); + return { + addEventListener: (_type: string, listener: () => void) => { query.onChange = listener; }, + removeEventListener: () => { query.onChange = undefined; }, + }; + }); let dpr = 1; vi.spyOn(window, 'devicePixelRatio', 'get').mockImplementation(() => dpr); const sink = makeSink(); @@ -361,7 +325,6 @@ describe('sync-to-pane on window resize', () => { const resizePane = (width: number, height: number) => { size = { width, height }; observers.at(-1)?.([{ contentRect: { width, height } } as ResizeObserverEntry], {} as ResizeObserver); - window.dispatchEvent(new Event('resize')); }; const viewports = () => command.mock.calls .map((call) => (call as unknown as [string, string[]])[1]) @@ -374,7 +337,7 @@ describe('sync-to-pane on window resize', () => { expect(viewports().at(-1)).toEqual(['set', 'viewport', '800', '600', '1']); const issued = viewports().length; - // A window drag: `resize` every frame, one settled viewport after the debounce. + // A window drag resizes the pane every frame; one viewport lands after it settles. for (let w = 801; w <= 860; w++) { resizePane(w, 600); await vi.advanceTimersByTimeAsync(16); @@ -383,10 +346,12 @@ describe('sync-to-pane on window resize', () => { await vi.advanceTimersByTimeAsync(200); expect(viewports().slice(issued)).toEqual([['set', 'viewport', '860', '600', '1']]); - // A display-scale change resizes nothing, so only the window signal sees it. dpr = 2; - window.dispatchEvent(new Event('resize')); + queries.at(-1)!.onChange!(); expect(viewports().at(-1)).toEqual(['set', 'viewport', '860', '600', '2']); + // Re-armed for the new scale. + expect(queries.at(-1)!.media).toBe('(resolution: 2dppx)'); + expect(queries.at(-1)!.onChange).toBeDefined(); }); }); diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index abca7a573..1a9d07f5c 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -79,6 +79,10 @@ function dimsMatch(a: { w: number; h: number }, b: { w: number; h: number }): bo return Math.abs(a.w - b.w) <= DIM_TOLERANCE && Math.abs(a.h - b.h) <= DIM_TOLERANCE; } +function dprMatch(a: number, b: number): boolean { + return Math.abs(a - b) <= 0.001; +} + // A pop-out/pop-in relaunch restores a single URL. A transient about:blank — a // stray tab the close+reopen can momentarily surface, or a freshly-relaunched // blank page — must never be treated as the page to restore, or the real URL is @@ -284,6 +288,7 @@ export class AgentBrowserSurfaceController { // observer on the pane, not two. private paneSize: { w: number; h: number } | null = null; private paneSizeObserver: ResizeObserver | null = null; + private dprQuery: MediaQueryList | null = null; // --- canonical URL tracking --- // The newest non-blank active-tab URL observed from the live stream. Kept @@ -323,7 +328,11 @@ export class AgentBrowserSurfaceController { // frame (or a provisional frame from a later pointer move). private frameDrawSeq = 0; private provisionalUntil = 0; + // Counts the provisional paints that supersede a crisp capture in flight — + // not those made only while one is overdue, which it is newer than + // (`createScreenshotLoop`). private provisionalPaintGeneration = 0; + private paintingForOverdue = false; // Param writes buffered while detached (a minimized popped-out pane can still // observe URL changes); flushed on the next attach. private pendingParams = new Map<string, unknown>(); @@ -463,9 +472,7 @@ export class AgentBrowserSurfaceController { // This surface owns its session again — clear any teardown mark a prior // surface (re-using the same managed name) left behind, so auto-revert works. if (this.session) clearAgentBrowserSessionClosed(this.sessionKey(this.session)); - // Display-scale (DPR) changes don't resize the pane, so ResizeObserver misses - // them; a window resize is the available signal. - window.addEventListener('resize', this.onWindowResize); + this.watchDpr(); this.registration = registerAgentBrowserScreen(this.id, { snapshot: this.computeScreenSnapshot(), actions: this.screenActions, @@ -480,17 +487,20 @@ export class AgentBrowserSurfaceController { this.maybeRecoverStalePort(); } - private onWindowResize = (): void => { - // A display-scale (DPR) change doesn't resize the pane, so ResizeObserver - // misses it; refresh the cache off the window-resize signal too. - this.refreshPaneSize(); - // Only a DPR change is this listener's to sync. A size change also reaches - // the pane's debounced ResizeObserver, and a window drag fires `resize` - // every frame — one `set viewport` spawn each, per synced pane. - const issued = this.lastIssued; - if (this.syncEngaged && issued && Math.abs(issued.dpr - (window.devicePixelRatio || 1)) > 0.001) { - this.issueSyncToPane(); - } + // A display-scale (DPR) change resizes nothing, so the pane's ResizeObserver + // misses it. A `(resolution)` query fires once when the scale leaves its + // value, and is re-armed for the new one. + private watchDpr(): void { + this.dprQuery?.removeEventListener('change', this.onDprChange); + this.dprQuery = typeof window.matchMedia === 'function' + ? window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`) + : null; + this.dprQuery?.addEventListener('change', this.onDprChange); + } + + private onDprChange = (): void => { + this.watchDpr(); + if (this.syncEngaged) this.issueSyncToPane(); this.publishScreen(); }; @@ -513,6 +523,7 @@ export class AgentBrowserSurfaceController { const observer = new ResizeObserver((entries) => { const cr = entries[entries.length - 1]?.contentRect; if (cr) this.paneSize = { w: Math.round(cr.width), h: Math.round(cr.height) }; + this.publishScreen(); // While syncing, push the new pane size to the browser (debounced). The // inner re-check drops a resize whose sync was disengaged mid-debounce. if (!this.syncEngaged) return; @@ -826,7 +837,7 @@ export class AgentBrowserSurfaceController { // The body rides along only when we asked for it (via `wantFrameData`), so // its presence is the request — re-testing `wantsProvisionalFrame` here would // only race its own `provisionalUntil` deadline and drop a frame we wanted. - if (event.data) this.drawProvisionalFrame(event.data); + if (event.data) this.drawProvisionalFrame(event.data, this.paintingForOverdue); this.maybeDisengageSync(); this.publishScreen(); if (!this.poppedOut && !this.relaunching) screenshotLoop.pulse(); @@ -862,7 +873,7 @@ export class AgentBrowserSurfaceController { this.paintBitmap(bitmap); }; - private drawProvisionalFrame(data: string): void { + private drawProvisionalFrame(data: string, forOverdueCapture: boolean): void { const sink = this.sink; if (!sink || typeof createImageBitmap !== 'function') return; let bytes: Uint8Array<ArrayBuffer>; @@ -882,7 +893,7 @@ export class AgentBrowserSurfaceController { bitmap.close(); return; } - this.provisionalPaintGeneration += 1; + if (!forOverdueCapture) this.provisionalPaintGeneration += 1; // This paint puts CSS-resolution pixels on the canvas behind the crisp // loop's back, so its byte-dedup (`lastDrawnKey`) no longer describes what // is on screen: a resting page whose crisp bytes match the last crisp draw @@ -899,10 +910,9 @@ export class AgentBrowserSurfaceController { } private wantsProvisionalFrame(): boolean { - return !this.hasFrame || !this.platform.agentBrowserScreenshot || performance.now() <= this.provisionalUntil - // A crisp capture queued behind a blocking `open` would otherwise leave - // the previous page on screen for the whole load. - || !!this.screenshotLoop?.captureOverdue(); + const forInput = !this.hasFrame || !this.platform.agentBrowserScreenshot || performance.now() <= this.provisionalUntil; + this.paintingForOverdue = !forInput; + return forInput || !!this.screenshotLoop?.captureOverdue(); } // agent-browser's stream publishes the initial headed tab list but not every @@ -1269,7 +1279,7 @@ export class AgentBrowserSurfaceController { // --- screen indicator (SYNCED/SCALED) + sync-to-pane --- private computeScreenSnapshot(): ScreenSnapshot { - // Read the cached pane size (updated by the ResizeObserver / window resize) + // Read the cached pane size (updated by the ResizeObserver) // rather than forcing layout on every frame. null ⇒ no attached view ⇒ 0×0. const pane = this.paneSize; const displayDpr = window.devicePixelRatio || 1; @@ -1325,7 +1335,7 @@ export class AgentBrowserSurfaceController { if (!w || !h) return; const dpr = window.devicePixelRatio || 1; const prev = this.lastIssued; - if (prev && prev.w === w && prev.h === h && Math.abs(prev.dpr - dpr) <= 0.001) return; + if (prev && prev.w === w && prev.h === h && dprMatch(prev.dpr, dpr)) return; this.lastIssued = { w, h, dpr }; this.syncConfirmed = false; this.runAgentBrowser(['set', 'viewport', String(w), String(h), String(dpr)]); @@ -1593,7 +1603,8 @@ export class AgentBrowserSurfaceController { this.cdpTeardown?.(); this.cdpTeardown = null; this.cdpKey = null; - if (this.started) window.removeEventListener('resize', this.onWindowResize); + this.dprQuery?.removeEventListener('change', this.onDprChange); + this.dprQuery = null; this.registration?.dispose(); this.registration = null; this.sink = null; diff --git a/lib/src/components/wall/browser-url.ts b/lib/src/components/wall/browser-url.ts index 45fffe765..226825b8c 100644 --- a/lib/src/components/wall/browser-url.ts +++ b/lib/src/components/wall/browser-url.ts @@ -8,6 +8,9 @@ * components so they can be unit-tested directly. */ import type { AgentBrowserTab } from '../../lib/agent-browser-tab'; +import { isLoopbackHostname } from '../../lib/ip-literal'; +import { getPlatformOrNull } from '../../lib/platform'; +import { IFRAME_HTTP_ONLY } from '../../lib/platform/iframe-proxy-types'; /** Host + path of a URL (e.g. `localhost:5173/app`) — the browser header's * primary text, and the iframe surface's title. Pass `includeSearch` to keep @@ -87,14 +90,16 @@ export function browserSurfaceUrl(raw: string): string | null { } } -/** Whether `url` parses as an https:// URL — the scheme a host with the iframe - * proxy cannot embed (docs/specs/dor-browser.md → "Iframe Renderer"). */ -export function isHttpsUrl(url: string): boolean { +/** Why this host cannot show `url` in an iframe pane, or null when it can: a + * host with the iframe proxy frames http:// only (docs/specs/dor-browser.md → + * "Iframe Renderer"). The raw fallback of a proxy-less host frames https too. */ +export function iframeRefusal(url: string): string | null { try { - return new URL(url).protocol === 'https:'; + if (new URL(url).protocol !== 'https:') return null; } catch { - return false; + return null; } + return getPlatformOrNull()?.createIframeProxyUrl ? IFRAME_HTTP_ONLY : null; } /** The host part of a schemeless authority, minus any `:port`. An IPv6 literal @@ -107,19 +112,6 @@ function authorityHostname(authority: string): string { return close === -1 ? authority : authority.slice(0, close + 1); } -/** True for hostnames that resolve to the local machine. `*.localhost` is - * included because browsers route it to loopback per the RFC. */ -function isLoopbackHostname(hostname: string): boolean { - const host = hostname.toLowerCase(); - return ( - host === 'localhost' || - host === '127.0.0.1' || - host === '::1' || - host === '[::1]' || - host.endsWith('.localhost') - ); -} - /** The TCP port of a loopback URL, or null if the URL is not loopback / has no * resolvable port. Defaults the port from the scheme (http→80, https→443) so a * bare `http://localhost` still correlates. */ diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index d374bb333..8e70352c2 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -43,7 +43,7 @@ import { type ToolTakeoverGate, } from './tool-takeover'; import { attachSurfacePorts } from './surface-ports'; -import { browserSurfaceUrl, hostPathDisplay, isHttpsUrl } from './browser-url'; +import { browserSurfaceUrl, hostPathDisplay, iframeRefusal } from './browser-url'; import { automationCli, automationMode, automationProvider, browserPlatform, type LaunchBinaryPath } from './browser-automation'; import { BrowserBindingReservations } from './browser-binding-reservations'; import { @@ -697,10 +697,9 @@ export function useDorControl({ const rendering = automationProvider(target.renderMode); if (rendering !== provider) { // Name the command that does work on it, so the caller's next try lands. - const url = rendering ? undefined : browserUrlFromParams(lath.getMeta(target.id)?.params); const remedy = rendering ? `drive it with ${automationCli(rendering)} --surface ${target.ref}` - : `an iframe cannot be driven; open its page with ${automationCli(provider)} open ${url ?? '<url>'}`; + : `an iframe cannot be driven; open its page with dor ab open ${browserUrlFromParams(lath.getMeta(target.id)?.params) ?? '<url>'}`; detail.respond({ ok: false, error: `surface '${target.ref}' is not ${provider} rendered (render_mode: ${target.renderMode}) — ${remedy}`, @@ -1580,11 +1579,10 @@ export function useDorControl({ detail.respond({ ok: false, error: 'url must be an http:// or https:// URL' }); return; } - // A host with the iframe proxy frames http:// only, so an https:// pane - // would open straight onto its refusal (docs/specs/dor-browser.md → - // "Iframe Renderer"). Say so here, where the caller can act on it. - if (getPlatform().createIframeProxyUrl && isHttpsUrl(url)) { - detail.respond({ ok: false, error: `iframe panes show http:// pages only; open ${url} with \`dor ab open ${url}\`` }); + // Refused here, where the caller can act on it, not in the pane. + const refusal = iframeRefusal(url); + if (refusal) { + detail.respond({ ok: false, error: `${refusal} — open it with dor ab open ${url}` }); return; } const target = resolveVisibleSurface(stringParam(params.surface), detail.surfaceId); diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index e608e3cb0..70b88675f 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -69,7 +69,7 @@ export interface WallActions { * renderer is single-frame, so a page's new-tab request (target=_blank / * window.open, surfaced by the proxy shim) becomes a new pane * (docs/specs/dor-browser.md → "Iframe Shim"). */ - onOpenBrowserPane?: (id: string, url: string, renderMode?: 'iframe' | 'ab-screencast') => void; + onOpenBrowserPane?: (id: string, url: string) => void; /** The stable `surface:N` ref for a pane/door id (minted lazily, exactly as * `dor list` assigns refs). Used by the pane context menu to show the handle. */ resolveSurfaceRef: (id: string) => string; diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index a052d5756..c8ec8fb96 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -444,9 +444,7 @@ describe('agent-browser host screenshot transport', () => { await host.closePoppedOut(); }); - // A capture queued behind a page-loading `open` outlives the webview's reply - // timeout, and the webview asks again. A second spawn would only queue behind - // the first, then race it for the session's one capture file. + // `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 = ''; diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index a8452685a..0a9033325 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -402,11 +402,10 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return path.join(await screenshotDir.get(), `shot-${name}.${ext}`); } - // One capture per session and format at a time, whoever asks. A `screenshot` - // queued behind a page-loading `open` blocks for up to 25s, and each webview - // adapter gives up on its reply sooner (VS Code at 10s) and asks again; a - // second spawn would only queue behind the first, then race it for the - // session's one capture file. A caller that asks mid-capture joins it. + // One capture per session and format at a time, whoever asks — 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, so a caller asking mid-capture joins it. const capturesInFlight = new Map<string, Promise<unknown>>(); function oneCapture<T>(key: string, capture: () => Promise<T>): Promise<T> { const pending = capturesInFlight.get(key) as Promise<T> | undefined; diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 023720d64..3ea6dbe4e 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -243,7 +243,7 @@ describe('errorPageHtml', () => { }); it('asks about a dev server only when the upstream is this machine', () => { - for (const local of ['http://localhost:5173/', 'http://127.0.0.2:8000/', 'http://app.localhost:3000/', 'http://[::1]:8080/']) { + for (const local of ['http://localhost:5173/', 'http://127.0.0.2:8000/', 'http://app.localhost:3000/', 'http://[::1]:8080/', 'http://[::ffff:127.0.0.1]:8080/']) { expect(unreachablePage(new URL(local), 'ECONNREFUSED').message, local).toContain('dev server'); expect(timedOutPage(new URL(local)).message, local).toContain('dev server'); } diff --git a/lib/src/host/iframe-proxy-rewrite.ts b/lib/src/host/iframe-proxy-rewrite.ts index f1b2de70d..81796b35c 100644 --- a/lib/src/host/iframe-proxy-rewrite.ts +++ b/lib/src/host/iframe-proxy-rewrite.ts @@ -9,6 +9,8 @@ * runtime-agnostic. */ +import { ipv4Value, isLoopbackHostname } from '../lib/ip-literal'; + /** Header bag shape both `http.IncomingHttpHeaders` and a plain map satisfy. */ export type ProxyHeaders = Record<string, string | string[] | undefined>; @@ -193,14 +195,21 @@ export function iframeShim(embedderOrigin: string): string { // `embedderOrigin` is the document that frames us, and it is required: without // one there is nobody to address the shim's messages to, so the caller must not // instrument at all rather than fall back to `'*'`. +const HEAD_CLOSE = /<\/head>/i; +const BODY_OPEN = /<body[^>]*>/i; + +/** The end of the document prefix `instrumentHtml` places the shim before + * (`</head>`) or after (`<body…>`). The proxy buffers until it sees one. */ +export const HEAD_MARKER = new RegExp(`${HEAD_CLOSE.source}|${BODY_OPEN.source}`, 'i'); + export function instrumentHtml(body: string, embedderOrigin: string, preserveCsp = false): string { const html = preserveCsp ? body : body.replace( /<meta[^>]+http-equiv=["']?content-security-policy["']?[^>]*>/gi, '', ); const shimTag = `<script>${iframeShim(embedderOrigin)}</script>`; - if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${shimTag}</head>`); - if (/<body[^>]*>/i.test(html)) return html.replace(/(<body[^>]*>)/i, `$1${shimTag}`); + if (HEAD_CLOSE.test(html)) return html.replace(HEAD_CLOSE, (tag) => shimTag + tag); + if (BODY_OPEN.test(html)) return html.replace(BODY_OPEN, (tag) => tag + shimTag); // Both tags are optional in valid HTML. Never ahead of the doctype, which // would switch the page to quirks mode, nor of a `<meta charset>`, which // counts only within the first 1024 bytes — the same window searched here. @@ -213,69 +222,14 @@ export function instrumentHtml(body: string, embedderOrigin: string, preserveCsp return html.slice(0, at) + shimTag + html.slice(at); } -/** The end of the document prefix `instrumentHtml` places the shim before - * (`</head>`) or after (`<body…>`). The proxy buffers until it sees one. */ -export const HEAD_MARKER = /<\/head>|<body[^>]*>/i; - // 169.254.0.0/16 — IPv4 link-local, incl. the 169.254.169.254 cloud-metadata // endpoint — as a numeric range so every equivalent encoding is caught. const LINK_LOCAL_V4_START = 0xa9fe0000; // 169.254.0.0 const LINK_LOCAL_V4_END = 0xa9feffff; // 169.254.255.255 -// Parse one dotted-quad component with inet_aton semantics: hex (0x…), octal -// (leading 0), or decimal. Returns null for anything else. -function parseIPv4Part(part: string): number | null { - if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part.slice(2), 16); - if (/^0[0-7]+$/.test(part)) return parseInt(part, 8); - if (/^(0|[1-9][0-9]*)$/.test(part)) return parseInt(part, 10); - return null; -} - -// Parse a hostname as an IPv4 literal the way the OS resolver (getaddrinfo / -// inet_aton) would — including short forms and non-decimal encodings — so that -// 2852039166, 0xA9FEA9FE, 0251.0376.0251.0376 and 169.254.169.254 all collapse -// to the same 32-bit value. Returns null when the string isn't a numeric IPv4. -function parseIPv4(host: string): number | null { - const parts = host.split('.'); - if (parts.length === 0 || parts.length > 4) return null; - const nums: number[] = []; - for (const part of parts) { - const n = parseIPv4Part(part); - if (n === null) return null; - nums.push(n); - } - // Every part but the last is a single byte; the last fills the remainder. - for (let i = 0; i < nums.length - 1; i++) { - if (nums[i] > 0xff) return null; - } - const last = nums[nums.length - 1]; - if (last > Math.pow(256, 5 - nums.length) - 1) return null; - let value = last; - for (let i = 0; i < nums.length - 1; i++) { - value += nums[i] * Math.pow(256, 3 - i); - } - return value >>> 0 === value ? value : null; -} - -// Extract the 32-bit IPv4 address embedded in an IPv4-mapped or IPv4-compatible -// IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, ::169.254.169.254), -// or null if this isn't such an address. -function embeddedIPv4(h: string): number | null { - const m = h.match(/^::(?:ffff:)?(.+)$/); - if (!m) return null; - const tail = m[1]; - if (tail.includes('.')) return parseIPv4(tail.slice(tail.lastIndexOf(':') + 1)); - const groups = tail.split(':'); - if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) { - return ((parseInt(groups[0], 16) << 16) >>> 0) + parseInt(groups[1], 16); - } - return null; -} - export function isBlockedAddress(hostname: string): boolean { - const h = hostname.replace(/^\[|\]$/g, '').toLowerCase(); // IPv6 link-local (fe80::/10). - if (/^fe[89ab][0-9a-f]:/.test(h)) return true; + if (/^\[?fe[89ab][0-9a-f]:/i.test(hostname)) return true; // Resolve the host to its 32-bit IPv4 value across every equivalent encoding // (decimal/octal/hex, short forms, IPv4-mapped IPv6) and range-check the // link-local / cloud-metadata block. The genuine end-to-end hole a literal @@ -284,7 +238,7 @@ export function isBlockedAddress(hostname: string): boolean { // can't see; the numeric-IPv4 spellings are collapsed by that same parser // before the guard runs, so canonicalizing them here is defense-in-depth // against callers that don't pre-normalize rather than a live bypass fix. - const v4 = h.includes(':') ? embeddedIPv4(h) : parseIPv4(h); + const v4 = ipv4Value(hostname); return v4 !== null && v4 >= LINK_LOCAL_V4_START && v4 <= LINK_LOCAL_V4_END; } @@ -295,19 +249,10 @@ export interface ErrorPage { message: string; } -/** Whether the upstream is this machine — the one case where "the dev server" - * is a fair guess at what the user pointed the pane at. */ -function isLoopbackUpstream(upstream: URL): boolean { - const host = upstream.hostname.toLowerCase(); - if (host === 'localhost' || host.endsWith('.localhost') || host === '[::1]') return true; - const v4 = parseIPv4(host); - return v4 !== null && v4 >>> 24 === 127; -} - export function unreachablePage(upstream: URL, detail: string): ErrorPage { return { title: `Nothing responding at ${upstream.host}`, - message: `Dormouse couldn’t reach ${upstream.href} (${detail}). ${isLoopbackUpstream(upstream) + message: `Dormouse couldn’t reach ${upstream.href} (${detail}). ${isLoopbackHostname(upstream.hostname) ? 'Is the dev server running?' : 'Check that the server is up and reachable from this machine.'}`, }; @@ -316,7 +261,7 @@ export function unreachablePage(upstream: URL, detail: string): ErrorPage { export function timedOutPage(upstream: URL): ErrorPage { return { title: `${upstream.host} isn’t responding`, - message: `Dormouse connected to ${upstream.host} but it didn’t respond in time — ${isLoopbackUpstream(upstream) + message: `Dormouse connected to ${upstream.host} but it didn’t respond in time — ${isLoopbackHostname(upstream.hostname) ? 'the dev server may be busy (e.g. optimizing dependencies)' : 'the server may be busy or slow'}. Try reloading.`, }; diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 878e92740..8a1ae0156 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -53,7 +53,7 @@ */ import * as http from 'http'; import * as net from 'net'; -import type { IframeProxyResult } from '../lib/platform/iframe-proxy-types'; +import { IFRAME_HTTP_ONLY, type IframeProxyResult } from '../lib/platform/iframe-proxy-types'; import { isForeignOrigin, isLoopbackHost, isOwnOrigin } from './loopback-guard'; import { FRAMING_RESPONSE_HEADERS, @@ -145,7 +145,7 @@ export async function createIframeProxyUrl( // plain http, and rewriting authenticated https pages is the agent-browser's // job (spec → Target policy). if (upstream.protocol !== 'http:') { - return { ok: false, reason: 'scheme', detail: 'the embedded view frames http:// pages only' }; + return { ok: false, reason: 'scheme', detail: IFRAME_HTTP_ONLY }; } // SSRF guard: the proxy fetches a user-supplied URL, so refuse the link-local // / cloud-metadata ranges (169.254.169.254 and friends). Other private ranges diff --git a/lib/src/lib/ip-literal.ts b/lib/src/lib/ip-literal.ts new file mode 100644 index 000000000..c306c3692 --- /dev/null +++ b/lib/src/lib/ip-literal.ts @@ -0,0 +1,73 @@ +/** + * Hostname classification shared by the webview and the Node host modules + * (the iframe proxy's SSRF guard and error pages, the browser URL helpers). + * Pure string parsing: nothing here resolves a name. + */ + +// Parse one dotted-quad component with inet_aton semantics: hex (0x…), octal +// (leading 0), or decimal. Returns null for anything else. +function parseIPv4Part(part: string): number | null { + if (/^0x[0-9a-f]+$/.test(part)) return parseInt(part.slice(2), 16); + if (/^0[0-7]+$/.test(part)) return parseInt(part, 8); + if (/^(0|[1-9][0-9]*)$/.test(part)) return parseInt(part, 10); + return null; +} + +// Parse a hostname as an IPv4 literal the way the OS resolver (getaddrinfo / +// inet_aton) would — including short forms and non-decimal encodings — so that +// 2852039166, 0xA9FEA9FE, 0251.0376.0251.0376 and 169.254.169.254 all collapse +// to the same 32-bit value. Returns null when the string isn't a numeric IPv4. +function parseIPv4(host: string): number | null { + const parts = host.split('.'); + if (parts.length === 0 || parts.length > 4) return null; + const nums: number[] = []; + for (const part of parts) { + const n = parseIPv4Part(part); + if (n === null) return null; + nums.push(n); + } + // Every part but the last is a single byte; the last fills the remainder. + for (let i = 0; i < nums.length - 1; i++) { + if (nums[i] > 0xff) return null; + } + const last = nums[nums.length - 1]; + if (last > Math.pow(256, 5 - nums.length) - 1) return null; + let value = last; + for (let i = 0; i < nums.length - 1; i++) { + value += nums[i] * Math.pow(256, 3 - i); + } + return value >>> 0 === value ? value : null; +} + +// Extract the 32-bit IPv4 address embedded in an IPv4-mapped or IPv4-compatible +// IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, ::169.254.169.254), +// or null if this isn't such an address. +function embeddedIPv4(h: string): number | null { + const m = h.match(/^::(?:ffff:)?(.+)$/); + if (!m) return null; + const tail = m[1]; + if (tail.includes('.')) return parseIPv4(tail.slice(tail.lastIndexOf(':') + 1)); + const groups = tail.split(':'); + if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) { + return ((parseInt(groups[0], 16) << 16) >>> 0) + parseInt(groups[1], 16); + } + return null; +} + +/** The 32-bit IPv4 address a hostname spells — in any inet_aton encoding, or + * embedded in an IPv4-mapped/compatible IPv6 literal, bracketed or not — or + * null. The WHATWG URL parser rewrites `::ffff:127.0.0.1` to a hex-group + * spelling, so a literal match would miss it. */ +export function ipv4Value(hostname: string): number | null { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return h.includes(':') ? embeddedIPv4(h) : parseIPv4(h); +} + +/** Whether a hostname names this machine: `localhost`, `*.localhost` (browsers + * route it to loopback), `::1`, or 127.0.0.0/8 in any spelling. */ +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true; + const v4 = ipv4Value(h); + return v4 !== null && v4 >>> 24 === 127; +} diff --git a/lib/src/lib/platform/iframe-proxy-types.ts b/lib/src/lib/platform/iframe-proxy-types.ts index dcf5cddf8..9b92817a9 100644 --- a/lib/src/lib/platform/iframe-proxy-types.ts +++ b/lib/src/lib/platform/iframe-proxy-types.ts @@ -16,6 +16,11 @@ * can import it without dragging the browser-typed `platform/types` graph into * a Node tsconfig (and vice-versa). */ +/** Why a host with the iframe proxy refuses an `https://` page — the one + * wording the proxy, the panel, the Display modal and `surface.iframe` share. + * Every refusal names `dor ab open <url>` as the remedy. */ +export const IFRAME_HTTP_ONLY = 'the embedded view frames http:// pages only'; + export type IframeProxyResult = | { ok: true; url: string } | { ok: false; reason: 'unreachable' | 'scheme'; detail?: string }; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 2fd6db7d0..82fe34c42 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -455,6 +455,34 @@ 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', () => { + beforeEach(stubWebviewEnv); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // 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. + it('waits out a daemon command held behind a page load', async () => { + vi.useFakeTimers(); + const adapter = new VSCodeAdapter(); + for (const request of [ + () => adapter.agentBrowserScreenshot('sess', { format: 'jpeg' }), + () => adapter.agentBrowserCommand('sess', ['reload']), + () => adapter.agentBrowserEdit('sess', 'copy'), + ]) { + let settled: unknown; + void request().then((result) => { settled = result; }); + await vi.advanceTimersByTimeAsync(26_000); + expect(settled).toBeUndefined(); + await vi.advanceTimersByTimeAsync(4_000); + expect(JSON.stringify(settled)).toMatch(/timed out/); + } + }); +}); + describe('VSCodeAdapter notepad archive', () => { beforeEach(stubWebviewEnv); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 4dd6ad10e..6499ea634 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -35,6 +35,11 @@ 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 @@ -385,7 +390,7 @@ export class VSCodeAdapter implements PlatformAdapter { const result = await this.requestResponse<AgentBrowserCommandResult>( 'agentBrowser:command', 'agentBrowser:commandResult', { session, args, binaryPath }, (msg) => ({ exitCode: msg.exitCode, stdout: msg.stdout, stderr: msg.stderr }), - 10000, + AGENT_BROWSER_REPLY_TIMEOUT_MS, ); return result ?? { exitCode: 1, stdout: '', stderr: 'agent-browser command timed out' }; } @@ -394,7 +399,7 @@ export class VSCodeAdapter implements PlatformAdapter { const result = await this.requestResponse<AgentBrowserEditResult>( 'agentBrowser:edit', 'agentBrowser:editResult', { session, op, binaryPath }, (msg) => ({ ok: msg.ok, text: msg.text, error: msg.error }), - 10000, + AGENT_BROWSER_REPLY_TIMEOUT_MS, ); return result ?? { ok: false, error: 'agent-browser edit timed out' }; } @@ -404,7 +409,7 @@ export class VSCodeAdapter implements PlatformAdapter { '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 }), - 10000, + AGENT_BROWSER_REPLY_TIMEOUT_MS, ); return result ?? { ok: false, error: 'agent-browser screenshot timed out' }; } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d0e9353f2..54b6965bc 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": 5850, + "docs/specs/dor-browser.md": 5900, "docs/specs/dor-cli.md": 6300, "docs/specs/dor-tool.md": 4150, "docs/specs/glossary.md": 2950, From 24eb1003742430b389f1dae4e7f4767e33be5aca Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 20:23:23 -0700 Subject: [PATCH 14/18] Address round-1 review: URL-bar refusal, iframe entry table, capture edges - B1: the URL editor refuses a non-http(s) address with a warning under the field (a new `AnchoredWarning`, which the rename warning now wraps) instead of closing silently while the host drops it. - B2: the https refusal table in dor-browser.md now states what each entry actually shows; one hoisted check refuses a swap to `iframe` for tools and plain panes alike. Terminal-context port rows are always http:// by construction (`listenerUrlsByPort`), so they are not an entry. - T1: the controller remembers and relaunches at http(s) URLs only (`isBrowsableUrl`, now in platform/browser-automation.ts for both realms), so a `file:`/`data:` tab leaves the last http(s) page as the one restored; the header still shows such a page. - T2: `oneCapture` joins no capture from before a close or relaunch, nor one out past 30s, and a replacement capture gets a fresh file. - T3: an overdue capture clears the loop's owed shot only when it is drawn, so a failed or timed-out one still sharpens the pane. - T4: the uninstrumented-load check waits for the frame's first shim report, so a proxied image, PDF or JSON document framed from the start is not flagged; the banner no longer asserts a cause. - T5: proxied responses carry `Vary: Sec-Fetch-Dest`, since the encoding asked of the upstream depends on it. - T6: the spec names the `(resolution)` media query, not the window resize. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 42 +++++++----- docs/specs/dor-browser.rationale.md | 2 + lib/src/components/Wall.tsx | 19 ++++-- lib/src/components/wall/AnchoredWarning.tsx | 65 +++++++++++++++++++ lib/src/components/wall/IframePanel.test.tsx | 21 +++++- lib/src/components/wall/IframePanel.tsx | 11 ++-- .../components/wall/IllegalRenameWarning.tsx | 58 +++-------------- .../wall/SurfacePaneHeader.test.tsx | 31 +++++++++ lib/src/components/wall/SurfacePaneHeader.tsx | 21 +++++- .../agent-browser-screenshot-loop.test.ts | 22 +++++++ .../wall/agent-browser-screenshot-loop.ts | 6 +- .../agent-browser-surface-controller.test.ts | 17 +++++ .../wall/agent-browser-surface-controller.ts | 29 +++++---- lib/src/host/agent-browser-host.test.ts | 41 ++++++++++++ lib/src/host/agent-browser-host.ts | 46 +++++++++---- lib/src/host/browser-host-shared.ts | 12 +--- lib/src/host/iframe-proxy.test.ts | 13 ++++ lib/src/host/iframe-proxy.ts | 11 ++++ lib/src/host/playwright-host.ts | 2 +- lib/src/lib/platform/browser-automation.ts | 13 ++++ 20 files changed, 364 insertions(+), 118 deletions(-) create mode 100644 lib/src/components/wall/AnchoredWarning.tsx diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index ad8b3840d..335800aaa 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -39,8 +39,9 @@ Invariants on the flat persisted `BrowserPanelParams`: live agent-browser. **Only params may omit it** — a live `ScreenSnapshot` always carries one, so nothing defaults it a second time. - **`url` is the canonical target** across render swaps and relaunches. - Agent-browser mirrors the newest non-blank active tab URL into it; iframe - persists only navigations initiated by Dormouse chrome. + 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 automation state flat** (`session`, `wsPort`, `binaryPath`, `syncEngaged`, `key`, plus Playwright `cwd`/`nativeIdentity`), never nested. Pop-out is not a param — it derives from `renderMode` once, at controller construction. @@ -116,7 +117,8 @@ Header contract: persisted title keeps the query. - **Must open a pre-selected `InlineEditInput` from the URL.** Blur discards; `normalizeNavUrl` follows CLI scheme selection plus bare loopback → `http://` - and bare remote → `https://` (rationale). + and bare remote → `https://` (rationale). **Must refuse any other scheme with + a warning under the field**, since no renderer opens one. - **Must keep back/forward/reload enabled.** Agent-browser uses native commands; iframe uses parent history and re-resolves its proxy. - **Must show non-default managed `--key` as a badge, never a title prefix.** @@ -176,8 +178,8 @@ The iframe option lists that the embed keeps no logins or cookies (for Resolution controls apply to both screencast providers, as GUI wrappers around native commands: **Resize with pane** is Dormouse-owned sync issuing `set viewport <paneW> <paneH> <displayDpr>` once a pane resize settles (200ms); -**only a DPR change re-syncs at once**, off the window `resize` event, which -fires every frame of a drag. **Fixed** issues +**only a DPR change re-syncs at once**, off a `(resolution: <dpr>dppx)` media +query `change` event. **Fixed** issues `set viewport <w> <h> <dpr>` or `set device <name>` from the modal's registry. **Only `syncEngaged` persists** — device/custom viewport state lives in @@ -359,8 +361,8 @@ shared by the stream and `tab list --json`). `ab-popout` relaunches the same session headed, because Chrome fixes headed/headless at daemon launch. The pane becomes a stub with Pop back in; while the window is still opening (a relaunch in flight, or an eager pane -without its session) the stub offers nothing. **State carried in v1 is only the active -non-blank URL**: other tabs, DOM state, scroll, form inputs, session storage, +without its session) 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 @@ -395,7 +397,7 @@ sidecar/Rust adapter. | Method | 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 in flight**: a request made meanwhile joins it. | +| `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 in flight**: a request made meanwhile joins it — never one out past 30s, nor one from before the session's close or relaunch. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `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. | @@ -482,15 +484,18 @@ The proxy instruments any `http://` upstream, loopback and remote alike: pinned by `lib/src/host/iframe-proxy-rewrite.test.ts`. **Must refuse `https://` at every entry to the iframe renderer on a host with -the proxy, in the one `IFRAME_HTTP_ONLY` wording, naming `dor ab open <url>`** -(a host without it frames https raw): +the proxy, in the one `IFRAME_HTTP_ONLY` wording** (a host without it frames +https raw): | Entry | Outcome | | --- | --- | -| `surface.iframe` (`dor iframe`) | refused before any pane opens | -| Display modal / render swap to `iframe` | option disabled with the reason; the Wall's swap refuses too | +| `surface.iframe` (`dor iframe`) | refused before any pane opens, naming `dor ab open <url>` | +| 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 | | New-tab request from a framed page | an `ab-screencast` pane, bound to its launch like a render swap, closed if the launch fails | -| A pane already holding one | synchronous `scheme` panel error | +| A pane already holding one | `scheme` panel error with Open in agent-browser and `dor ab open <url>` | + +The terminal context's port rows are always `http://` (`listenerUrlsByPort`). Header rewriting: @@ -506,6 +511,7 @@ Header rewriting: | response | `X-Dormouse-Preserve-CSP: 1` | consumed; preserves upstream CSP headers and meta policies | | response | hop-by-hop (RFC 7230 §6.1) | dropped | | response | `Location` | upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy | +| response | `Vary` | `Sec-Fetch-Dest` appended, since the `Accept-Encoding` sent upstream depends on it | | response body | `<meta http-equiv="content-security-policy">` | removed unless the response opts into CSP preservation | **Must update this table whenever header rewriting changes.** @@ -556,10 +562,12 @@ for chrome/history without reloading the frame. New-tab requests show an overlay: accept opens an adjacent browser pane (for `https://`, see [Iframe Renderer](#iframe-renderer)); cancel drops it. -**A proxied frame `load` with no `location` report within 1s marks the document -uninstrumented** (off the proxy, refused, or its grant gone), and a banner -offers Reload and Open in agent-browser. Only a report naming the proxy origin -counts, including one up to 250ms before the load. +**Once a proxied frame's shim has reported, a `load` with no `location` report +within 1s marks the document uninstrumented** (not HTML, off the proxy, refused, +or its grant gone), and a banner offers Reload and Open in agent-browser. Only a +report naming the proxy origin counts, including one up to 250ms before the +load; a new frame source waits for its first report again, since a non-HTML +document served from the start carries no shim (rationale). Source of truth: `lib/src/host/iframe-proxy-rewrite.ts` (`iframeShim`), `lib/src/components/wall/browser-url.ts` (`browserSurfaceUrl`), diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 3a6156ab2..bebde83ba 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -104,6 +104,8 @@ The built-in local-file viewer supplies its own content boundary and permits the ## Iframe Shim +**Why the uninstrumented check waits for a first report.** The proxy instruments `text/html` only, and the parent cannot read a cross-origin frame's content type. A frame judged from its first load flagged every working non-HTML page — `dor iframe …/health.json`, and every image or PDF the file-viewer Tool frames directly. Waiting for one report means the frame has shown it carries the shim, so a later silent load is a real change; a link from an instrumented page to a PDF still flags, which the banner's wording ("not HTML, …") admits. + **Why the CLI's own check is not enough.** `open-window` carries a string the framed page chose, and the new-tab prompt in front of it is user consent, not a boundary — the user is agreeing to open a pane, not vetting a scheme. The same check gates `surface.iframe`, a wire protocol on the control socket rather than the CLI, so nothing upstream of it has already filtered. **Why the panel checks again.** Every writer of `params.url` ends at the panel, and on a host with no proxy the raw fallback hands that string straight to `<iframe src>` under a sandbox that keeps `allow-same-origin`. Enumerating the writers is the fragile half: the header's URL editor was one the guarded callers did not cover, because `normalizeNavUrl` deliberately keeps a typed `javascript:` or `data:` scheme so the address bar can carry one. React blanks a `javascript:` `src` prop and nothing else, so `data:text/html,…` framed verbatim (reproduced in `IframePanel.test.tsx`, 2026-09) — a framework mitigation the code never claimed, for one scheme out of the set. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 1ff066ecd..6b55fe238 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2017,6 +2017,18 @@ export function Wall({ const params = nav.paneParams(id); const currentRenderMode = surfaceRenderModeFromParams(params); + // No pane, tool or not, swaps onto an iframe that would refuse its page; + // the Display modal never offers it (docs/specs/dor-browser.md → "Iframe + // Renderer"). + if (mode === 'iframe') { + const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; + const refused = url ? iframeRefusal(url) : null; + if (refused) { + console.warn(`[dormouse] cannot swap surface '${id}' to iframe: ${refused}`); + return; + } + } + // Tools keep their Session and current URL through renderer swaps, and // take only their declarable renders: anything else would be written // as `toolRender` and launch nothing (docs/specs/dor-tool.md). @@ -2024,7 +2036,7 @@ export function Wall({ if (mode === currentRenderMode || !isToolRender(mode)) return; const url = browserUrlFromParams(params); const platform = getPlatform(); - if (!url || (mode === 'ab-screencast' && !platform.agentBrowserOpen) || (mode === 'iframe' && iframeRefusal(url))) return; + if (!url || (mode === 'ab-screencast' && !platform.agentBrowserOpen)) return; closeAgentBrowserSession(params); disposeAgentBrowserSurfaceController(id); lath.store.updateParams(id, { @@ -2056,9 +2068,8 @@ export function Wall({ // Canonical params.url (mirrored from the chrome snapshot) first; fall // back to the live snapshot for a surface that hasn't reported a tab yet. const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; - const refused = url ? iframeRefusal(url) : 'no URL observed yet'; - if (!url || refused) { - console.warn(`[dormouse] cannot swap surface '${id}' to iframe: ${refused}`); + if (!url) { + console.warn(`[dormouse] cannot swap surface '${id}' to iframe: no URL observed yet`); return; } replaceSurface(id, { diff --git a/lib/src/components/wall/AnchoredWarning.tsx b/lib/src/components/wall/AnchoredWarning.tsx new file mode 100644 index 000000000..999786cd9 --- /dev/null +++ b/lib/src/components/wall/AnchoredWarning.tsx @@ -0,0 +1,65 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { POPUP_SURFACE_CLASS } from '../design'; +import { cfg } from '../../cfg'; +import { useDismissOverlay } from './use-dismiss-overlay'; + +const POPOVER_GAP = 6; +const POPOVER_MARGIN = 8; + +export interface AnchoredWarningProps { + anchorRect: DOMRect; + title: string; + message: string; + onClose: () => void; + [key: `data-${string}`]: string; +} + +/** A header field's refusal, anchored under the field it came from; dismissed + * like every pane-header popover (`useDismissOverlay`) or after a timeout. */ +export function AnchoredWarning({ anchorRect, title, message, onClose, ...dataAttrs }: AnchoredWarningProps) { + const ref = useRef<HTMLDivElement>(null); + const [style, setStyle] = useState<CSSProperties>({ + position: 'fixed', + left: anchorRect.left, + top: anchorRect.bottom + POPOVER_GAP, + }); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const top = anchorRect.bottom + POPOVER_GAP; + const maxLeft = Math.max(POPOVER_MARGIN, window.innerWidth - rect.width - POPOVER_MARGIN); + setStyle({ + position: 'fixed', + left: Math.min(Math.max(anchorRect.left, POPOVER_MARGIN), maxLeft), + top, + }); + }, [anchorRect]); + + useDismissOverlay(onClose, ref); + + useEffect(() => { + const autoDismissMs = cfg.overlays.warningAutoDismissMs; + if (autoDismissMs <= 0) return; + const timeout = window.setTimeout(onClose, autoDismissMs); + return () => window.clearTimeout(timeout); + }, [onClose]); + + return createPortal( + <div + {...dataAttrs} + ref={ref} + role="alert" + aria-label={`${title}: ${message}`} + className={`${POPUP_SURFACE_CLASS} max-w-72 px-2.5 py-1.5 text-xs leading-snug`} + style={style} + onPointerDown={(e) => e.stopPropagation()} + > + <div className="font-medium" style={{ color: 'var(--color-error)' }}>{title}</div> + <div className="mt-0.5 text-muted">{message}</div> + </div>, + document.body, + ); +} diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index 4b101b394..a473eab22 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -316,7 +316,7 @@ describe('iframe failures offer a way out', () => { await act(async () => { vi.advanceTimersByTime(2000); }); await act(async () => { iframe.dispatchEvent(new Event('load')); }); await act(async () => { vi.advanceTimersByTime(1100); }); - expect(banner()?.textContent).toContain('isn’t running through Dormouse'); + expect(banner()?.textContent).toContain('Dormouse can’t follow this page'); await act(async () => { button('Open in agent-browser')!.click(); }); expect(onSwapRenderMode).toHaveBeenCalledWith('iframe-uninstrumented', 'ab-screencast'); @@ -327,6 +327,8 @@ describe('iframe failures offer a way out', () => { expect(banner()).toBeNull(); // Flagged again, then a later report from the shim clears it. + await report(); + await act(async () => { vi.advanceTimersByTime(2000); }); await act(async () => { iframe.dispatchEvent(new Event('load')); }); await act(async () => { vi.advanceTimersByTime(1100); }); expect(banner()).not.toBeNull(); @@ -337,11 +339,28 @@ describe('iframe failures offer a way out', () => { } }); + // The proxy instruments HTML only: an image, PDF or JSON document framed from + // the start carries no shim and is working, not lost. + it('judges nothing until the frame\'s shim has reported once', async () => { + vi.useFakeTimers(); + try { + proxyPlatform(); + const iframe = await renderPanel(stubActions(), paneProps('iframe-non-html')); + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('does not count a clicked link that leaves the proxy as the shim reporting', async () => { vi.useFakeTimers(); try { proxyPlatform(); const iframe = await renderPanel(stubActions(), paneProps('iframe-offproxy-link')); + await report(); + await act(async () => { vi.advanceTimersByTime(2000); }); // The shim posts a clicked link's href just before the frame navigates. await act(async () => { window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'location', url: 'https://elsewhere.example/' } })); diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index 8b979fb4a..5cda6da93 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -40,7 +40,9 @@ const IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups const IFRAME_ALLOW = 'autoplay; clipboard-write; fullscreen'; // Uninstrumented documents (docs/specs/dor-browser.md → "Iframe Shim"). The // lead admits the shim's pageshow report, which races the frame's load event -// to the parent. +// to the parent. The proxy instruments HTML only, so a frame is judged only +// once its shim has reported: a proxied image, PDF or JSON document served +// from the start is working, not lost. const SHIM_REPORT_TIMEOUT_MS = 1000; const SHIM_REPORT_LEAD_MS = 250; @@ -296,13 +298,14 @@ export function IframePanel({ id, title, params }: PaneProps) { return registerProxyOrigin(proxyOrigin); }, [proxyOrigin]); - // A new frame source starts over: no verdict until its document loads. + // A new frame source starts over: no verdict until its shim reports. useEffect(() => { setUninstrumented(false); + lastShimReportRef.current = Number.NEGATIVE_INFINITY; return () => clearTimeout(shimCheckRef.current); }, [resolution]); const onFrameLoad = useCallback(() => { - if (!proxyOrigin) return; + if (!proxyOrigin || lastShimReportRef.current === Number.NEGATIVE_INFINITY) return; const loadedAt = performance.now(); clearTimeout(shimCheckRef.current); shimCheckRef.current = setTimeout(() => { @@ -428,7 +431,7 @@ export function IframePanel({ id, title, params }: PaneProps) { onMouseDown={(e) => e.stopPropagation()} > <span className="min-w-0 flex-1 px-1.5 py-0.5 text-muted"> - This page isn’t running through Dormouse — it left the proxy, was blocked, or the proxy expired. + Dormouse can’t follow this page — it isn’t an HTML page on the proxy, so the URL bar and leader chord stop at it. </span> <button type="button" className={popupButton()} onClick={() => chromeActions.reload()}>Reload</button> {openInAgentBrowser && ( diff --git a/lib/src/components/wall/IllegalRenameWarning.tsx b/lib/src/components/wall/IllegalRenameWarning.tsx index 1667704c6..dafc2a2b9 100644 --- a/lib/src/components/wall/IllegalRenameWarning.tsx +++ b/lib/src/components/wall/IllegalRenameWarning.tsx @@ -1,15 +1,8 @@ -import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; -import { createPortal } from 'react-dom'; -import { POPUP_SURFACE_CLASS } from '../design'; -import { cfg } from '../../cfg'; import type { SetTerminalUserTitleResult } from '../../lib/terminal-registry'; -import { useDismissOverlay } from './use-dismiss-overlay'; +import { AnchoredWarning } from './AnchoredWarning'; export type RenameRejection = Extract<SetTerminalUserTitleResult, { accepted: false }>['reason']; -const POPOVER_GAP = 6; -const POPOVER_MARGIN = 8; - export interface IllegalRenameWarningProps { anchorRect: DOMRect; reason: RenameRejection; @@ -18,49 +11,14 @@ export interface IllegalRenameWarningProps { } export function IllegalRenameWarning({ anchorRect, reason, attemptedValue, onClose }: IllegalRenameWarningProps) { - const ref = useRef<HTMLDivElement>(null); - const [style, setStyle] = useState<CSSProperties>({ - position: 'fixed', - left: anchorRect.left, - top: anchorRect.bottom + POPOVER_GAP, - }); - - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const top = anchorRect.bottom + POPOVER_GAP; - const maxLeft = Math.max(POPOVER_MARGIN, window.innerWidth - rect.width - POPOVER_MARGIN); - setStyle({ - position: 'fixed', - left: Math.min(Math.max(anchorRect.left, POPOVER_MARGIN), maxLeft), - top, - }); - }, [anchorRect]); - - useDismissOverlay(onClose, ref); - - useEffect(() => { - const autoDismissMs = cfg.overlays.warningAutoDismissMs; - if (autoDismissMs <= 0) return; - const timeout = window.setTimeout(onClose, autoDismissMs); - return () => window.clearTimeout(timeout); - }, [onClose]); - - return createPortal( - <div - ref={ref} - role="alert" + return ( + <AnchoredWarning data-testid="illegal-rename-warning" - aria-label={`Illegal name: ${describeReason(reason, attemptedValue)}`} - className={`${POPUP_SURFACE_CLASS} max-w-72 px-2.5 py-1.5 text-xs leading-snug`} - style={style} - onPointerDown={(e) => e.stopPropagation()} - > - <div className="font-medium" style={{ color: 'var(--color-error)' }}>Illegal name</div> - <div className="mt-0.5 text-muted">{describeReason(reason, attemptedValue)}</div> - </div>, - document.body, + anchorRect={anchorRect} + title="Illegal name" + message={describeReason(reason, attemptedValue)} + onClose={onClose} + /> ); } diff --git a/lib/src/components/wall/SurfacePaneHeader.test.tsx b/lib/src/components/wall/SurfacePaneHeader.test.tsx index 9ac4ef36c..c3dab415a 100644 --- a/lib/src/components/wall/SurfacePaneHeader.test.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.test.tsx @@ -566,6 +566,37 @@ describe('SurfacePaneHeader — browser chrome', () => { registration.dispose(); }); + it('refuses a non-http(s) address visibly instead of navigating to it', () => { + const navigate = vi.fn(); + const registration = registerAgentBrowserScreen('pane-url-refuse', { + snapshot: SCREEN, + actions: { engageSync: vi.fn(), applyDevice: vi.fn(), applyViewport: vi.fn(), openModal: vi.fn() }, + chrome: CHROME, + chromeActions: { navigate, back: vi.fn(), forward: vi.fn(), reload: vi.fn() }, + hostCapable: true, + }); + renderHeader(headerProps('pane-url-refuse', 'x'), stubActions()); + + for (const typed of ['file:///tmp/report.html', 'about:blank']) { + act(() => { + (container.querySelector('span[title="Vite + React"]') as HTMLElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const input = container.querySelector<HTMLInputElement>('[data-url-input-for="pane-url-refuse"]')!; + act(() => { + setNativeFieldValue(input, typed); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + const warning = document.querySelector('[data-url-refusal-for="pane-url-refuse"]'); + expect(warning?.textContent).toContain(typed); + expect(warning?.textContent).toContain('http:// and https:// pages only'); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + } + expect(navigate).not.toHaveBeenCalled(); + + registration.dispose(); + }); + it('cancels URL editing on Escape without navigating', () => { const navigate = vi.fn(); const registration = registerAgentBrowserScreen('pane-url-esc', { diff --git a/lib/src/components/wall/SurfacePaneHeader.tsx b/lib/src/components/wall/SurfacePaneHeader.tsx index 0dc1a4d95..258a1fc9d 100644 --- a/lib/src/components/wall/SurfacePaneHeader.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.tsx @@ -29,7 +29,8 @@ import { import { BROWSER_DISPLAY_LABEL, BrowserDisplayIcon } from './BrowserDisplayIcon'; import { InlineEditInput } from './InlineEditInput'; import type { PaneProps } from './pane-props'; -import { loopbackPort, normalizeNavUrl, pathDisplay } from './browser-url'; +import { browserSurfaceUrl, loopbackPort, pathDisplay } from './browser-url'; +import { AnchoredWarning } from './AnchoredWarning'; import { triggerDevServerRescan, useDevServerMatch } from './agent-browser-ports'; import { ModeContext, @@ -93,9 +94,14 @@ export function SurfacePaneHeader({ id, title, params, parked }: PaneProps) { if (!screen && editingUrl) setEditingUrl(false); }, [screen, editingUrl]); - const submitUrl = (value: string) => { - const url = normalizeNavUrl(value); + // Every renderer shows http(s) only — the iframe sink and both provider + // hosts refuse anything else — so another scheme is refused here, visibly. + const [urlRefusal, setUrlRefusal] = useState<{ rect: DOMRect; value: string } | null>(null); + const closeUrlRefusal = useCallback(() => setUrlRefusal(null), []); + const submitUrl = (value: string, el: HTMLInputElement) => { + const url = browserSurfaceUrl(value); if (url) screen?.chromeActions.navigate(url); + else if (value.trim()) setUrlRefusal({ rect: el.getBoundingClientRect(), value: value.trim() }); setEditingUrl(false); }; const closeUrlEditor = () => setEditingUrl(false); @@ -297,6 +303,15 @@ export function SurfacePaneHeader({ id, title, params, parked }: PaneProps) { {renderBrowserControls('popover')} {!inlineMinimizeKill && <MinimizeKillButtons surfaceId={id} beforeAct={closeMenu} {...minimizeKillFocus} />} </BrowserHeaderPopover>} + {urlRefusal && ( + <AnchoredWarning + data-url-refusal-for={id} + anchorRect={urlRefusal.rect} + title="Can’t open this address" + message={`Browser panes open http:// and https:// pages only, not “${urlRefusal.value}”.`} + onClose={closeUrlRefusal} + /> + )} </div> ); } 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 73c32f2a9..49a3463b0 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts @@ -285,4 +285,26 @@ describe('screenshot loop behind a blocking command', () => { loop.dispose(); }); + + 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']); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const loop = createScreenshotLoop({ getSession: () => 'sess', getBinaryPath: () => undefined, isCapable: () => true, draw: vi.fn() }); + + loop.pulse(); + await vi.advanceTimersByTimeAsync(300); + for (let i = 0; i < 5; i++) { + loop.pulse(); + await vi.advanceTimersByTimeAsync(1000); + } + // An adapter gives up on the reply (VS Code answers its timeout `{ ok: false }`): + // nothing was drawn, so the pane is still on the stream's frames. + releases[0]({ ok: false, error: 'agent-browser screenshot timed out' }); + await vi.advanceTimersByTimeAsync(10); + expect(screenshot).toHaveBeenCalledTimes(2); + + loop.dispose(); + }); }); diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 5ac08d6fa..162c7cd3a 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -135,8 +135,9 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { const now = performance.now(); const elapsedMs = now - lastStart; deps.log?.(`[agent-browser] screenshot done ${JSON.stringify({ session, seq: mySeq, ok: res.ok, bytes: res.bytes?.byteLength ?? 0, elapsedMs: Math.round(elapsedMs), stalled: stalled(), dirty })}`); - // Overdue (see above): only a pulse after its release is owed a shot. - if (elapsedMs > overdueAfterMs() && lastPulseAt <= now - avgMs) dirty = false; + // Overdue (see above): once it is drawn, only a pulse after its release + // is owed a shot. + const pulsesInImage = elapsedMs > overdueAfterMs() && lastPulseAt <= now - avgMs; avgMs = avgMs * 0.6 + Math.min(elapsedMs, overdueAfterMs()) * 0.4; inFlight = false; // A provisional stream frame painted during this capture is visibly newer. @@ -152,6 +153,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { // work pending so the resting frame still sharpens. dirty = true; } else { + if (pulsesInImage) dirty = false; display(res.bytes, res.mime || 'image/jpeg', mySeq, provisionalAtStart); } } else { 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 cd0d3e1d8..a59b0020e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -750,6 +750,23 @@ describe('relaunch (pop-out / pop-in)', () => { expect(platform.agentBrowserPopOut).toHaveBeenCalledWith('sess', expect.objectContaining({ url: 'https://slow.example/' }), undefined); }); + it('relaunches at the last page the host can reopen, not a file: or data: tab', async () => { + const platform = relaunchPlatform(); + const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 1111, url: 'https://before.example/' }); + const sink = makeSink(); + controller.attachView(sink); + await flushMicrotasks(); + const socket = streamSocket(1111); + socket?.emitMessage(JSON.stringify({ type: 'url', url: 'https://app.example/report' })); + socket?.emitMessage(JSON.stringify({ type: 'url', url: 'file:///tmp/report.html' })); + // The header shows the page; the restorable URL stays the last http(s) one. + expect(getAgentBrowserScreenController('id')?.chrome().url).toBe('file:///tmp/report.html'); + 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); + }); + it('clears a stale title when navigation commits at the same URL', async () => { const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 1111, url: 'https://same.example/', diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 1a9d07f5c..40b82c614 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -5,7 +5,7 @@ * only; Wall owns daemon teardown. */ import type { PlatformAdapter } from '../../lib/platform/types'; -import { playwrightTextInputs, type BrowserAutomationProvider } from '../../lib/platform/browser-automation'; +import { isBrowsableUrl, playwrightTextInputs, type BrowserAutomationProvider } from '../../lib/platform/browser-automation'; import { isAllowedBinaryFor } from '../../lib/agent-browser-binary'; import { readTextFromClipboard } from '../../lib/clipboard'; import { isAbDebugLogsEnabled } from '../../lib/feature-flags'; @@ -83,11 +83,10 @@ function dprMatch(a: number, b: number): boolean { return Math.abs(a - b) <= 0.001; } -// A pop-out/pop-in relaunch restores a single URL. A transient about:blank — a -// stray tab the close+reopen can momentarily surface, or a freshly-relaunched -// blank page — must never be treated as the page to restore, or the real URL is -// lost on the way back in. Mirrors the host's usableRelaunchUrl. -function isRestorableUrl(url: string | null | undefined): url is string { + +// 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'; @@ -291,7 +290,9 @@ export class AgentBrowserSurfaceController { private dprQuery: MediaQueryList | null = null; // --- canonical URL tracking --- - // The newest non-blank active-tab URL observed from the live stream. Kept + // The newest active-tab URL observed from the live stream that a relaunch + // can restore — http(s) only, as the host relaunches nowhere else, so a + // transient about:blank or a `file:`/`data:` page leaves the last one. Kept // separate from paramsUrl: engine param writes can lag a tab message, but // pop-in/auto-revert must carry the page the user just navigated to. private latestRestorableUrl: string | undefined; @@ -362,7 +363,7 @@ export class AgentBrowserSurfaceController { this.paramsUrl = params.url; this.paramsKey = params.key ?? null; this.paramsSyncEngaged = params.syncEngaged; - this.latestRestorableUrl = isRestorableUrl(params.url) ? params.url : undefined; + this.latestRestorableUrl = isBrowsableUrl(params.url) ? params.url : undefined; // poppedOut is derived from the canonical renderMode; an unset mode (a direct // mount in tests) is not popped out. this.poppedOut = isPopout(params.renderMode); @@ -656,7 +657,7 @@ export class AgentBrowserSurfaceController { } if (params.url !== this.paramsUrl) { this.paramsUrl = params.url; - if (isRestorableUrl(params.url)) this.latestRestorableUrl = params.url; + if (isBrowsableUrl(params.url)) this.latestRestorableUrl = params.url; } if ((params.key ?? null) !== this.paramsKey) { this.paramsKey = params.key ?? null; @@ -1182,12 +1183,12 @@ export class AgentBrowserSurfaceController { // --- canonical URL tracking --- private rememberRestorableUrl(url: string | null | undefined): boolean { - if (!isRestorableUrl(url)) return false; + if (!isBrowsableUrl(url)) return false; this.latestRestorableUrl = url; // Track the active tab faithfully so params.url is always the page the user // is on. Two guards: freeze while a relaunch is in flight (the active tab is // momentarily a blank/booting page that must not overwrite the real target), - // and never record a transient about:blank (isRestorableUrl above). + // and never record a URL a relaunch cannot restore (latestRestorableUrl). if (!this.relaunching && url !== this.paramsUrl) { this.paramsUrl = url; this.writeParams({ url }); @@ -1201,7 +1202,7 @@ export class AgentBrowserSurfaceController { } private applyObservedNavigation(url: string | null | undefined, title?: string | null): void { - if (!isRestorableUrl(url)) return; + if (!isShownUrl(url)) return; this.rememberRestorableUrl(url); const prev = this.tabs; if (prev.length === 0) { @@ -1220,7 +1221,7 @@ export class AgentBrowserSurfaceController { // the previous page's title no longer describes it, so the tab falls back to // its URL until the load completes and `tabs` brings the real title. private applyStreamUrl(url: string): void { - if (!isRestorableUrl(url)) return; + if (!isShownUrl(url)) return; this.rememberRestorableUrl(url); const active = this.activeTab(); if (!active) { @@ -1239,7 +1240,7 @@ export class AgentBrowserSurfaceController { this.latestRestorableUrl, this.chrome.url, this.paramsUrl, - ].find(isRestorableUrl); + ].find((url) => isBrowsableUrl(url)); } // --- header: title + browser-chrome --- diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index c8ec8fb96..fde4a39ae 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -484,6 +484,47 @@ describe('agent-browser host screenshot transport', () => { await host.closePoppedOut(); }); + it('joins no capture from before a close or relaunch, nor one past the reply timeout', async () => { + // Every screenshot hangs; closes and relaunch steps answer at once. + const shots: string[] = []; + spawnMock.mockImplementation(async (_binary: string, args: string[]) => { + if (args.includes('screenshot')) { + shots.push(args[3]); + return new Promise(() => {}); + } + return spawnResult({ code: args.includes('open') ? 1 : 0 }); + }); + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const capture = async () => { + void host.screenshotToFile('wedged', { format: 'jpeg' }); + await vi.waitFor(() => expect(shots.length).toBeGreaterThan(0)); + await new Promise((resolve) => setTimeout(resolve, 20)); + }; + + await capture(); + await capture(); + expect(shots).toHaveLength(1); + + // A close ends the session those captures were for. + await host.command('wedged', ['close']); + await capture(); + expect(shots).toHaveLength(2); + + // So does a relaunch, which reuses the session name. + await host.popIn('wedged', { url: 'https://example.com/' }); + await capture(); + expect(shots).toHaveLength(3); + + // Past the reply timeout a pending capture is wedged: the next asks afresh, + // into a file the wedged one cannot overwrite. + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now + 31_000); + await capture(); + expect(shots).toHaveLength(4); + expect(new Set(shots).size).toBe(4); + await host.closePoppedOut(); + }); + 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. diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 0a9033325..da999dccf 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -63,13 +63,13 @@ import type { AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, } from '../lib/platform/types'; +import { isBrowsableUrl } from '../lib/platform/browser-automation'; import { privateCaptureDir } from './private-capture-dir'; import { captureFormat, editScript, generateGuiSession, isAgentBrowserSession, - isBrowsableUrl, jpegQuality, parseWebviewCommand, type WebviewCommand, @@ -90,6 +90,9 @@ function webviewArgv(command: WebviewCommand): string[] { } } +// A caller past this joins no pending capture: every adapter has stopped +// waiting for it (vscode-adapter.ts, the standalone host's 30s forward). +const CAPTURE_JOIN_MAX_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 @@ -143,6 +146,8 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser function beginRelaunch(session: string): number { const generation = ++nextRelaunchGeneration; relaunchGenerations.set(session, generation); + // The relaunched daemon's captures must not join its predecessor's. + forgetCaptures(session); return generation; } @@ -405,14 +410,32 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // One capture per session and format at a time, whoever asks — 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, so a caller asking mid-capture joins it. - const capturesInFlight = new Map<string, Promise<unknown>>(); - function oneCapture<T>(key: string, capture: () => Promise<T>): Promise<T> { - const pending = capturesInFlight.get(key) as Promise<T> | undefined; - if (pending) return pending; - const started = capture().finally(() => capturesInFlight.delete(key)); - capturesInFlight.set(key, started); - return started; + // the session's one capture file, so a caller asking mid-capture joins it — + // but never one older than an adapter's reply timeout, which is wedged, nor + // one from before the session's close or relaunch. + type PendingCapture = { session: string; started: number; promise: Promise<unknown> }; + const capturesInFlight = new Map<string, PendingCapture>(); + function oneCapture<T>(session: string, kind: string, capture: () => Promise<T>): Promise<T> { + const key = `${kind}\0${session}`; + const pending = capturesInFlight.get(key); + if (pending && Date.now() - pending.started < CAPTURE_JOIN_MAX_MS) return pending.promise as Promise<T>; + if (pending) forgetCaptures(session); + const entry: PendingCapture = { session, started: Date.now(), promise: Promise.resolve() }; + const promise = capture().finally(() => { + if (capturesInFlight.get(key) === entry) capturesInFlight.delete(key); + }); + entry.promise = promise; + capturesInFlight.set(key, entry); + return promise; + } + + /** Join none of `session`'s pending captures, and give its next one a fresh + * file, so a capture that is still running can never overwrite it. */ + function forgetCaptures(session: string): void { + for (const [key, entry] of capturesInFlight) { + if (entry.session === session) capturesInFlight.delete(key); + } + screenshotNames.delete(session); } async function command(session: string, args: string[], binaryPath?: string): Promise<AgentBrowserCommandResult> { @@ -431,6 +454,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser if (parsed.kind === 'close') { poppedOutSessions.delete(session); relaunchGenerations.delete(session); + forgetCaptures(session); } return runWithBinaryFallback(['--session', session, ...webviewArgv(parsed)], binaryPath); } @@ -495,7 +519,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser return { ok: false, error: 'a valid session name is required' }; } const format = captureFormat(opts.format); - return oneCapture(`file:${format}:${session}`, async (): Promise<AgentBrowserScreenshotFileResult> => { + return oneCapture(session, `file:${format}`, async (): Promise<AgentBrowserScreenshotFileResult> => { const ext = format === 'png' ? 'png' : 'jpg'; let out: string; try { @@ -529,7 +553,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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 oneCapture(`bytes:${format}:${session}`, async (): Promise<AgentBrowserScreenshotResult> => { + return oneCapture(session, `bytes:${format}`, async (): Promise<AgentBrowserScreenshotResult> => { const shot = await screenshotToFile(session, opts, binaryPath); if (!shot.ok) return { ok: false, error: shot.error }; try { diff --git a/lib/src/host/browser-host-shared.ts b/lib/src/host/browser-host-shared.ts index baa41f9a7..df4c2436b 100644 --- a/lib/src/host/browser-host-shared.ts +++ b/lib/src/host/browser-host-shared.ts @@ -5,6 +5,7 @@ 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 @@ -48,17 +49,6 @@ export function jpegQuality(quality: unknown): number { return Math.min(100, Math.max(1, Math.round(quality))); } -/** A URL a provider may launch or navigate to: http(s) only, untrimmed. */ -export function isBrowsableUrl(value: unknown): value is string { - if (typeof value !== 'string' || value !== value.trim()) return false; - try { - const { protocol } = new URL(value); - return protocol === 'http:' || protocol === 'https:'; - } catch { - return false; - } -} - /** 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 diff --git a/lib/src/host/iframe-proxy.test.ts b/lib/src/host/iframe-proxy.test.ts index 14694df73..f58f7e3b2 100644 --- a/lib/src/host/iframe-proxy.test.ts +++ b/lib/src/host/iframe-proxy.test.ts @@ -267,6 +267,19 @@ describe('iframe proxy — serving', () => { expect((await ask()).body).toBe('(none)'); }); + it('keys caches on Sec-Fetch-Dest, since the encoding asked for depends on it', async () => { + const port = await upstream((q, s) => { + if (q.url === '/star') { s.writeHead(200, { 'content-type': 'text/plain', vary: '*' }); s.end('x'); return; } + s.writeHead(200, { 'content-type': q.url === '/page' ? 'text/html' : 'application/javascript', vary: 'Accept-Language' }); + s.end(q.url === '/page' ? '<html><head></head><body>x</body></html>' : 'x'); + }); + const origin = new URL(await frame(`http://127.0.0.1:${port}/`)).origin; + + expect((await get(`${origin}/page`)).headers.vary).toBe('Accept-Language, Sec-Fetch-Dest'); + expect((await get(`${origin}/app.js`)).headers.vary).toBe('Accept-Language, Sec-Fetch-Dest'); + expect((await get(`${origin}/star`)).headers.vary).toBe('*'); + }); + it('streams the instrumented head as soon as a marker split across chunks completes', async () => { let finish: () => void = () => {}; const port = await upstream(async (_q, s) => { diff --git a/lib/src/host/iframe-proxy.ts b/lib/src/host/iframe-proxy.ts index 8a1ae0156..586733e6e 100644 --- a/lib/src/host/iframe-proxy.ts +++ b/lib/src/host/iframe-proxy.ts @@ -403,9 +403,20 @@ function sanitizeResponseHeaders(grant: Grant, headers: http.IncomingHttpHeaders if (typeof loc === 'string') { out.location = rewriteOrigin(loc, grant.upstream.origin, grant.proxyOrigin); } + // The upstream was asked for identity or not by `Sec-Fetch-Dest` + // (DOCUMENT_DESTINATIONS), so a cache must key on it too: a compressed, + // uninstrumented fetch of a page must never answer the frame's navigation. + out.vary = varyAlso(out.vary, 'Sec-Fetch-Dest'); return out; } +function varyAlso(vary: http.OutgoingHttpHeader | undefined, field: string): string { + const fields = (Array.isArray(vary) ? vary.join(',') : String(vary ?? '')) + .split(',').map((part) => part.trim()).filter(Boolean); + if (fields.includes('*') || fields.some((part) => part.toLowerCase() === field.toLowerCase())) return fields.join(', '); + return [...fields, field].join(', '); +} + // Compare parsed origins, never string prefixes or embedded query values. // A foreign redirect/referer must keep its authority and its payload intact. function rewriteOrigin(value: string, from: string, to: string): string { diff --git a/lib/src/host/playwright-host.ts b/lib/src/host/playwright-host.ts index 1880cdf9a..fbb4a6104 100644 --- a/lib/src/host/playwright-host.ts +++ b/lib/src/host/playwright-host.ts @@ -9,6 +9,7 @@ import type { Browser, Page, CDPSession } from 'playwright-core'; import { spawnAndCapture } from 'dor-lib-common'; import { messageOf } from '../lib/errors'; import { + isBrowsableUrl, PLAYWRIGHT_REQUEST_TIMEOUT_MS, PLAYWRIGHT_TEXT_INPUT_MAX, type PlaywrightRequest, @@ -18,7 +19,6 @@ import { captureFormat, editScript, generateGuiSession, - isBrowsableUrl, isPlaywrightSession, jpegQuality, parseWebviewCommand, diff --git a/lib/src/lib/platform/browser-automation.ts b/lib/src/lib/platform/browser-automation.ts index dab29b9ff..004bcd250 100644 --- a/lib/src/lib/platform/browser-automation.ts +++ b/lib/src/lib/platform/browser-automation.ts @@ -58,3 +58,16 @@ export function playwrightTextInputs(text: string): { type: 'input_text'; text: } return messages; } + +/** A URL a browser provider may launch, relaunch or navigate to: http(s) only, + * untrimmed. The hosts refuse anything else (`parseWebviewCommand`), 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; + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} From d1d3fcb8e6694842a7ab575714e285833b8c9e32 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 20:26:07 -0700 Subject: [PATCH 15/18] Bound the agent-browser capture spawn with the new spawn timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spawnAndCapture` now takes a `timeoutMs`, so the screenshot spawn is killed past 30s — beyond the CLI's 25s action timeout a capture can queue behind a page-loading `open`. A hung capture can therefore no longer pin `oneCapture` for its session, which replaces the 30s join cap the previous commit used. Close and relaunch still evict pending captures and give the next a fresh file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 2 +- lib/src/host/agent-browser-host.test.ts | 18 ++++++++--------- lib/src/host/agent-browser-host.ts | 26 ++++++++++++------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 335800aaa..f35b32ab4 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -397,7 +397,7 @@ sidecar/Rust adapter. | Method | 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 in flight**: a request made meanwhile joins it — never one out past 30s, nor one from before the session's close or relaunch. | +| `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 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. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `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. | diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index fde4a39ae..17db52829 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -371,7 +371,7 @@ describe('agent-browser host screenshot transport', () => { expect(spawnMock).toHaveBeenCalledWith('/usr/local/bin/agent-browser', [ '--session', 'shotfile', 'screenshot', shotPath, '--screenshot-format', 'jpeg', '--screenshot-quality', '85', - ]); + ], { timeoutMs: 30_000 }); }); // The frame is a picture of the user's authenticated browser, written by an @@ -484,7 +484,7 @@ describe('agent-browser host screenshot transport', () => { await host.closePoppedOut(); }); - it('joins no capture from before a close or relaunch, nor one past the reply timeout', async () => { + it('joins no capture from before a close or relaunch, and bounds every capture', async () => { // Every screenshot hangs; closes and relaunch steps answer at once. const shots: string[] = []; spawnMock.mockImplementation(async (_binary: string, args: string[]) => { @@ -515,13 +515,13 @@ describe('agent-browser host screenshot transport', () => { await capture(); expect(shots).toHaveLength(3); - // Past the reply timeout a pending capture is wedged: the next asks afresh, - // into a file the wedged one cannot overwrite. - const now = Date.now(); - vi.spyOn(Date, 'now').mockReturnValue(now + 31_000); - await capture(); - expect(shots).toHaveLength(4); - expect(new Set(shots).size).toBe(4); + // Each replacement writes a file the capture it replaced cannot overwrite. + expect(new Set(shots).size).toBe(3); + // And no capture can pin the slot: the spawn itself is bounded past the + // CLI's 25s action timeout. + 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(); }); diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index da999dccf..31e69e94a 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -90,9 +90,10 @@ function webviewArgv(command: WebviewCommand): string[] { } } -// A caller past this joins no pending capture: every adapter has stopped -// waiting for it (vscode-adapter.ts, the standalone host's 30s forward). -const CAPTURE_JOIN_MAX_MS = 30_000; +// 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 +// `oneCapture`. 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 @@ -164,7 +165,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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): Promise<AgentBrowserCommandResult> { + async function runWithBinaryFallback(args: string[], binaryPath?: string, timeoutMs?: number): Promise<AgentBrowserCommandResult> { 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,7 +179,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser let lastError = ''; for (const binary of candidates) { - const result = await spawnAndCapture(binary, args); + const result = await (timeoutMs === undefined ? spawnAndCapture(binary, args) : spawnAndCapture(binary, args, { timeoutMs })); if (result.ok) { return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -411,16 +412,15 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // 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, so a caller asking mid-capture joins it — - // but never one older than an adapter's reply timeout, which is wedged, nor - // one from before the session's close or relaunch. - type PendingCapture = { session: string; started: number; promise: Promise<unknown> }; + // never one from before the session's close or relaunch. The spawn's + // `CAPTURE_TIMEOUT_MS` bounds how long any capture stays joinable. + type PendingCapture = { session: string; promise: Promise<unknown> }; const capturesInFlight = new Map<string, PendingCapture>(); function oneCapture<T>(session: string, kind: string, capture: () => Promise<T>): Promise<T> { const key = `${kind}\0${session}`; const pending = capturesInFlight.get(key); - if (pending && Date.now() - pending.started < CAPTURE_JOIN_MAX_MS) return pending.promise as Promise<T>; - if (pending) forgetCaptures(session); - const entry: PendingCapture = { session, started: Date.now(), promise: Promise.resolve() }; + if (pending) return pending.promise as Promise<T>; + const entry: PendingCapture = { session, promise: Promise.resolve() }; const promise = capture().finally(() => { if (capturesInFlight.get(key) === entry) capturesInFlight.delete(key); }); @@ -430,7 +430,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } /** Join none of `session`'s pending captures, and give its next one a fresh - * file, so a capture that is still running can never overwrite it. */ + * file, so one still running cannot overwrite it. */ function forgetCaptures(session: string): void { for (const [key, entry] of capturesInFlight) { if (entry.session === session) capturesInFlight.delete(key); @@ -533,7 +533,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } 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); + const result = await runWithBinaryFallback(args, binaryPath, 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}` }; From 40ad49ad8f86f2c58b179937427291740ba4b4be Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 23 Sep 2026 20:45:00 -0700 Subject: [PATCH 16/18] Address round-2 review: one URL for the iframe swap, load-tagged reports, UTF-16 by BOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Display modal and the swap to `iframe` now judge the same URL, the page on screen (chrome URL, then params.url, which keeps only http(s)). `iframeRefusal` also refuses anything but http(s), so a `file:` page disables the option, and Apply is disabled if the page changed under a chosen iframe. - T1: the shim tags its `pageshow`/`DOMContentLoaded` location reports `loaded: true` (a field on the existing kind), and only those vouch for a loaded document, so a clicked link's report from the page being left no longer hides a shim-less page that loads right after. - T2: a UTF-16 body is recognized by every WHATWG label and by a byte-order mark, and passes through uninstrumented; the fallback insertion point stays behind a UTF-8 BOM. - T3: an overdue capture no longer clears the shot its wait's pulses owe — when in the round trip a slow capture was taken is unknown. It is still drawn on arrival: the loop now drops a decoded shot only when a newer one has drawn, not when a newer one merely started. - T4: the spec says one capture per session and format. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 22 ++++++----- docs/specs/dor-browser.rationale.md | 4 +- lib/src/components/Wall.test.tsx | 14 +++++++ lib/src/components/Wall.tsx | 10 ++--- .../wall/AgentBrowserScreenModal.test.tsx | 15 ++++++++ .../wall/AgentBrowserScreenModal.tsx | 3 +- lib/src/components/wall/IframePanel.test.tsx | 28 +++++++++++++- lib/src/components/wall/IframePanel.tsx | 6 ++- .../agent-browser-screenshot-loop.test.ts | 15 +++----- .../wall/agent-browser-screenshot-loop.ts | 28 +++++++------- .../agent-browser-surface-controller.test.ts | 5 ++- lib/src/components/wall/browser-url.ts | 17 ++++----- lib/src/host/iframe-proxy-rewrite.test.ts | 37 ++++++++++++++++++- lib/src/host/iframe-proxy-rewrite.ts | 28 ++++++++++++-- lib/src/host/iframe-proxy.test.ts | 19 +++++++++- lib/src/host/iframe-proxy.ts | 20 +++++++++- scripts/spec-word-budgets.json | 2 +- 17 files changed, 206 insertions(+), 67 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index f35b32ab4..5b9c15b27 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -397,7 +397,7 @@ sidecar/Rust adapter. | Method | 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 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. | +| `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. | | `agentBrowserStreamStatus` | Current stream port, for stale-`wsPort` recovery. | | `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. | @@ -517,11 +517,11 @@ Header rewriting: **Must update this table whenever header rewriting changes.** **Must instrument only an identity-encoded, ASCII-compatible HTML body, and keep -its `content-type` as sent**, charset included; a compressed or UTF-16 body -passes through uninstrumented (rationale). **Never place the shim ahead of the -doctype or a `<meta charset>`**: it goes before `</head>`, else after `<body…>`, -else after the document's leading doctype/`<html>`/`<head>`/`<meta charset>` -tags. +its `content-type` as sent**, charset included; a compressed body, or a UTF-16 +one (any WHATWG label, or a byte-order mark), passes through uninstrumented +(rationale). **Never place the shim ahead of the doctype, a `<meta charset>` or a +UTF-8 BOM**: it goes before `</head>`, else after `<body…>`, else after the +document's leading BOM/doctype/`<html>`/`<head>`/`<meta charset>` tags. **Must preserve enforced and report-only CSP verbatim when the upstream response sends `X-Dormouse-Preserve-CSP: 1`.** Add the validated ancestor policy separately, for every MIME type; preserve meta policies during HTML instrumentation. Never infer this opt-in from request headers. Additional upstream restrictions may prevent framing or shim execution. (rationale) @@ -545,8 +545,9 @@ Source of truth: `lib/src/components/wall/IframePanel.tsx`, ### Iframe Shim **Must send four fixed, never-user-provided message kinds to the app and nothing -else** — `leader`, `pointerdown`, `location`, `open-window`. **`location` is -never relayed from a nested document**; the other three are. **`open-window` +else** — `leader`, `pointerdown`, `location`, `open-window`; `location` carries +`loaded: true` only on the document's own `pageshow`/`DOMContentLoaded` report. +**`location` is never relayed from a nested document**; the other three are. **`open-window` intercepts every anchor target but `_self`**, plus `window.open`. **Only `http:` and `https:` reach a browser Surface, re-checked at the sink.** @@ -565,8 +566,9 @@ New-tab requests show an overlay: accept opens an adjacent browser pane (for **Once a proxied frame's shim has reported, a `load` with no `location` report within 1s marks the document uninstrumented** (not HTML, off the proxy, refused, or its grant gone), and a banner offers Reload and Open in agent-browser. Only a -report naming the proxy origin counts, including one up to 250ms before the -load; a new frame source waits for its first report again, since a non-HTML +`loaded` report naming the proxy origin counts, including one up to 250ms before +the load — a clicked link's report comes from the page being left; a new frame +source waits for its first report again, since a non-HTML document served from the start carries no shim (rationale). Source of truth: `lib/src/host/iframe-proxy-rewrite.ts` (`iframeShim`), diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index bebde83ba..86e723e47 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -48,7 +48,7 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a **Why no crisp capture starts inside the provisional window.** A host screenshot round trip is ~120ms against a ~20Hz stream, so *every* capture started while provisional frames are still landing is superseded before it resolves; the shots skipped would never have drawn anything. -**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. VS Code's adapter gave up on a reply after 10s and the webview re-asked the same way, posting the full JPEG to both requests once it came; every adapter now waits 30s, and the host still joins concurrent captures, since surfaces can share a session. When each overdue paint counted against the held capture, it was discarded on arrival and re-taken — an extra full capture on every slow navigation, the crisp frame a round trip late. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. +**Why an overdue capture paints the stream and is never re-issued.** `open` holds the daemon's queue until the page loads, up to 25s (see [Pop-Out](#pop-out)), and the URL bar, `dor ab open` and every relaunch run it. A capture issued meanwhile waits behind it, so the canvas stayed on the previous page for the whole load while the stream was already showing the new one. An 8s watchdog then freed the slot and spawned another `screenshot` into the same queue — about three blocked CLI processes by 25s — and on VS Code the first reply's unlink could delete the second capture's file. VS Code's adapter gave up on a reply after 10s and the webview re-asked the same way, posting the full JPEG to both requests once it came; every adapter now waits 30s, and the host still joins concurrent captures, since surfaces can share a session. When each overdue paint counted against the held capture, it was discarded on arrival and re-taken — an extra full capture on every slow navigation, the crisp frame a round trip late. Its wait's pulses still owe one follow-up: a capture queued behind `open` is taken at the end of its round trip, but one slow in itself may have been taken before the page's last change. The overdue round trip timed the page load, not a capture, so the loop clamps it before it enters the pacing average; otherwise the next slow load would wait ~16s to count as overdue. **Why a stale-dropped capture must leave the loop dirty.** A provisional frame can supersede the host capture or its pending bitmap decode. Nothing is guaranteed to pulse the loop again: a single pointer move over a static page pulses exactly once, and that one pulse is consumed by the very capture the provisional paint supersedes — leaving the pane on the blurry provisional frame until the page happens to change on its own. @@ -98,6 +98,8 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli The built-in local-file viewer supplies its own content boundary and permits the inline shim, so removing its CSP would expand active documents' resource access. Its response opts into preservation without new renderer or host-bridge state. The proxy adds an independent ancestor policy: CSP policies intersect, so no directive parser or partial reconstruction can accidentally weaken the upstream. An opt-in upstream with stricter framing or script restrictions keeps those restrictions even if the shim cannot run. +**Why a UTF-16 body is recognized by its BOM too.** The browser's BOM sniff wins over any header, so a `text/html` body with no charset but `FF FE` is UTF-16; its latin1 scan finds no markers (every character is followed by a NUL), and the fallback spliced the shim in ahead of the BOM, turning the page to mojibake. A UTF-8 BOM ahead of the fallback position had the same fate. + **Why the HTML path keeps the body's own encoding.** Each was reproduced against the proxy (2026-09-23): relabelling every HTML response `charset=utf-8` overrode both the upstream header and any `<meta charset>`, so a Shift_JIS or windows-1252 page mis-decoded; an upstream that compresses without being asked had the shim prepended to its gzip bytes with `content-encoding: gzip` kept, and the frame failed with `ERR_CONTENT_DECODING_FAILED`; and a valid document with neither `</head>` nor `<body>` got the shim before `<!doctype html>`, switching it to quirks mode. Deleting `Accept-Encoding` on every request sent a remote upstream's scripts and styles uncompressed, typically 3-5x the bytes. **Why a grant gets its own origin instead of a path token.** A dedicated origin keeps root-relative resources and client-side routers working with no body URL rewriting; a path token would have to survive every link, redirect and `fetch` the page makes. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index d4376de54..1f18cfd5b 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -13,6 +13,7 @@ import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { sessionForKey } from 'dor-lib-common/agent-browser'; import { Wall } from './Wall'; import * as helpers from '../lib/helper-terminal'; +import * as agentBrowserScreen from './wall/agent-browser-screen'; import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { getAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller'; import * as browserAutomation from './wall/browser-automation'; @@ -3947,6 +3948,19 @@ describe('Wall on the Lath engine', () => { expect(getAgentBrowserScreenController(tab)?.snapshot().renderMode).toBe('ab-screencast'); expect(warn).toHaveBeenCalledWith(`[dormouse] cannot swap surface '${tab}' to iframe: the embedded view frames http:// pages only`); + // The swap judges the page on screen, as the Display modal does — here a + // local http:// page, though params.url still names the https login. + const real = getAgentBrowserScreenController(tab)!; + const lookup = agentBrowserScreen.getAgentBrowserScreenController; + const shown = vi.spyOn(agentBrowserScreen, 'getAgentBrowserScreenController').mockImplementation((id) => ( + id === tab ? { ...real, chrome: () => ({ ...real.chrome(), url: 'http://localhost:5173/report' }) } : lookup(id))); + const beforeSwap = leafIds(); + await act(async () => { real.actions.setRenderMode?.('iframe'); }); + await flush(); + shown.mockRestore(); + const [framed] = leafIds().filter((id) => !beforeSwap.includes(id)); + expect(getAgentBrowserScreenController(framed)?.chrome().url).toBe('http://localhost:5173/report'); + // A launch that fails takes its pane with it. const beforeFailure = leafIds(); await openTab('https://other.example/'); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 6b55fe238..6f5f8f306 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2020,9 +2020,11 @@ export function Wall({ // No pane, tool or not, swaps onto an iframe that would refuse its page; // the Display modal never offers it (docs/specs/dor-browser.md → "Iframe // Renderer"). + // The page on screen, which the Display modal judges too: `params.url` + // keeps only http(s) pages, so it can name an earlier one. + const shownUrl = getAgentBrowserScreenController(id)?.chrome().url || browserUrlFromParams(params); if (mode === 'iframe') { - const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; - const refused = url ? iframeRefusal(url) : null; + const refused = shownUrl ? iframeRefusal(shownUrl) : null; if (refused) { console.warn(`[dormouse] cannot swap surface '${id}' to iframe: ${refused}`); return; @@ -2065,9 +2067,7 @@ export function Wall({ // agent-browser → iframe: frame the active tab's URL, then the replace // closes the now-unneeded headless browser. Webview-only. if (currentRenderMode !== 'iframe' && mode === 'iframe') { - // Canonical params.url (mirrored from the chrome snapshot) first; fall - // back to the live snapshot for a surface that hasn't reported a tab yet. - const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; + const url = shownUrl; if (!url) { console.warn(`[dormouse] cannot swap surface '${id}' to iframe: no URL observed yet`); return; diff --git a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx index 728c666d2..8312ade9f 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.test.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.test.tsx @@ -120,12 +120,27 @@ describe('AgentBrowserScreenModal', () => { expect(iframeRow().textContent).toContain('the embedded view frames http:// pages only'); secure.dispose(); + // Nothing but http(s) is framed at all. + const file = registerStubScreen('file', { + snapshot: { ...STUB_SCREEN, renderMode: 'ab-screencast' }, + chrome: { url: 'file:///tmp/report.html', displayUrl: 'report.html', title: null, key: null }, + }); + act(() => root.render(<AgentBrowserScreenModal controller={getAgentBrowserScreenController('file')!} label="surface:7" onClose={() => {}} />)); + expect(iframeRow().querySelector('input')!.disabled).toBe(true); + file.dispose(); + const local = registerStubScreen('local', { snapshot: { ...STUB_SCREEN, renderMode: 'ab-screencast' }, chrome: { url: 'http://localhost:5173/', displayUrl: 'localhost:5173/', title: null, key: null }, }); act(() => root.render(<AgentBrowserScreenModal controller={getAgentBrowserScreenController('local')!} label="surface:6" onClose={() => {}} />)); expect(iframeRow().querySelector('input')!.disabled).toBe(false); + // The page can move to https after iframe was picked: Apply then stands down. + act(() => iframeRow().querySelector<HTMLInputElement>('input')!.click()); + const apply = () => [...document.body.querySelectorAll('button')].find((button) => button.textContent === 'Apply')!; + expect(apply().disabled).toBe(false); + act(() => local.updateChrome({ url: 'https://example.com/', displayUrl: 'example.com/', title: null, key: null })); + expect(apply().disabled).toBe(true); local.dispose(); }); diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index ea45560c3..63c4b2e70 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -125,7 +125,8 @@ export function AgentBrowserScreenModal({ // `set viewport`, and a valid custom size. const applyDisabled = viewportDisabled || switchingMode - ? false + // The page may have changed since iframe was picked. + ? renderMode === 'iframe' && embedRefusal !== null : (!hostCapable || (target === 'custom' && !customValid)); const apply = () => { diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index a473eab22..b2965fe96 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -291,9 +291,14 @@ describe('iframe failures offer a way out', () => { setPlatform(platform); return platform; } - const report = async (path = '/app') => { + // The shim's load report (`pageshow` / `DOMContentLoaded`); a clicked link's + // report carries no `loaded`. + const report = async (path = '/app', loaded = true) => { await act(async () => { - window.dispatchEvent(new MessageEvent('message', { origin: PROXY, data: { __dormouse: 'location', url: `${PROXY}${path}` } })); + window.dispatchEvent(new MessageEvent('message', { + origin: PROXY, + data: { __dormouse: 'location', url: `${PROXY}${path}`, ...(loaded ? { loaded: true } : {}) }, + })); }); }; const button = (label: string) => Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label); @@ -354,6 +359,25 @@ describe('iframe failures offer a way out', () => { } }); + it('does not count a clicked link\'s report as the next document reporting', async () => { + vi.useFakeTimers(); + try { + proxyPlatform(); + const iframe = await renderPanel(stubActions(), paneProps('iframe-click-report')); + await report(); + await act(async () => { vi.advanceTimersByTime(2000); }); + // A same-origin link to a proxied PDF: the page being left reports the + // href a tick after the click, and the shim-less PDF loads right after. + await report('/manual.pdf', false); + await act(async () => { vi.advanceTimersByTime(50); }); + await act(async () => { iframe.dispatchEvent(new Event('load')); }); + await act(async () => { vi.advanceTimersByTime(1100); }); + expect(banner()).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('does not count a clicked link that leaves the proxy as the shim reporting', async () => { vi.useFakeTimers(); try { diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index 5cda6da93..ef3b8b43b 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -324,7 +324,7 @@ export function IframePanel({ id, title, params }: PaneProps) { if (!proxyOrigin) return; const onMessage = (e: MessageEvent) => { if (e.origin !== proxyOrigin) return; - const data = e.data as { __dormouse?: unknown; url?: unknown } | null; + const data = e.data as { __dormouse?: unknown; url?: unknown; loaded?: unknown } | null; if (data?.__dormouse === 'pointerdown') { actions.onClickPanel(id); return; @@ -345,7 +345,9 @@ export function IframePanel({ id, title, params }: PaneProps) { // document; a clicked link's href can name anywhere. const nextUrl = upstreamUrlFromFrameLocation(data.url, liveUrl || sourceUrl, proxyOrigin); if (!nextUrl) return; - lastShimReportRef.current = performance.now(); + // Only a load report vouches for the document that just loaded: a + // clicked link's report comes from the page being left. + if (data.loaded === true) lastShimReportRef.current = performance.now(); setUninstrumented(false); observeFrameUrl(nextUrl); } 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 49a3463b0..fbad286d7 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.test.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.test.ts @@ -239,7 +239,7 @@ describe('screenshot loop backpressure', () => { }); describe('screenshot loop behind a blocking command', () => { - it('reports the capture overdue, never re-issues it, and owes a shot only for pulses after its release', async () => { + 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']); @@ -265,23 +265,18 @@ describe('screenshot loop behind a blocking command', () => { } expect(screenshot).toHaveBeenCalledTimes(1); - // Those changes are in the capture `open` releases: drawn, nothing owed. + // `open` returns: the capture is drawn, and since when in the wait it was + // taken is unknown, the wait's pulses are owed one more. releases[0]({ ok: true, bytes: new Uint8Array([1]), mime: 'image/jpeg' }); await vi.advanceTimersByTimeAsync(10); expect(loop.captureOverdue()).toBe(false); expect(draw).toHaveBeenCalledTimes(1); - expect(screenshot).toHaveBeenCalledTimes(1); + expect(screenshot).toHaveBeenCalledTimes(2); // The wait timed the page load, not a capture, so the next slow load is - // overdue just as soon — and a change just before its reply is owed a shot. - loop.pulse(); + // overdue just as soon. await vi.advanceTimersByTimeAsync(500); - expect(screenshot).toHaveBeenCalledTimes(2); expect(loop.captureOverdue()).toBe(true); - loop.pulse(); - releases[1]({ ok: true, bytes: new Uint8Array([2]), mime: 'image/jpeg' }); - await vi.advanceTimersByTimeAsync(10); - expect(screenshot).toHaveBeenCalledTimes(3); loop.dispose(); }); diff --git a/lib/src/components/wall/agent-browser-screenshot-loop.ts b/lib/src/components/wall/agent-browser-screenshot-loop.ts index 162c7cd3a..c1c0acfa7 100644 --- a/lib/src/components/wall/agent-browser-screenshot-loop.ts +++ b/lib/src/components/wall/agent-browser-screenshot-loop.ts @@ -68,20 +68,21 @@ const STALL_WARNING_MS = 8000; * * A shot out past twice the usual round trip (at least 400ms) is overdue: queued * behind a blocking daemon command, such as an `open` waiting on its page load, - * while the panel paints the stream instead. It is never re-issued — a second - * one would only queue behind it, and every host adapter bounds the wait. It is - * taken when the command lets it go, so the pulses of its wait leave nothing - * owed, and its round trip, which timed the command, is clamped before it - * enters the pacing average. + * or slow itself, while the panel paints the stream instead. It is never + * re-issued — a second one would only queue behind it, and every host adapter + * bounds the wait. When in the round trip its image was taken is unknown, so a + * pulse during it leaves one shot owed like any other; its round trip is + * clamped before it enters the pacing average. */ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { let inFlight = false; let dirty = false; let seq = 0; + // The newest shot drawn: an older one never paints over it. + let drawnSeq = 0; let lastStart = 0; let avgMs = 120; let timer: ReturnType<typeof setTimeout> | undefined; - let lastPulseAt = 0; let disposed = false; // The `bytes:generation` of the frame currently on the canvas. Skips decoding a // capture we've already displayed onto this same draw target. @@ -100,8 +101,10 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { // the ArrayBufferLike default so they satisfy BlobPart. const part = bytes as Uint8Array<ArrayBuffer>; createImageBitmap(new Blob([part], { type: mime })).then((bitmap) => { - // A newer shot landed first (or we're gone) — drop this one. - if (disposed || mySeq !== seq) { + // A newer shot drew first (or we're gone) — drop this one. A newer shot + // merely started is no reason to withhold this image, which is still + // newer than what is on the canvas. + if (disposed || mySeq < drawnSeq) { bitmap.close(); return; } @@ -116,6 +119,7 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { // Record only once actually drawn, so a shot dropped by the seq guard never // suppresses a later identical capture that must still paint. lastDrawnKey = key; + drawnSeq = mySeq; deps.draw(bitmap); }).catch((err) => console.warn('[agent-browser] screenshot decode failed:', err)); }; @@ -132,12 +136,8 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { deps.log?.(`[agent-browser] screenshot start ${JSON.stringify({ session, seq: mySeq })}`); const stalled = () => performance.now() - lastStart > STALL_WARNING_MS; platform.agentBrowserScreenshot(session, { format: 'jpeg', quality: 85 }, deps.getBinaryPath()).then((res) => { - const now = performance.now(); - const elapsedMs = now - lastStart; + const elapsedMs = performance.now() - lastStart; deps.log?.(`[agent-browser] screenshot done ${JSON.stringify({ session, seq: mySeq, ok: res.ok, bytes: res.bytes?.byteLength ?? 0, elapsedMs: Math.round(elapsedMs), stalled: stalled(), dirty })}`); - // Overdue (see above): once it is drawn, only a pulse after its release - // is owed a shot. - const pulsesInImage = elapsedMs > overdueAfterMs() && lastPulseAt <= now - avgMs; avgMs = avgMs * 0.6 + Math.min(elapsedMs, overdueAfterMs()) * 0.4; inFlight = false; // A provisional stream frame painted during this capture is visibly newer. @@ -153,7 +153,6 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { // work pending so the resting frame still sharpens. dirty = true; } else { - if (pulsesInImage) dirty = false; display(res.bytes, res.mime || 'image/jpeg', mySeq, provisionalAtStart); } } else { @@ -206,7 +205,6 @@ export function createScreenshotLoop(deps: ScreenshotLoopDeps): ScreenshotLoop { return { pulse: () => { if (disposed || !deps.isCapable()) return; - lastPulseAt = performance.now(); dirty = true; schedule(); }, 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 a59b0020e..a058a03f5 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -254,13 +254,14 @@ describe('provisional stream paint', () => { await frame('still loading'); expect(decodes()).toBe(decoded + 1); - // `open` returns: the held capture is the newer image — drawn, not re-taken. + // `open` returns: the held capture is drawn on arrival rather than dropped + // as older than the overdue paints (the follow-up its wait's pulses owe + // comes after). const drawn = drawImage.mock.calls.length; clock.now += 1000; releases[1]({ ok: true, bytes: new Uint8Array([2]), mime: 'image/jpeg' }); await flushMicrotasks(); await flushMicrotasks(); - expect(platform.agentBrowserScreenshot).toHaveBeenCalledTimes(2); expect(drawImage.mock.calls.length).toBe(drawn + 1); }); diff --git a/lib/src/components/wall/browser-url.ts b/lib/src/components/wall/browser-url.ts index 226825b8c..f35df1016 100644 --- a/lib/src/components/wall/browser-url.ts +++ b/lib/src/components/wall/browser-url.ts @@ -90,16 +90,15 @@ export function browserSurfaceUrl(raw: string): string | null { } } -/** Why this host cannot show `url` in an iframe pane, or null when it can: a - * host with the iframe proxy frames http:// only (docs/specs/dor-browser.md → - * "Iframe Renderer"). The raw fallback of a proxy-less host frames https too. */ +/** Why this host cannot show `url` in an iframe pane, or null when it can (or + * when there is no URL yet): nothing but http(s) is framed, and a host with the + * iframe proxy frames http:// only (docs/specs/dor-browser.md → "Iframe + * Renderer"). The raw fallback of a proxy-less host frames https too. */ export function iframeRefusal(url: string): string | null { - try { - if (new URL(url).protocol !== 'https:') return null; - } catch { - return null; - } - return getPlatformOrNull()?.createIframeProxyUrl ? IFRAME_HTTP_ONLY : null; + if (!url) return null; + const framed = browserSurfaceUrl(url); + if (!framed) return IFRAME_HTTP_ONLY; + return framed.startsWith('https:') && getPlatformOrNull()?.createIframeProxyUrl ? IFRAME_HTTP_ONLY : null; } /** The host part of a schemeless authority, minus any `:port`. An IPv6 literal diff --git a/lib/src/host/iframe-proxy-rewrite.test.ts b/lib/src/host/iframe-proxy-rewrite.test.ts index 3ea6dbe4e..4c500e0b4 100644 --- a/lib/src/host/iframe-proxy-rewrite.test.ts +++ b/lib/src/host/iframe-proxy-rewrite.test.ts @@ -7,6 +7,8 @@ import { iframeShim, normalizeEmbedderOrigins, errorPageHtml, + declaresUtf16, + startsWithUtf16Bom, unreachablePage, timedOutPage, } from './iframe-proxy-rewrite'; @@ -65,8 +67,9 @@ describe('instrumentHtml', () => { const withCharset = instrumentHtml('<!DOCTYPE html><html lang="ja"><head><meta charset="shift_jis"><title>x

hi', APP); expect(withCharset.indexOf('')).toBeLessThan(withCharset.search(shim)); expect(withCharset).toMatch(/