Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1557134
Add Playwright browser surfaces with shared provider controls
nedtwigg Sep 10, 2026
6f2b010
Reuse Playwright tab state during capture and clear stale headed owne…
nedtwigg Sep 10, 2026
1269651
Merge origin/main into playwright-browser
nedtwigg Sep 24, 2026
3f190e5
Simplify the Playwright provider and share its plumbing with agent-br…
nedtwigg Sep 24, 2026
8619367
Check a host-returned Playwright executable before dor pw spawns it
nedtwigg Sep 24, 2026
d629d9c
Refuse surface.browser while its Workspace closes
nedtwigg Sep 24, 2026
aef2b18
Offer each browser Surface only the render modes it can take
nedtwigg Sep 24, 2026
28b729b
Name the provider in browser launch errors and warnings
nedtwigg Sep 24, 2026
ffd9d67
Paste into Playwright panes as text the host inserts whole
nedtwigg Sep 24, 2026
a6ebb39
Let host-reported Playwright headedness and cwd reach the controller
nedtwigg Sep 24, 2026
a62c905
Merge origin/main into playwright-browser
nedtwigg Sep 24, 2026
8068e5b
Hold a Playwright pane's own mode write over params that predate it
nedtwigg Sep 24, 2026
eb8778e
Pin the host gates of offeredRenderModes
nedtwigg Sep 24, 2026
076f556
Put a paste chunk boundary inside an emoji in the host paste test
nedtwigg Sep 24, 2026
3aeacda
Address review round 1: bounded launches, durable keys, stale bindings
nedtwigg Sep 24, 2026
28f0aa0
Bound every Playwright CLI call so a wedged CLI cannot overrun a launch
nedtwigg Sep 24, 2026
5edc46b
Address review round 3: blank relaunches, provider-keyed controllers
nedtwigg Sep 24, 2026
1e16cf1
Let the context panel's port actions shrink to fit its minimum width
nedtwigg Sep 24, 2026
fd4179e
Merge origin/main into playwright-browser
nedtwigg Sep 24, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ standalone/sidecar/dor-cli/
standalone/sidecar/iframe-proxy.cjs
standalone/sidecar/recovery.cjs
standalone/sidecar/agent-browser-host.cjs
standalone/sidecar/playwright-host.cjs
standalone/sidecar/burrow.cjs
# Kept beside it: a checkout that built before the Burrow rename, or before the
# alerts joined its bundle, still holds the old bundle, and `bundle.resources`
Expand Down Expand Up @@ -101,3 +102,6 @@ audit-status.txt
# Impeccable per-developer state and generated critiques (design.json is shared).
.impeccable/config.local.json
.impeccable/critique/

# Native Playwright CLI snapshots and traces
.playwright-cli/
59 changes: 43 additions & 16 deletions docs/specs/dor-browser.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli

**Why the screenshot path is private.** The frame is a picture of the user's authenticated browser, written by an external process under the ambient umask, so a derivable name in the shared temp directory is readable by anything else on the machine for as long as it exists. Precedent: `standalone/sidecar/clipboard-ops.js` applies the same discipline, cleanup included, to clipboard images.

## Playwright Renderer

**Why a failed launch waits for its `open`.** `open` runs unawaited while the host polls for the endpoint. A `close` issued before the CLI has registered the session closes nothing, and the `open` then brings up a Chromium window nothing tracks. Before the launch had a deadline, its worst case (close, 30 s of polling, an 8 s connect, close) ran past the webview's 40 s wait, so a slow pop-out could finish after the webview had restored the previous renderer (review of #773, 2026-09).

**Why a paste is text, not keys.** agent-browser's stream takes only key and mouse events, so its paste replays a key down and up per character. Sent to the Playwright host, whose input queue closes the viewer (1008) at 256 queued messages, any paste over about 128 characters arriving as one burst truncated and dropped the pane into a 2 s reconnect (static reading, 2026-09). The 8192-character chunk keeps a message under the 64 KiB socket cap even when every character JSON-escapes to six bytes.

## Iframe Renderer

**Why a site's framing refusal is overridden.** The framing headers exist to stop a third party from framing a site to deceive its user; here the embed is the user's own `dor iframe` — the same trust boundary the agent-browser renderer already sits on.
Expand Down
60 changes: 40 additions & 20 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ Public PTY env:
hex — `randomBytes` in the VS Code host, `getrandom` in the standalone host)
and never goes on the wire.

The CLI also reads `DORMOUSE_AGENT_BROWSER_BIN`, the user's own binary override
that no host sets (`docs/specs/dor-browser.md`).
The CLI also reads `DORMOUSE_AGENT_BROWSER_BIN` and `DORMOUSE_PLAYWRIGHT_BIN`,
the user's own binary overrides that no host sets (`docs/specs/dor-browser.md`).
**An empty override must read as unset**, in `dor` and the hosts alike.

**`DORMOUSE_CLI_BIN` is host-internal spawn configuration, never
terminal-facing:** `pty-core` prepends its *value* to the child's `PATH`, then
Expand Down Expand Up @@ -89,19 +90,19 @@ Source of truth: `dor/bin/dor`, `dor/bin/dor.cmd`, `scripts/stage-dor-cli.mjs`,
**Every spawn of an external/user-installed binary must go through
`spawnAndCapture` from `dor-lib-common`, never raw `node:child_process`
`spawn`** — `dor ab` driving `agent-browser`, the agent-browser host running
tab/eval/screenshot commands, and anything added later. It is the only code
`dor` and the `lib` host share, and it owns the Windows recipe: cross-spawn
rather than Node's own `spawn`, `windowsHide`, and resolution on `exit` with an
exit-time output snapshot (rationale).
tab/eval/screenshot commands, and anything added later. It owns the Windows
recipe: cross-spawn rather than Node's own `spawn`, `windowsHide`, and
resolution on `exit` with an exit-time output snapshot (rationale).

**Never forward an argument containing a literal `%VAR%`** — `cmd.exe` expands
it through a `.cmd` shim, an unavoidable batch limitation; today's forwarded
arguments carry none.

- **`dor ab` spawns the `PATH`-resolved absolute path, never the bare name** —
cross-spawn resolves a bare name through `which`, which searches the cwd
before `PATH` on Windows (rationale). The host's candidate list still ends in a
bare name (`## Future`).
- **`dor ab`, `dor pw` and the Playwright host spawn the `PATH`-resolved
absolute path, never the bare name** — cross-spawn resolves a bare name
through `which`, which searches the cwd before `PATH` on Windows (rationale).
The agent-browser host's candidate list still ends in a bare name
(`## Future`).
- **Within the `PATH` directories, and only those, the walk must select the file
`which` would** — a divergence either runs a different binary or reports a
present install as missing. **Never extend the search to the cwd**, which is
Expand All @@ -124,8 +125,8 @@ arguments carry none.
`accessSync(X_OK)` reports every readable file as executable on Windows.

Source of truth: `binaryCandidateNames`, `isExecutableFile`, `resolveBinaryPath`
and `agentBrowserIsMissing` in `dor/src/commands/agent-browser.ts`; `getPathInfo`
in `which/which.js` is what they mirror; pinned in
and `browserBinaryIsMissing` in `dor-lib-common/src/resolve-binary.ts`;
`getPathInfo` in `which/which.js` is what they mirror; pinned in
`dor/test/cli-output.test.mjs`.

**`spawnAndCapture` never throws:** a spawn-level failure resolves as
Expand All @@ -140,6 +141,13 @@ the exit-grace fallback, without terminating descendants (rationale).
`dor-lib-common/test/spawn.test.mjs` pins caller exit with an inherited-pipe daemon
still alive.

**An optional `timeoutMs` must kill the child and resolve `{ ok: false }` with
`SPAWN_TIMEOUT_CODE`** (`ETIMEDOUT`), without waiting for the kill. POSIX
SIGKILLs the child; **Windows must end the whole tree** with
`%SystemRoot%\System32\taskkill.exe /PID <pid> /T /F`, since a `.cmd` shim's
child is `cmd.exe` and the real CLI is its descendant (`treeKillCommand`, pinned
through its `isWindows` argument).

**Resolution.** `dor-lib-common`'s `exports` point at its built `dist`
(Node-type-free `.d.ts`, since `dor`'s `tsc` avoids `@types/node`); every
esbuild/Vite consumer inlines it. **The `dor` and `dormouse-lib` prebuilds must
Expand Down Expand Up @@ -380,7 +388,7 @@ It picks the style (`cmd` / `posix` / `powershell`) with the same classifier
clipboard/drop path escaping uses
([mouse-and-clipboard.md](mouse-and-clipboard.md) §8.6).

**Every first-party command except the `dor agent-browser` / `dor ab`
**Every first-party command except the `dor agent-browser` / `dor ab` and `dor playwright` / `dor pw`
passthrough accepts `--json`**, emitting a stable object with the same handles
as its text output; single-Surface responses always carry both `surface_id`
(stable) and `surface_ref` (Workspace-stable short ref). Text output carries the
Expand All @@ -402,7 +410,7 @@ snapshot-tested `findReplace` / `remove` help patches (`root` / `command-usage`
renderer. **stricli's default `--help-all`/`-H` integration must stay
unregistered**, leaving `--help`/`-h` the single documented help surface.
`dor --version`/`-v` (sole argument only) is rewritten
to `dor version`, and `ab` to `agent-browser`, before parsing.
to `dor version`, `ab` to `agent-browser`, and `pw` to `playwright`, before parsing.

The spec keeps the behavior help cannot express:

Expand All @@ -414,7 +422,7 @@ The spec keeps the behavior help cannot express:
| `read` | Clean, ANSI-free rendered lines; line limits count rendered lines. |
| `await` | **Must name `--until quiet\|exit`; never infer it.** Timeout 1–86400 whole seconds, default 600; `alert.md` owns wake semantics. |
| `kill` | **Must select exactly one confirmation mode.** Conditional text needs four non-whitespace characters and must match `read`; browser Surfaces are killable. |
| `iframe`, `agent-browser` / `ab` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. |
| `iframe`, `agent-browser` / `ab`, `playwright` / `pw` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. |
| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--all --json` adds `caller_workspace_ref` / `focused_workspace_ref`** beside the `_surface_ref` pair, which under `--all` names a `surface:N` every Workspace has; the `_surface_id` halves stay unique. **`--workspaces` takes `--json` and `--window` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. |
| `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). |
| `app` | Standalone only ([dor app](#dor-app)). |
Expand Down Expand Up @@ -548,11 +556,23 @@ multiple bindings for one dev server remain one candidate:
Only terminal Surfaces own ports, so a browser-Surface handle is rejected.

Source of truth: `dor/src/commands/open-target.ts`,
`dor/src/commands/iframe.ts`, `dor/src/commands/agent-browser.ts`, `resolveOpen`
`dor/src/commands/iframe.ts`, `resolveOpenTargetArgs` in `dor/src/commands/browser-cli.ts`, `resolveOpen`
in `dor/src/protocol.ts`, the `surface.resolveOpen` handler in
`lib/src/components/wall/use-dor-control.ts`, `listenerUrlsByPort` in
`lib/src/components/wall/port-url.ts`.

## Playwright Surface Addressing

**Must intercept `dor playwright` / `dor pw` before stricli parses native arguments.** Identity flags are the same mutually exclusive `--key`, `--session`, `--surface` set as agent-browser, plus the same `--workspace`, with native `-s` accepted for `--session`. `open` and `goto` share Browser Open Target Resolution. All other native arguments, stdout, stderr and exit status pass through; a failed viewer attachment adds a stderr warning without changing command success. Help, install, listing, and close commands never create a Surface.

**Must resolve a managed key or Surface before invoking the CLI**, so commands run in the bound native project cwd and executable. The `surface.resolveBrowser` request and subsequent `surface.browser` binding carry an explicit provider; legacy agent-browser methods retain their contract. Identity scope and lifetime belong to `docs/specs/dor-browser.md` → Playwright Renderer.

**Must pass the bound cwd through `spawnAndCapture`'s optional cwd argument.** Omission preserves inherited cwd for existing callers.

**Never spawn a bound executable the host's Playwright allowlist refuses**, since the binding comes back off persisted params; run the caller's own resolution instead. The caller's `DORMOUSE_PLAYWRIGHT_BIN` is the exact-match override. **Must run the caller's own executable, with a stderr warning, when the bound one is gone**, and **must fail naming a bound cwd that no longer exists** rather than report playwright-cli missing.

Source of truth: `runPlaywrightCli` and `resolveBinding` in `dor/src/commands/playwright.ts`; `BrowserBinding` in `dor/src/commands/types.ts`; `isAllowedPlaywrightBinary` in `dor-lib-common/src/agent-browser.ts`; `spawnAndCapture` in `dor-lib-common/src/spawn.ts`. Pinned by `dor/test/playwright.test.mjs`.

## Agent-Browser Surface Addressing

`dor ab --surface <handle> <verb...>` drives the browser Surface a handle names
Expand Down Expand Up @@ -587,8 +607,8 @@ Context Menu Connect): capability and renderer but no session, failing with

Like every handle target, `--surface` requires a live control endpoint.

Source of truth: `extractSessionFlags` / `resolveSession` in
`dor/src/commands/agent-browser.ts`, `resolveAgentBrowser` in
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
Expand Down Expand Up @@ -671,8 +691,8 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `openCommand` in `
`runWithBinaryFallback` still ends its list with the bare
`DEFAULT_AGENT_BROWSER_BIN`, so on Windows the extension-host or Tauri-app
working directory is searched first — narrower than `dor ab`'s case, since a
user does not clone into it. Sharing `resolveBinaryPath` means moving it to
`dor-lib-common` beside `spawnAndCapture`.
user does not clone into it. `resolvePlaywrightInstall` already walks its
candidates through `dor-lib-common`'s `resolveBinaryPath`.

- **Surface a dead control channel in the UI.** A lost bind leaves one
`[dor-control]` line on the host's stderr, and all a user sees is `dor`
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/dor-cli.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ repository containing `agent-browser.cmd` executed on the next `dor ab` — and
`dor skill` mandates `dor ab` for every page view. The 2026-09-19 security audit
([run 35432996343](https://github.com/diffplug/dormouse/actions/runs/35432996343))
raised it as its one BLOCKER: the hijack needs a *legitimate* install present,
because `agentBrowserIsMissing` refuses to spawn when the PATH walk finds
because `browserBinaryIsMissing` refuses to spawn when the PATH walk finds
nothing. The same audit named three sidecar sites spawning system binaries by
bare name (`standalone/sidecar/pty-core.js`, `standalone/sidecar/clipboard-ops.js`)
whose cwd is the app directory rather than a user's repository;
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ Source of truth: `surface.tool` in `lib/src/components/wall/use-dor-control.ts`;

- **Must retain the Session id, public Surface ref, terminal and notes across serving and renderer changes.** These are changes within one Surface.
- **Must bypass browser `replaceSurface` for Tool renderer swaps**, mutating the Tool's params and releasing the retired browser resources.
- **Never offer or apply a renderer swap outside a Tool's declarable `render` values** ([Declaring tools](#declaring-tools)): the Display modal lists no other, and `onSwapRenderMode` refuses one (rationale).
- **Must run the terminal Activity model for a Tool**, including when its browser is visible. Watched-command defaults belong to `docs/specs/alert.md`.
- **Never apply the untouched-shell kill or shell-replacement shortcut to a Tool**, which spawns touched.
- **Must classify Tool params before browser params**, since a serving Tool carries `renderMode` too.

Source of truth: `surfaceKindFromParams` / `isToolParams` in `lib/src/components/wall/browser-surface.ts`; `onSwapRenderMode` / `requestKill` / `isUntouchedShell` in `lib/src/components/Wall.tsx`; `lib/src/components/wall/tool-surface.test.ts`.
Source of truth: `surfaceKindFromParams` / `isToolParams` in `lib/src/components/wall/browser-surface.ts`; `onSwapRenderMode` / `requestKill` / `isUntouchedShell` in `lib/src/components/Wall.tsx`; `isToolRender` in `lib/src/lib/platform/tool-types.ts`; `lib/src/components/wall/tool-surface.test.ts`; `never swaps a Tool to a render it cannot declare` in `lib/src/components/Wall.test.tsx`.

## Declaring tools

Expand Down
4 changes: 4 additions & 0 deletions docs/specs/dor-tool.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

> Informative evidence for `docs/specs/dor-tool.md`, keyed by its headings.

## The tool capability set

Before the Display modal read the Surface's own render modes (2026-09), it offered a Tool Playwright screencast and popout wherever the host wired Playwright. `onSwapRenderMode`'s Tool branch then wrote `toolRender: 'pw-*'`, cleared the session and launched only for `ab-screencast`, so the pane sat on its placeholder until the next save coerced it to `iframe`; `pw-popout` also popped out a Tool. The Wall refuses independently because the in-controller popout never reaches it.

## Declaring tools

YAML authors naturally collapse one-element lists to scalars. Overloading a scalar dedupe key as a command would make `prespawn_dedupe: storybook` execute instead of identify. Separate future fields avoid that ambiguity.
Expand Down
4 changes: 3 additions & 1 deletion docs/specs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A Pane holds exactly one Surface today, but the model reserves several (a future
|---|---|---|
| `terminal` | — | a PTY + xterm.js instance — a **Session** |
| `tool` | — | a PTY and an optional browser on the same Session |
| `browser` | `iframe`, `ab-screencast`, `ab-popout` | an iframe proxy grant, or an agent-browser daemon session (`docs/specs/dor-browser.md`) |
| `browser` | `iframe`, `ab-screencast`, `ab-popout`, `pw-screencast`, `pw-popout` | an iframe proxy grant, or an automation-provider session (`docs/specs/dor-browser.md`) |

**For a browser Surface `renderMode` is canonical**; the CLI `render_mode` is derived from it and never stored.

Expand All @@ -35,6 +35,8 @@ A Pane holds exactly one Surface today, but the model reserves several (a future
| browser · iframe | `'browser'` | `iframe` | `browser` | `iframe` |
| browser · screencast | `'browser'` | `ab-screencast` | `browser` | `ab-screencast` |
| browser · popped out | `'browser'` | `ab-popout` | `browser` | `ab-popout` |
| browser · Playwright screencast | `'browser'` | `pw-screencast` | `browser` | `pw-screencast` |
| browser · Playwright popout | `'browser'` | `pw-popout` | `browser` | `pw-popout` |

**Kinds are capability sets, not exclusive categories** — terminal and browser carry one capability each, `tool` both. **Operations gate on the capability they need, never on the kind enum** ([Liskov contract](#liskov-contract)): `read` / `send` / `await` / port scans need the terminal, nav / render-mode / agent-browser verbs the browser. **`dor list --json` rows always emit `has_terminal` and `has_browser`** (rationale). **Must declare each kind's capabilities in the `hasTerminal` / `hasBrowser` table.** Persistence keeps its own `PersistedSurfaceType` discriminant (`docs/specs/transport.md`).

Expand Down
Loading
Loading