Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/compatible-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Source of truth: `CODING_AGENTS` in `lib/src/lib/coding-agents.ts`; `detectResum

- **Must keep one rebuilt invocation per Surface in a host-owned, single-use record outside the persisted Session.** The renderer save path never derives or writes it. (rationale)
- **Must call `beginCapture` before capture can return early.** The first call per host process clears the previous record; subsequent calls merge, preserving captures from other Windows. (rationale)
- **Must persist every detection synchronously through `createRecoveryStore`**, using `recovery.json` in the host-selected directory, an owner-only temporary file, and atomic rename. A failed write must not throw through teardown. Without a directory the store is memory-only and logs that limitation once.
- **Must persist every detection synchronously through `createRecoveryStore`**, using `recovery.json` in the host-selected directory, a temporary file with mode `0600` on Unix, and atomic rename. Windows storage permissions follow `docs/specs/security-local.md` -> "Persisted state". A failed write must not throw through teardown. Without a directory the store is memory-only and logs that limitation once.
- **Must read and unlink the durable record on the first claim**, including on parse failure; if unlink fails, ignore it. Discard records older than 7 days after unlinking. Within the process, each container claims only its saved pane ids, and each entry is handed out once. (rationale)
- **Must deliver claimed commands out of band on boot through `PlatformAdapter.getRecoveryCommands()`**; adapters whose hosts capture nothing may omit it. Only cold restore consumes these commands for execution; live resume never executes them.

Expand Down
156 changes: 60 additions & 96 deletions docs/specs/dor-browser.md

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli

**Why one lifecycle for both providers.** The two hosts carried the same policies twice — headed tracking, relaunch generations, the blank-tab sweep, capture joins, the editing scripts — and the copies drifted: an empty copy clobbered the clipboard in one, the capture directory lacked its `chmod` in the other, and only Playwright serialized its closes with its relaunches, so the webview kept its own record of closes in flight for agent-browser (review of the browser stack, 2026-09).

**Why the capture directory is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask, so a derivable name in the shared temp directory is readable by anything else on the machine for as long as it exists. Precedent: `standalone/sidecar/clipboard-ops.js` applies the same discipline, cleanup included, to clipboard images.
**Why captures use a private directory.** External screenshot writers use the
ambient umask; a random directory prevents pre-created names and Unix `0700`
blocks other accounts. A shared Windows temp parent reproduced inherited
Everyone read grants on the directory and screenshot (2026-10-01); Unix modes
do not remove those grants. Windows therefore relies on the temp parent's ACL.

**Why a named launch into a live browser navigates.** A Tool re-announcing — its dev server moved — sends a named launch into the session it already has. Relaunching it stopped the daemon (`close`, then SIGTERM and SIGKILL), so an agent driving that Tool lost its tabs, page state and CDP clients on every move, and a `dor agent-browser` command in flight failed or started a daemon mid-relaunch (review of #777, 2026-09). Only a change of mode needs a new browser.

Expand Down Expand Up @@ -235,3 +239,15 @@ The built-in local-file viewer supplies its own content boundary and permits the
**Why the policy also admits `'self'`.** Storybook and similar apps render same-origin documents in nested frames, so an app-only ancestor list blocks their inner document. Each proxy origin belongs to one grant and one fixed upstream; documents already executing there share same-origin authority. Admitting `'self'` deliberately lets a proxy document frame another document from that grant, including after a top-level navigation, but no foreign ancestor matches and no grant can frame another grant.

**Why the idle timer refreshes for an absent `Origin`.** "Own origin only" would expire a grant the user is still looking at, because a live frame's navigations and sub-resource loads carry no `Origin` at all. What a foreign `Origin` must not buy is keeping a closed pane's grant — and its live upstream binding — alive indefinitely by polling.

## Daemon-owned crisp captures

The historical agent-browser 0.27.3 experiment (measurement date unrecorded) used
headless CDP attachment and correct-target selection. `Page.captureScreenshot`
was byte-identical to the CLI at DPR 1 and followed external `set viewport`, but
returned CSS-resolution frames at higher DPR unless the client reapplied
`Emulation.setDeviceMetricsOverride`. That override introduced another viewport
writer; external `set device`/`set viewport` ratios were not recoverable from
frames. `captureBeyondViewport:true` bypassed emulation and crashed the headless
daemon; `clip.scale` returned blank frames. These results motivate the
Future item's daemon-owned route.
11 changes: 9 additions & 2 deletions docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ shell-integration scripts — the parser scans raw bytes and cannot defend it

The attacker is the page inside a browser pane.

**Known gap: Windows screenshots and pasted clipboard images inherit their
parent's ACL** — private under the default per-user `%TEMP%`, exposed only when
it (or the capture parent) is shared or loosened.

Source of truth: `lib/src/host/private-capture-dir.ts`,
`standalone/sidecar/clipboard-ops.js`, `standalone/src-tauri/src/clipboard_win.rs`.

**Every listener the webview realm exposes to a framed page checks the sender's
origin before it acts** — `IframePanel` against its own panel's proxy origin, the
Wall's leader channel against any live grant (`docs/specs/dor-browser.md` ->
Expand Down Expand Up @@ -198,8 +205,8 @@ buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record"
under `dormouse.session`, and `vscode.setState()`, a WebviewPanel's only store —
so the modes there are VS Code's, not ours, and no transcript reaches either
(`docs/specs/vscode.md` -> "Serialization and restore"). Dormouse also writes
`recovery.json` under the extension's storage directory, owner-only and
temp-then-rename: one rebuilt agent-resume invocation per Surface, no buffer,
`recovery.json` in extension storage, mode `0600` on Unix
and temp-then-rename: one rebuilt agent-resume invocation per Surface, no buffer,
unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record").

**The VS Code peer-link token is a local credential at rest** —
Expand Down
9 changes: 5 additions & 4 deletions docs/specs/security-local.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ Why deceptive links are gated twice. The modal omits its Open action and focuses
Why the origin check is not an authenticity check. The iframe proxy serves the
untrusted upstream on the same origin it grants the shim, so `e.origin` cannot
tell a message the shim sent from one the page sent; what the check buys is that
no *other* frame can send them at all. The four shim messages are bounded
downstream instead — exiting passthrough, selecting a pane, an `http:`/`https:`
only `browserSurfaceUrl` behind an open prompt, and a frame-URL reading that may
lie. `use-wall-keyboard`'s leader channel accepts any live grant rather than one
no *other* frame can send them at all. The shim's actions are bounded
downstream — exiting passthrough, selecting a pane, an `http:`/`https:` URL
behind an open prompt, a frame-URL reading that may lie, and read-only theme
variables. Theme delivery additionally checks the actual iframe window;
requesting it grants no host command. `use-wall-keyboard`'s leader channel accepts any live grant rather than one
panel's, so a page in one browser pane can exit passthrough while another is
focused. The nested-frame relay preserves this boundary: it accepts only the
same proxy origin and reconstructs one of the three pane-level shapes, so
Expand Down
3 changes: 3 additions & 0 deletions docs/specs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ Gaps rather than accepted risks: we intend to close them.
WebSocket cookie headers are stripped, but `document.cookie` remains shared;
cookie-authenticated iframe pages are unsupported
([Loopback Listeners](./security-local.md#loopback-listeners)).
- **Windows screenshots and pasted clipboard images inherit `%TEMP%`'s ACL:**
private by default, exposed if it is shared or loosened
([Browser panes](./security-local.md#browser-panes)).
- **Neither VS Code's peer-link token, its Tool trust receipts, nor the
`recovery.json` beside them carries a Windows ACL applied by Dormouse.**
They are written owner-only by unix mode, which Windows makes a no-op;
Expand Down
4 changes: 2 additions & 2 deletions lib/src/components/wall/AgentBrowserScreenModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ export function AgentBrowserScreenModal({
const currentMode: RenderMode = snapshot?.renderMode ?? 'agent-browser-screencast';
const canSwapRender = !!controller.actions.setRenderMode;
const [renderMode, setRenderMode] = useState<RenderMode>(currentMode);
// The controller declares what this Surface can take (a tool never pops out
// or changes provider); the current mode always shows so it stays selected.
// The controller declares what this Surface can take; a Tool never pops out.
// The current mode always shows so it stays selected.
const offered = (mode: RenderMode) => mode === currentMode || controller.renderModes.includes(mode);
const providers = BROWSER_PROVIDER_IDS.filter(provider => offered(renderModeFor(provider, 'screencast')) || offered(renderModeFor(provider, 'popout')));
const [provider, setChosenProvider] = useBrowserProvider(providers, parseRenderMode(currentMode).provider);
Expand Down
9 changes: 6 additions & 3 deletions lib/src/components/wall/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@
*
* One surface, swappable renderer: it reads the canonical `renderMode` and mounts
* the matching child — `IframePanel` for `iframe`, `AgentBrowserPanel` for
* `agent-browser-screencast` / `agent-browser-popout`. The two children stay separate components (their
* either automated provider's screencast or popout modes. The two children stay separate components (their
* input models differ — CDP `input_*` messages vs native DOM); the shell only owns
* the renderer choice. The browser chrome each child registers is keyed by
* `api.id`, so the shared header/modal are unaffected by which child is mounted.
*/
import type { RenderMode } from './agent-browser-screen';
import { resolveRenderMode } from './browser-surface';
import { resolveRenderMode, type LaunchFallback } from './browser-surface';
import type { BrowserViewportSetting } from 'dor-lib-common/browser-viewports';
import type { PaneProps } from './pane-props';
import { AgentBrowserPanel } from './AgentBrowserPanel';
import { IframePanel } from './IframePanel';

/** Canonical persisted state for a browser surface. `renderMode` + `url` are the
* single source of truth across swaps; the agent-browser fields ride flat and are
* single source of truth across swaps; automation bindings ride flat and are
* present only for automation modes. */
export type BrowserPanelParams = {
surfaceType?: string;
Expand All @@ -33,6 +34,8 @@ export type BrowserPanelParams = {
key?: string;
binaryPath?: string;
syncEngaged?: boolean;
browserViewport?: BrowserViewportSetting;
launchFallback?: LaunchFallback;
/** Set only on a Surface the pane context menu opened for a port, as
* `<sourceSurfaceId>:<port>:<iframe|agent|playwright>`, `agent` being
* agent-browser's. Reuse looks a Surface up by it,
Expand Down
6 changes: 3 additions & 3 deletions lib/src/components/wall/agent-browser-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export type AgentBrowserConnectionEvent =
| { type: 'status'; status: AgentBrowserStreamStatus }
| { type: 'tabs'; tabs: AgentBrowserTab[]; previousTabs: AgentBrowserTab[] }
/** The active tab committed a navigation. Fires at commit; the `tabs`
* snapshot refreshes only when the driving command completes, which for a
* slow page is the whole load (docs/specs/dor-browser.md → "Viewer
* Socket"). */
* snapshot may lag a commit: agent-browser refreshes it when the driving
* command completes, while Playwright polls. See docs/specs/dor-browser.md
* → "Viewer Socket". */
| { type: 'url'; url: string }
/** A popped-out window's page, as its browser reports it. */
| { type: 'page'; url: string; title: string | null }
Expand Down
20 changes: 12 additions & 8 deletions lib/src/components/wall/agent-browser-surface-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,15 @@ function allowedBinaryPath(candidate: unknown, provider: BrowserAutomationProvid
}

/**
* Where the Surface's browser is in its life (docs/specs/dor-browser.md →
* "Browser Connection" has the transition table). The stream connection
* exists exactly in `live`, and so does every daemon command (`driver`).
* Local lifecycle transitions. The stream and command driver exist only in live.
* idle → launching without a session, live for a handed-over stream, otherwise attaching;
* launching → live with the returned stream, otherwise attaching, or ended on failure;
* attaching → live or ended; live → parked after hidden headless delay, relaunching,
* or ended when its browser goes; a failed unpark reattaches without a page.
* parked → live at its stream on unpark, or relaunching;
* relaunching → live with the returned stream, otherwise attaching headless;
* ended → relaunching, or a navigation rebinds with its page; disposed is terminal.
* Cross-file lifetime/visibility requirements: docs/specs/dor-browser.md → "Browser Connection".
*/
type Phase =
/** Constructed; no view has started it yet. */
Expand Down Expand Up @@ -218,11 +224,9 @@ export class AgentBrowserSurfaceController {
}
/** Gates the render modes offered; see `ensureStarted`. */
private readonly isTool: boolean;
/** What `setRenderMode` accepts, fixed on first use with the host's
* capabilities. Never a popout or another provider for a tool, whose
* `render` is `iframe` or `agent-browser-screencast`: the swap would tear the browser
* down and re-derive the same screencast, so asking for a native window would
* get a reload (`docs/specs/dor-tool.md` -> Declaring tools). */
/** What `setRenderMode` accepts, fixed on first use with host capabilities.
* Tools offer iframe and the declarable screencast modes of either provider,
* never popout (`docs/specs/dor-tool.md` → Declaring tools). */
private renderModesCache: readonly RenderMode[] | null = null;
private get renderModes(): readonly RenderMode[] {
return this.renderModesCache ??= offeredRenderModes(this.isTool, this.provider);
Expand Down
2 changes: 1 addition & 1 deletion lib/src/host/agent-browser-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,7 @@ describe('agent-browser host captures', () => {
expect(existsSync(file)).toBe(false);
const dir = dirname(file);
expect(dir).not.toBe(tmpdir());
expect(statSync(dir).mode & 0o777).toBe(0o700);
if (process.platform !== 'win32') expect(statSync(dir).mode & 0o777).toBe(0o700);
// Never a name another capture wrote, and nothing left behind.
expect([...await take(captures)]).toEqual([0xff, 0xd8, 2]);
expect((spawnMock.mock.calls[1][1] as string[])[3]).not.toBe(file);
Expand Down
14 changes: 14 additions & 0 deletions lib/src/host/iframe-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,17 @@ describe('iframe proxy — the upgrade path', () => {
await expect(upgrade(url, wsHeaders({}))).rejects.toMatchObject({ code: 'ECONNREFUSED' });
});
});

describe('iframe grant capacity', () => {
it.each(['sequential', 'concurrent'] as const)('keeps at most 32 published listeners after %s creation', async (mode) => {
advanceClock(6 * 60_000);
await sweep();
const port = await upstream((_q, s) => { s.writeHead(204); s.end(); });
const target = `http://127.0.0.1:${port}/`;
const urls: string[] = [];
if (mode === 'concurrent') urls.push(...await Promise.all(Array.from({ length: 40 }, () => frame(target))));
else for (let i = 0; i < 40; i++) urls.push(await frame(target));
const listening = await Promise.all(urls.map((url) => isListening(Number(new URL(url).port))));
expect(listening.filter(Boolean)).toHaveLength(32);
});
});
5 changes: 5 additions & 0 deletions lib/src/host/iframe-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ export async function createIframeProxyUrl(
}
grant.port = port;
grant.proxyOrigin = `http://127.0.0.1:${port}`;
grant.lastUsed = Date.now();
grants.set(port, grant);
// A bind yields: other creations may have committed since the first sweep.
// Sweep after insertion as well, so neither sequential nor concurrent calls
// leave more than MAX_GRANTS published listeners behind.
sweepGrants(Date.now());
Comment thread
dormouse-bot marked this conversation as resolved.
log(`[iframe-proxy] ${upstream.href} → ${grant.proxyOrigin}`);

// The proxy origin maps to one fixed upstream, so the full path resolves
Expand Down
17 changes: 4 additions & 13 deletions lib/src/host/private-capture-dir.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
/**
* The private per-process directory the browser host's captures are written
* into (docs/specs/dor-browser.md → "Viewer Socket"; `./browser-capture.ts`).
*
* A frame is a picture of the user's authenticated browser, so the *directory*
* is the control: one `mkdtemp` per host, which is `0700` and unguessable. A
* derivable path directly in `os.tmpdir()` let any other local account read
* every frame, or pre-create the name as a symlink and have the writer clobber
* whatever it pointed at. `standalone/sidecar/clipboard-ops.js` does the same
* for clipboard images; the paths are meant to match, cleanup included — a
* frame of someone's authenticated browser is not something to leave in tmp for
* the OS to reap whenever it gets round to it.
* Per-process screenshot directory (docs/specs/dor-browser.md -> "Browser Host").
* mkdtemp makes an unguessable 0700 directory on Unix. Windows inherits the
* temp parent's ACL; this module applies no Windows permission boundary.
*/
import * as os from 'os';
import * as path from 'path';
Expand All @@ -26,8 +18,7 @@ export function privateCaptureDir(prefix: string): PrivateCaptureDir {
let once: Promise<string> | null = null;

function get(): Promise<string> {
// mkdtemp creates at 0700 already; the chmod covers an inherited-mode
// filesystem and is a no-op on Windows, where %TEMP% is per-user.
// Unix modes do not restrict an inherited Windows ACL.
once ??= fs.mkdtemp(path.join(os.tmpdir(), prefix)).then(async (dir) => {
if (process.platform !== 'win32') await fs.chmod(dir, 0o700).catch(() => {});
// Backstop for an exit that never reaches `remove` — a crash, or a host
Expand Down
6 changes: 3 additions & 3 deletions lib/src/host/recovery-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface RecoveryStore {
/**
* The record under `dir`, or a memory-only store when no directory was given.
*
* Owner-only and temp-then-rename, because a kill during the write must not
* Owner-only on Unix and temp-then-rename, because a kill during the write must not
* leave a torn record for the next start to parse — the same durability shape as
* the standalone session snapshot.
*/
Expand All @@ -71,8 +71,8 @@ export function createRecoveryStore(dir?: string, opts: { log?: RecoveryLog } =
const tmp = `${file}.${randomUUID()}.tmp`;
try {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
// Mode on create, so the bytes are never briefly world-readable; the rename
// preserves it.
// Unix mode applies before bytes are written and survives the rename.
// Windows permissions come from the containing directory.
fs.writeFileSync(tmp, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, file);
} catch (err) {
Expand Down
4 changes: 2 additions & 2 deletions scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"docs/specs/alert.md": 8650,
"docs/specs/auto-update.md": 1450,
"docs/specs/deploy.md": 1950,
"docs/specs/dor-browser.md": 9350,
"docs/specs/dor-browser.md": 9000,
"docs/specs/dor-cli.md": 6600,
"docs/specs/dor-tool.md": 6250,
"docs/specs/dor-tools-builtin.md": 1100,
Expand All @@ -25,7 +25,7 @@
"docs/specs/security-audit.md": 2100,
"docs/specs/security-ci.md": 2950,
"docs/specs/security-hosted.md": 2350,
"docs/specs/security-local.md": 3950,
"docs/specs/security-local.md": 4000,
"docs/specs/security-remote.md": 7300,
"docs/specs/security-supply-chain.md": 1250,
"docs/specs/security.md": 2150,
Expand Down
Loading