From 1557134bf39ff70fdeda693b257bbba618d6b7ac Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 15:51:40 -0700 Subject: [PATCH 01/16] Add Playwright browser surfaces with shared provider controls --- .gitignore | 4 + docs/specs/dor-browser.md | 45 +- docs/specs/dor-cli.md | 16 +- docs/specs/glossary.md | 4 +- docs/specs/security-local.md | 2 +- docs/specs/standalone.md | 8 +- docs/specs/transport.md | 2 +- docs/specs/vscode.md | 2 +- dor-lib-common/src/spawn.ts | 4 +- dor-lib-common/test/spawn.test.mjs | 14 + dor/skill.md | 20 + dor/src/cli.ts | 6 + dor/src/commands/agent-browser.ts | 201 +-------- dor/src/commands/browser-cli.ts | 180 ++++++++ dor/src/commands/playwright.ts | 87 ++++ dor/src/commands/types.ts | 12 +- dor/src/control-client.ts | 8 + dor/src/protocol.ts | 2 + dor/test/playwright.test.mjs | 65 +++ dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/playwright.md | 41 ++ lib/package.json | 9 +- lib/src/components/Wall.tsx | 74 ++-- lib/src/components/wall/AgentBrowserPanel.tsx | 33 +- .../wall/AgentBrowserScreenModal.test.tsx | 21 + .../wall/AgentBrowserScreenModal.tsx | 53 ++- .../components/wall/BrowserDisplayIcon.tsx | 6 + lib/src/components/wall/BrowserPanel.tsx | 4 +- lib/src/components/wall/TerminalContext.tsx | 2 +- .../components/wall/TerminalContextView.tsx | 7 +- .../components/wall/agent-browser-screen.ts | 6 +- .../wall/agent-browser-screenshot-loop.ts | 3 +- .../agent-browser-surface-controller.test.ts | 28 +- .../wall/agent-browser-surface-controller.ts | 92 +++-- lib/src/components/wall/browser-automation.ts | 53 +++ .../wall/browser-binding-reservations.test.ts | 29 ++ .../wall/browser-binding-reservations.ts | 20 + lib/src/components/wall/browser-surface.ts | 11 +- lib/src/components/wall/use-dor-control.ts | 85 +++- lib/src/host/browser-stream-guard.test.ts | 19 + lib/src/host/browser-stream-guard.ts | 19 + .../host/playwright-host.lifecycle.test.ts | 86 ++++ lib/src/host/playwright-host.test.ts | 95 +++++ lib/src/host/playwright-host.ts | 390 ++++++++++++++++++ lib/src/host/playwright-install.ts | 48 +++ lib/src/lib/agent-browser-binary.test.ts | 8 +- lib/src/lib/agent-browser-binary.ts | 15 +- lib/src/lib/platform/browser-automation.ts | 29 ++ lib/src/lib/platform/types.ts | 6 + lib/src/lib/platform/vscode-adapter.ts | 7 + lib/tsconfig.app.json | 2 +- pnpm-lock.yaml | 16 + scripts/spec-word-budgets.json | 4 +- standalone/scripts/build-sidecar-proxy.mjs | 2 + standalone/scripts/dev-agent-browser.mjs | 9 + standalone/sidecar/main.js | 10 +- standalone/sidecar/shutdown.test.js | 45 ++ standalone/src-tauri/src/lib.rs | 42 ++ standalone/src/browser-sidecar-adapter.ts | 9 + standalone/src/tauri-adapter.ts | 11 + vscode-ext/src/agent-browser-host.ts | 8 +- vscode-ext/src/message-router.ts | 4 + vscode-ext/src/message-types.ts | 3 + 63 files changed, 1779 insertions(+), 369 deletions(-) create mode 100644 dor/src/commands/browser-cli.ts create mode 100644 dor/src/commands/playwright.ts create mode 100644 dor/test/playwright.test.mjs create mode 100644 dor/test/snapshots/help/playwright.md create mode 100644 lib/src/components/wall/browser-automation.ts create mode 100644 lib/src/components/wall/browser-binding-reservations.test.ts create mode 100644 lib/src/components/wall/browser-binding-reservations.ts create mode 100644 lib/src/host/browser-stream-guard.test.ts create mode 100644 lib/src/host/browser-stream-guard.ts create mode 100644 lib/src/host/playwright-host.lifecycle.test.ts create mode 100644 lib/src/host/playwright-host.test.ts create mode 100644 lib/src/host/playwright-host.ts create mode 100644 lib/src/host/playwright-install.ts create mode 100644 lib/src/lib/platform/browser-automation.ts create mode 100644 standalone/sidecar/shutdown.test.js diff --git a/.gitignore b/.gitignore index fbc49a524..fd5235c67 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ standalone/dist/ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.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 still holds # the old bundle, and `bundle.resources` would ship it. @@ -90,3 +91,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/ diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 91bc6215d..c15adda64 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -4,7 +4,7 @@ > (a browser pane is a **browser Surface**), and `docs/specs/dor-cli.md` for the > shared `dor` CLI, surface handle model, and host control plumbing this surface > builds on. -> Owns the browser Surface end to end — params, chrome, both renderers, the +> Owns the browser Surface end to end — params, chrome, the renderers, the > iframe proxy boundary. Evidence behind the rules: > [dor-browser.rationale.md](dor-browser.rationale.md). @@ -12,15 +12,16 @@ One body component renders all web content: `BrowserPanel`, persisted as `surfaceType: 'browser'` with a swappable `renderMode`. Two axes define a browser pane — its **target** (today always a bare URL; process-backed targets belong to the **dor-tools** scope, `docs/specs/dor-tool.md`) and its **render** mode -(`ab-screencast`, `ab-popout`, `iframe`). **Render is a pane parameter, never a +(`ab-screencast`, `ab-popout`, `pw-screencast`, `pw-popout`, `iframe`). **Render is a pane parameter, never a separate surface kind**; `docs/specs/glossary.md` owns the `kind` / `render_mode` mapping. -Both entry points take a URL, a schemeless `host:port`, or a terminal Surface +Browser entry points take a URL, a schemeless `host:port`, or a terminal Surface 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 ` uses the iframe renderer. The CLI accepts `https://`, but the proxy instruments `http://` upstreams only, so the pane reports an unproxyable scheme. @@ -41,8 +42,8 @@ Invariants on the flat persisted `BrowserPanelParams`: - **`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 session state is flat** (`session`, `wsPort`, `binaryPath`, - `syncEngaged`, `key`), never nested. Pop-out is not a param — it derives from +- **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. - **Never move a browser Surface's DOM, and never let a minimize unmount it** (rationale): Lath never re-parents its leaf div, and a minimize **parks** it @@ -56,14 +57,14 @@ Source of truth: `lib/src/components/wall/BrowserPanel.tsx` (`BrowserPanelParams ## Placement And Lifetime -**Both CLI entry points share one placement rule** (`createContentSurface`): +**Must share one placement rule across browser entry points** (`createContentSurface`): replace an untouched *terminal* caller in place, else split next to the reference surface. **Never replace a reference that already has a browser** — web content is not destroyed to make room. A replacement transfers the target Surface's `surface:N` ref to the new browser Surface id. `dor iframe` also takes `--surface`, `--minimize`, `--json`. -**Both open focus-neutrally**, like `dor ensure`, with one exception: replacing +**Must open focus-neutrally**, like `dor ensure`, with one exception: replacing the pane the user is currently selected on moves selection to the replacement (`docs/specs/layout.md` corner case #6, which owns both halves). @@ -146,11 +147,11 @@ Source of truth: `lib/src/components/wall/use-dev-server-ports.ts`, **Must scan once per context opening**, using the shared per-port URL selection in `docs/specs/dor-cli.md` → Browser Open Target Resolution. Zero/one port uses an inline row; multiple ports use a selector. Failed scans are distinct from no listeners. -**Must offer System browser, Iframe, Agent browser, and Popout for the selected port**, disabling unavailable host capabilities with a reason. Opening a browser from context always preserves the source terminal, including an untouched one. +**Must offer System browser, Iframe, and each automation provider’s screencast and popout for the selected port**, disabling unavailable host capabilities with a reason. Opening a browser from context always preserves the source terminal, including an untouched one. -**Must reuse targets per source and port**: iframe has a separate Surface; agent screencast and popout share a Session and switch display modes. Reattach minimized targets and recreate closed ones. System browser follows the OS opener's behavior. +**Must reuse targets per source, port, and provider**: each provider’s screencast and popout share a browser session and switch display modes. Reattach minimized targets and recreate closed ones. System browser follows the OS opener's behavior. -**Must create agent-browser Surfaces eagerly without a session**, binding the returned session only after the host launch succeeds; failures are reported in context. A launch completing after its eager Surface has closed releases its browser session. Concurrent requests for the same target are serialized. +**Must create automated browser Surfaces eagerly without a session**, binding the returned session only after the host launch succeeds; failures are reported in context. A launch completing after its eager Surface has closed releases its browser session. Concurrent requests for the same target are serialized. Source of truth: `openContextPort` in `lib/src/components/Wall.tsx`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. @@ -158,18 +159,18 @@ Source of truth: `openContextPort` in `lib/src/components/Wall.tsx`; `listenerUr **Must make the Display modal the sole GUI for render mode and screencast resolution.** It splits the Browser Chrome icon pair across its nesting: the -robot rides the `ab-screencast` parent, each nested resolution row carrying only +robot rides each provider’s screencast parent, each nested resolution row carrying only its presentation glyph. **Must hide the popout option where the host lacks `agentBrowserPopOut`.** -Resolution controls apply only to `ab-screencast`, as GUI wrappers around native +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 ` or `set device ` from the modal's registry. **Only `syncEngaged` persists** — device/custom viewport state lives in -agent-browser itself. `SYNCED`/`SCALED` derives from viewport versus pane CSS +the browser itself. `SYNCED`/`SCALED` derives from viewport versus pane CSS dimensions, DPR issued but not compared because stream frames are CSS-resolution. Sync coexists with external `set viewport`/`set device` last-writer-wins: **disengage sync (→ `SCALED`) only after a frame confirms Dormouse's own issued @@ -402,6 +403,24 @@ Source of truth: `lib/src/host/agent-browser-host.ts` (`runWithBinaryFallback`), `standalone/src/tauri-adapter.ts`, `standalone/src-tauri/src/lib.rs`, `standalone/sidecar/main.js`. +## Playwright Renderer + +**Must share browser chrome, Display controls, input, screenshot scheduling, parking, and pop-out behavior with agent-browser.** `pw-screencast` and `pw-popout` select the provider beneath the existing surface controller; neither introduces a Surface kind. Cross-provider swaps preserve only the active URL, warn when other tabs will be lost, and close the old provider. Failed launches restore the previous renderer for visible and minimized Surfaces. + +**Must use the user's installed `@playwright/cli`, resolved from `DORMOUSE_PLAYWRIGHT_BIN` or `PATH`.** GUI launches use Chromium. Native commands retain Playwright semantics: `open` restarts, `goto` navigates; commands for unsupported engines still run, with a viewer warning. The viewer requires the local browser-binding endpoint shipped in CLI 0.1.19; installation errors name that requirement. + +**Must scope managed keys by provider and Dormouse workspace.** Managed bindings retain unique native session names. The first command reserves its cwd and executable for two minutes while binding the Surface; concurrent first commands share the reservation. A successful binding removes the reservation and the Surface retains its cwd/executable for later commands, including relative file paths. `--session` bypasses managed-key addressing and uses the caller's native project scope. `--surface` requires a Playwright renderer. GUI Connect inherits the source terminal's cwd; a swap without one uses the host cwd. + +**Must 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 a viewer is connected. A native headed launch updates the pane's display mode. + +**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. + +**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. 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. Temporary screenshots follow the agent-browser private-directory contract. + +**Must authorize every stream upgrade with an own-loopback Host and a single-use, 60-second token bound to that viewer port.** Grants are capped at 1024; normal HTTP requests are refused. Input is limited to 64 KiB per message and 256 queued messages; frame backpressure drops frames above 2 MB queued. The same guarded stream serves all three hosts. + +Source of truth: `browserPlatform` in `lib/src/components/wall/browser-automation.ts`; `BrowserBindingReservations` in `lib/src/components/wall/browser-binding-reservations.ts`; `createPlaywrightHost` in `lib/src/host/playwright-host.ts`; `resolvePlaywrightInstall` in `lib/src/host/playwright-install.ts`; `BrowserStreamGrants` in `lib/src/host/browser-stream-guard.ts`. Pinned by `lib/src/host/playwright-host.test.ts` (opt-in real CLI via `DORMOUSE_PLAYWRIGHT_TEST_BIN`), `lib/src/host/playwright-host.lifecycle.test.ts`, `lib/src/host/browser-stream-guard.test.ts`, and `lib/src/components/wall/browser-binding-reservations.test.ts`. + ## Iframe Renderer `dor iframe ` frames the page's own DOM — zero-lag for human inspection, but diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 8590e70af..a90979521 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -325,7 +325,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 @@ -348,7 +348,7 @@ renderer. **stricli's default `--help-all`/`-H` integration must stay unregistered**: it bypasses those patches and prints raw usage lines contradicting what the commands accept, 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: @@ -360,7 +360,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. | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | @@ -428,6 +428,16 @@ 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, 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. + +Source of truth: `runPlaywrightCli` in `dor/src/commands/playwright.ts`; `BrowserBinding` in `dor/src/commands/types.ts`; `spawnAndCapture` in `dor-lib-common/src/spawn.ts`. Pinned by `dor/test/playwright.test.mjs`. + ## Agent-Browser Surface Addressing `dor ab --surface ` drives the browser Surface a handle names diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 523998c32..82b064b40 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -22,7 +22,7 @@ A Pane holds exactly one Surface today, but the model reserves several (a future | Kind | Sub-kinds | Backed by | |---|---|---| | `terminal` | — | a PTY + xterm.js instance — a **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. @@ -32,6 +32,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** — the two above carry one capability each, the staged `tool` (`docs/specs/dor-tool.md`) 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`). diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 58407e0bd..23fe7dd8a 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -116,7 +116,7 @@ ancestor chain the webview supplies with each proxy URL request.** `'self'` allo same-grant nesting; any foreign ancestor fails. No request header identifies the embedder, and the browser checks the whole chain (rationale). -- **FAIL IF** any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's *admits all, vouches for none, names its embedder* is another, and is not a violation (rationale). `scripts/loopback-lint.mjs` (`pnpm test`) makes the cheap half deterministic — a new loopback bind that does not reference a guard module fails the build — but only in the bind forms its `BIND_FORMS` lists, each pinned by a fixture in `scripts/loopback-lint-selftest.mjs`, which goes red on a form that has none. **Adding a server dependency means adding its bind spelling there**; a host built at runtime is invisible to a regex in any spelling. The lint sees only a guard reference, not whether every request calls it, so this bullet is still read by hand. Derive the set by searching the shipped trees for `createServer`, `.listen(`, `serve(` and `WebSocket` rather than trusting this list. Today the set is three: the iframe proxy (`lib/src/host/iframe-proxy.ts`), the VS Code agent-browser stream relay (`vscode-ext/src/agent-browser-host.ts`), and the browser-dev bridge (`standalone/scripts/dev-agent-browser.mjs`). A Unix-domain socket or named pipe is not in scope — no browser can reach one — which is why the `dor` control channel is bounded by socket permissions instead. +- **FAIL IF** any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's *admits all, vouches for none, names its embedder* is another, and is not a violation (rationale). `scripts/loopback-lint.mjs` (`pnpm test`) makes the cheap half deterministic — a new loopback bind that does not reference a guard module fails the build — but only in the bind forms its `BIND_FORMS` lists, each pinned by a fixture in `scripts/loopback-lint-selftest.mjs`, which goes red on a form that has none. **Adding a server dependency means adding its bind spelling there**; a host built at runtime is invisible to a regex in any spelling. The lint sees only a guard reference, not whether every request calls it, so this bullet is still read by hand. Derive the set by searching the shipped trees for `createServer`, `.listen(`, `serve(` and `WebSocket` rather than trusting this list. Current listeners include the iframe proxy (`lib/src/host/iframe-proxy.ts`), the VS Code agent-browser stream relay (`vscode-ext/src/agent-browser-host.ts`), the browser-dev bridge (`standalone/scripts/dev-agent-browser.mjs`), and the Playwright viewer (`lib/src/host/playwright-host.ts`; token contract in `docs/specs/dor-browser.md` → Playwright Renderer). A Unix-domain socket or named pipe is not in scope — no browser can reach one — which is why the `dor` control channel is bounded by socket permissions instead. - **FAIL IF** the iframe proxy rewrites `Origin` to the upstream's own origin for a caller whose inbound `Origin` is not the proxy's own — in `handleRequest` **or** `handleUpgrade`. A foreign `Origin` must be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy (rationale). - **FAIL IF** the iframe proxy forwards `Cookie` upstream or `Set-Cookie` downstream on HTTP or WebSocket handshakes, including refused upgrades. Pinned by `lib/src/host/iframe-proxy.test.ts` (rationale). - **FAIL IF** the iframe proxy stops checking that `Host` names its own grant port, on either path. Its per-grant ephemeral port and one-fixed-upstream binding are real mitigations but neither is a secret, so the `Host` check is what makes DNS rebinding fail. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 9c28836e0..76a718e7d 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -207,14 +207,14 @@ the comments at `force_windows_gui_subsystem` and `resolve_dor_node_path`. ## Sidecar lifecycle -Source of truth: `standalone/sidecar/main.js`. +Source of truth: `standalone/sidecar/main.js`. Browser cleanup is pinned by `standalone/sidecar/shutdown.test.js`. Shutdown (`sidecar:shutdown` message, stdin EOF, or SIGTERM) is **idempotent and ordered**: -1. `agentBrowser.closePoppedOut()` under a 1.5s race — quitting must not orphan a - headed Chrome window, and a hung agent-browser must not wedge the exit (as in - the VS Code host's `deactivate()`; `docs/specs/dor-browser.md`). +1. **Must await both browser providers’ cleanup under one 1.5s deadline** + (`agentBrowser.closePoppedOut()` and `playwright.close()`); + `docs/specs/dor-browser.md` owns their teardown contracts. 2. Close the dor control socket. 3. Dispose the Burrow service, dropping the relay socket and settling every outstanding ask so nothing waits on a webview that is going away. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 4663ec747..ff1fd812f 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -46,7 +46,7 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY **An unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. -The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `PERSIST_SESSION = false` gate as `TauriAdapter`, `persistsSession: false`, and any pre-gate `localStorage` blob deleted on `init()` (rationale). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, Playwright. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `PERSIST_SESSION = false` gate as `TauriAdapter`, `persistsSession: false`, and any pre-gate `localStorage` blob deleted on `init()` (rationale). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 6797f7ae8..06dce4d08 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -244,7 +244,7 @@ frame-src http://127.0.0.1:* http://localhost:* **`frame-src` is loopback-only** — `dor iframe` frames its target through the transparent proxy the extension host stands up, so the only origin ever embedded is loopback on an OS-assigned port; without the override `default-src 'none'` blocks the frame and leaves a blank white pane (`docs/specs/dor-browser.md`). -**The webview CSP carries no relay sources.** Its `connect-src` loopback `ws:` entries are for the agent-browser stream relay only — the Burrow holds its `/ws/burrow` socket from the *extension host*, which no CSP fences, so the origin allowlist is enforced there instead (see "Burrow: a service in the extension host"). +**The webview CSP carries no relay sources.** Its `connect-src` loopback `ws:` entries are for the agent-browser stream relay and guarded Playwright viewer — the Burrow holds its `/ws/burrow` socket from the *extension host*, which no CSP fences, so the origin allowlist is enforced there instead (see "Burrow: a service in the extension host"). **That allowlist is a build-time constant, never a runtime value**: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into `dist/extension.js`. The default, the replace-not-add override rule, and the two build-time guards are `docs/specs/relay.md` → "Where a Burrow may reach a Relay". diff --git a/dor-lib-common/src/spawn.ts b/dor-lib-common/src/spawn.ts index 135d5b312..49f420a64 100644 --- a/dor-lib-common/src/spawn.ts +++ b/dor-lib-common/src/spawn.ts @@ -36,11 +36,11 @@ const CLOSE_GRACE_MS = 250; * * Never throws: a spawn-level failure resolves as `{ ok: false, error }`. */ -export function spawnAndCapture(binary: string, args: readonly string[]): Promise { +export function spawnAndCapture(binary: string, args: readonly string[], options: { cwd?: string } = {}): Promise { return new Promise((resolve) => { let child: ReturnType; try { - child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, cwd: options.cwd }); } catch (error) { // Invalid argv (for example a NUL in an eval string) throws before a child // exists; preserve the same result contract as an asynchronous ENOENT. diff --git a/dor-lib-common/test/spawn.test.mjs b/dor-lib-common/test/spawn.test.mjs index 4d4958667..b52f8bffd 100644 --- a/dor-lib-common/test/spawn.test.mjs +++ b/dor-lib-common/test/spawn.test.mjs @@ -2,6 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { mkdtemp, rm, realpath } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { spawnAndCapture } from '../dist/index.js'; const node = process.execPath; @@ -113,3 +116,14 @@ test('releases inherited pipes so the capture caller can exit while the daemon l } } }); + +test('runs relative paths in the requested cwd without changing the caller cwd', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'dor-spawn-cwd-')); + const before = process.cwd(); + try { + const result = await spawnAndCapture(node, ['-e', 'process.stdout.write(process.cwd())'], { cwd }); + assert.equal(result.ok, true); + assert.equal(result.stdout, await realpath(cwd)); + assert.equal(process.cwd(), before); + } finally { await rm(cwd, { recursive: true, force: true }); } +}); diff --git a/dor/skill.md b/dor/skill.md index f277b8c5e..e8035f74c 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -192,6 +192,26 @@ 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`. + +```sh +dor pw --key app open :5173 +dor pw --key app snapshot +dor pw --key app click e15 +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 +belong to `playwright-cli`; `dor pw --help` describes the wrapper. + ## Recipes **Run a dev server and show it to the user.** Keep the surface handle from diff --git a/dor/src/cli.ts b/dor/src/cli.ts index bec0c14ed..d30f2e3b9 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -1,3 +1,4 @@ +import { playwrightCommand, runPlaywrightCli } from './commands/playwright.js'; import { buildApplication, buildRouteMap, @@ -84,6 +85,7 @@ const COMMANDS = [ killCommand, iframeCommand, agentBrowserCommand, + playwrightCommand, listCommand, ] as const satisfies readonly Command[]; @@ -98,6 +100,7 @@ const ROUTES = { kill: killCommand.command, iframe: iframeCommand.command, 'agent-browser': agentBrowserCommand.command, + playwright: playwrightCommand.command, list: listCommand.command, }; @@ -171,6 +174,7 @@ export async function runCli(rawArgv: string[], options: CliOptions = {}): Promi // `dor ab ` forwards args verbatim to agent-browser, so they must // never reach stricli's flag parser. Only a bare `--help`/`-h` (or // `dor help agent-browser`, normalized above) falls through to stricli. + if (argv[0] === 'playwright' && !isAgentBrowserHelpInvocation(argv)) return runPlaywrightCli(argv.slice(1), options); if (argv[0] === 'agent-browser' && !isAgentBrowserHelpInvocation(argv)) { return runAgentBrowserCli(argv.slice(1), options); } @@ -223,6 +227,8 @@ function normalizeVersionAlias(argv: string[]): string[] { /** `ab` is the documented short alias for `agent-browser`, in any help form. */ function normalizeAgentBrowserAlias(argv: string[]): string[] { + if (argv[0] === 'pw') return ['playwright', ...argv.slice(1)]; + if (argv[0] === 'help' && argv[1] === 'pw') return ['help', 'playwright', ...argv.slice(2)]; if (argv[0] === 'ab') return ['agent-browser', ...argv.slice(1)]; if (argv[0] === 'help' && argv[1] === 'ab') return ['help', 'agent-browser', ...argv.slice(2)]; return argv; diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 21c96f059..51ec94601 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -1,3 +1,4 @@ +import type { ResolvedSessionFlags } from './browser-cli.js'; /** `dor ab` passthrough and Surface binding; see docs/specs/dor-cli.md and * docs/specs/dor-browser.md. `runCli` intercepts real invocations before * stricli so forwarded arguments are never parsed as dor flags. */ @@ -7,16 +8,14 @@ import { buildCommand } from '@stricli/core'; // Windows recipe (cross-spawn for PATHEXT/.cmd, windowsHide, exit-vs-close). // See docs/specs/dor-cli.md → "Spawning External Binaries". import { - spawnAndCapture, parseStreamPort, - sessionForKey, streamStatusArgs, AGENT_BROWSER_BIN_ENV, DEFAULT_AGENT_BROWSER_BIN, } from 'dor-lib-common'; -import { existsSync } from 'node:fs'; +import { extractSessionFlags, resolveOpenTargetArgs, resolveBinaryPath, browserBinaryIsMissing, execBrowserProcess, isMissingBinaryError } from './browser-cli.js'; +export { extractSessionFlags, resolveBinaryPath } from './browser-cli.js'; import type { - CliEnv, AgentBrowserExecResult, CliOptions, CliResult, @@ -25,20 +24,10 @@ import type { ParseResult, } from './types.js'; import { errorMessage, fail, requireControlClient, stringParser } from './shared.js'; -import { - inferredHttpUrl, - isSpecialOpenTarget, - isSurfaceOpenTarget, - resolveSurfaceOpenTarget, -} from './open-target.js'; const INSTALL_HINT = 'npm i -g agent-browser'; const INSTALL_DOCS = 'https://agent-browser.dev'; -// Extensions a bare command name can carry on Windows, in PATH-search order. -// Shared by resolveBinaryPath (PATH walk) and existsCandidate (explicit path). -const WINDOWS_BIN_EXTS = ['.cmd', '.exe', '.bat']; - /** * Clear, multi-line guidance shown when the user's agent-browser binary is * absent. `binary` is named only when it differs from the default, so a custom @@ -60,11 +49,6 @@ function missingBinaryMessage(binary: string): string { ].join('\n'); } -// A managed --key becomes part of an agent-browser session name (see -// sessionForKey in dor-lib-common), which becomes a filesystem path — so `/` is -// not usable; restrict to a readable, path-safe charset. -const KEY_PATTERN = /^[A-Za-z0-9._-]+$/; - export const agentBrowserCommand: Command = { name: 'agent-browser', helpPatches: [ @@ -135,72 +119,6 @@ Examples: }), }; -/** The three identity flags dor intercepts, in the order they are reported when - * more than one is given. */ -const IDENTITY_FLAGS = ['--key', '--session', '--surface'] as const; -type IdentityFlag = (typeof IDENTITY_FLAGS)[number]; - -/** Either a session known CLI-side (from `--session`, or namespaced from - * `--key`) or a Surface handle for the host to resolve — never neither, never - * both. A union rather than two optionals so the arm that has no session is - * the arm that has a surface, by construction. `key` rides along only when it - * named the session: a raw or surface-addressed session may be GUI-minted, - * which no key names. */ -type ResolvedSessionFlags = { rest: string[] } & ( - | { session: string; key?: string; surface?: undefined } - | { surface: string; session?: undefined; key?: undefined } -); - -export function extractSessionFlags(args: string[]): ParseResult { - const values = new Map(); - const rest: string[] = []; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] ?? ''; - const flag = IDENTITY_FLAGS.find((name) => arg === name || arg.startsWith(`${name}=`)); - if (!flag) { - rest.push(arg); - continue; - } - - let value: string | undefined; - if (arg.includes('=')) { - value = arg.slice(arg.indexOf('=') + 1); - } else { - value = args[index + 1]; - index += 1; - } - if (!value || value.startsWith('-')) { - return { ok: false, message: `${flag} requires a value` }; - } - values.set(flag, value); - } - - // Three ways to name one browser; naming it twice is always a mistake, never - // a precedence question. - const given = IDENTITY_FLAGS.filter((flag) => values.has(flag)); - if (given.length > 1) { - // "--key and --session"; "--key, --session and --surface" — reported in - // IDENTITY_FLAGS order, not argv order, so the message is stable. - const joined = `${given.slice(0, -1).join(', ')} and ${given[given.length - 1]}`; - return { ok: false, message: `${joined} are mutually exclusive` }; - } - - const key = values.get('--key'); - if (key !== undefined && !KEY_PATTERN.test(key)) { - return { ok: false, message: `--key must match ${KEY_PATTERN} (it becomes part of an agent-browser session name)` }; - } - - const surface = values.get('--surface'); - if (surface !== undefined) return { ok: true, value: { surface, rest } }; - - const session = values.get('--session'); - if (session !== undefined) return { ok: true, value: { session, rest } }; - - const resolvedKey = key ?? 'default'; - return { ok: true, value: { key: resolvedKey, session: sessionForKey(resolvedKey), rest } }; -} - export async function runAgentBrowserCli(args: string[], options: CliOptions): Promise { const flags = extractSessionFlags(args); if (!flags.ok) return fail(flags.message); @@ -223,12 +141,12 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P const env = options.env ?? {}; const binary = env[AGENT_BROWSER_BIN_ENV] || DEFAULT_AGENT_BROWSER_BIN; - const exec = options.execAgentBrowser ?? execAgentBrowserProcess; + const exec = options.execAgentBrowser ?? execBrowserProcess; // Resolve the binary to an absolute path once: it both proves the install // present (below) and travels to the host as `binaryPath` (a GUI host may not // share this terminal's PATH). undefined means "not found on PATH" — or, for - // an explicit path, simply "returned verbatim", which agentBrowserIsMissing + // an explicit path, simply "returned verbatim", which browserBinaryIsMissing // re-checks on disk. const binaryPath = resolveBinaryPath(binary, env); @@ -238,7 +156,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P // output, so `dor ab` would print nothing at all. Checking the filesystem // ourselves sidesteps that ordering. Skipped when a stub exec is injected // (tests), which supplies its own ENOENT behavior via the catch below. - if (options.execAgentBrowser === undefined && agentBrowserIsMissing(binary, env, binaryPath)) { + if (options.execAgentBrowser === undefined && browserBinaryIsMissing(binary, env, binaryPath)) { return fail(missingBinaryMessage(binary)); } @@ -306,55 +224,6 @@ async function resolveSession( } } -// agent-browser's URL-navigation verbs. `goto` / `navigate` are documented -// aliases of `open`, so a Dormouse target resolves the same in all three. -const OPEN_SUBCOMMANDS = new Set(['open', 'goto', 'navigate']); - -/** - * Rewrite a forwarded navigation argv so agent-browser receives a URL: - * `surface:` handles resolve via the host port scan, a bare `:port`/`host:port` - * sugars to http. Non-navigation commands and plain URLs pass through unchanged. - * - * The target is matched by shape (not position), which is what lets `open - * --headed surface:3` resolve — dor can't know agent-browser's flag arity, so it - * can't reliably find "the positional". The trade-off is that a *flag value* - * shaped like a target would be grabbed; this is safe because no agent-browser - * `open` flag takes a `surface:`/`:port`/`host:port`-shaped value (`--headers` is - * JSON, `--init-script` a path, `--enable` a feature name), and `inferredHttpUrl` - * rejects a bare-integer host so a stray `n:n` value can't become a URL. Only the - * first special-shaped arg is rewritten — these verbs take a single target. - */ -async function resolveOpenTargetArgs(rest: string[], options: CliOptions): Promise> { - const subcommand = rest.find((arg) => !arg.startsWith('-')); - if (subcommand === undefined || !OPEN_SUBCOMMANDS.has(subcommand)) return { ok: true, value: rest }; - - const index = rest.findIndex((arg) => isSpecialOpenTarget(arg)); - if (index === -1) return { ok: true, value: rest }; - - const raw = rest[index] ?? ''; - let url: string; - if (isSurfaceOpenTarget(raw)) { - // A Surface handle only resolves against a live Dormouse host; outside one - // there is no control endpoint and the error says so. - const client = requireControlClient(options); - if (client instanceof Error) return { ok: false, message: client.message }; - const resolved = await resolveSurfaceOpenTarget(raw, client); - if (!resolved.ok) return resolved; - url = resolved.value; - } else { - // A schemeless :port / host:port needs no host round trip. isSpecialOpenTarget - // matched a non-surface target, so inference here always succeeds; leave argv - // untouched rather than forward a non-URL if that ever changes. - const inferred = inferredHttpUrl(raw); - if (inferred === null) return { ok: true, value: rest }; - url = inferred; - } - - const next = [...rest]; - next[index] = url; - return { ok: true, value: next }; -} - function shouldManageSurface(exitCode: number, rest: string[]): boolean { if (exitCode !== 0 || rest.length === 0) return false; if (rest.includes('--help') || rest.includes('-h')) return false; @@ -363,61 +232,3 @@ function shouldManageSurface(exitCode: number, rest: string[]): boolean { const subcommand = rest.find((arg) => !arg.startsWith('-')); return subcommand !== undefined && subcommand !== 'close'; } - -export function resolveBinaryPath(binary: string, env: CliEnv): string | undefined { - if (binary.includes('/') || binary.includes('\\')) return binary; - const pathVar = env.PATH; - if (!pathVar) return undefined; - const isWindows = process.platform === 'win32'; - const names = isWindows ? WINDOWS_BIN_EXTS.map((ext) => `${binary}${ext}`) : [binary]; - for (const dir of pathVar.split(isWindows ? ';' : ':')) { - if (!dir) continue; - for (const name of names) { - const candidate = `${dir}${isWindows ? '\\' : '/'}${name}`; - if (existsSync(candidate)) return candidate; - } - } - return undefined; -} - -function isMissingBinaryError(error: unknown): boolean { - return !!error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'; -} - -/** - * Whether the binary can be proven absent without spawning it, given the path - * `resolveBinaryPath` already produced for it. Returns true only when the absence - * is certain; ambiguous cases (no PATH to search) fall through to the spawn, - * which still rejects with ENOENT. - */ -function agentBrowserIsMissing(binary: string, env: CliEnv, resolvedPath: string | undefined): boolean { - // Explicit path (e.g. a DORMOUSE_AGENT_BROWSER_BIN override): resolveBinaryPath - // hands such a path back verbatim without touching disk, so check it (and - // Windows launcher extensions) directly. - if (binary.includes('/') || binary.includes('\\')) { - return !existsCandidate(binary, process.platform === 'win32'); - } - // Bare name: resolvedPath is the PATH walk's result. Without a PATH to search - // we can't prove anything, so let the spawn decide. - if (!env.PATH) return false; - return resolvedPath === undefined; -} - -function existsCandidate(path: string, isWindows: boolean): boolean { - if (existsSync(path)) return true; - if (!isWindows) return false; - return WINDOWS_BIN_EXTS.some((ext) => existsSync(`${path}${ext}`)); -} - -// The default exec: delegate the spawn/capture/Windows handling to -// spawnAndCapture, and adapt its never-throws result to this call site's -// throw-on-spawn-failure contract (callers catch ENOENT via isMissingBinaryError). -async function execAgentBrowserProcess(binary: string, args: string[]): Promise { - const result = await spawnAndCapture(binary, args); - if (!result.ok) { - const error: Error & { code?: string } = new Error(result.error.message); - error.code = result.error.code; - throw error; - } - return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; -} diff --git a/dor/src/commands/browser-cli.ts b/dor/src/commands/browser-cli.ts new file mode 100644 index 000000000..f44901912 --- /dev/null +++ b/dor/src/commands/browser-cli.ts @@ -0,0 +1,180 @@ +/** Shared CLI addressing, navigation and executable plumbing for browser providers. */ +import { existsSync } from 'node:fs'; +import { spawnAndCapture, sessionForKey } from 'dor-lib-common'; +import type { CliEnv, CliOptions, ParseResult, AgentBrowserExecResult } from './types.js'; +import { requireControlClient } from './shared.js'; +import { inferredHttpUrl, isSpecialOpenTarget, isSurfaceOpenTarget, resolveSurfaceOpenTarget } from './open-target.js'; +const KEY_PATTERN = /^[A-Za-z0-9._-]+$/; +const WINDOWS_BIN_EXTS = ['.cmd', '.exe', '.bat']; +/** The three identity flags dor intercepts, in the order they are reported when + * more than one is given. */ +const IDENTITY_FLAGS = ['--key', '--session', '--surface'] as const; +type IdentityFlag = (typeof IDENTITY_FLAGS)[number]; + +/** Either a session known CLI-side (from `--session`, or namespaced from + * `--key`) or a Surface handle for the host to resolve — never neither, never + * both. A union rather than two optionals so the arm that has no session is + * the arm that has a surface, by construction. `key` rides along only when it + * named the session: a raw or surface-addressed session may be GUI-minted, + * which no key names. */ +export type ResolvedSessionFlags = { rest: string[] } & ( + | { session: string; key?: string; surface?: undefined } + | { surface: string; session?: undefined; key?: undefined } +); + +export function extractSessionFlags(args: string[]): ParseResult { + const values = new Map(); + const rest: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ''; + const flag = IDENTITY_FLAGS.find((name) => arg === name || arg.startsWith(`${name}=`)); + if (!flag) { + rest.push(arg); + continue; + } + + let value: string | undefined; + if (arg.includes('=')) { + value = arg.slice(arg.indexOf('=') + 1); + } else { + value = args[index + 1]; + index += 1; + } + if (!value || value.startsWith('-')) { + return { ok: false, message: `${flag} requires a value` }; + } + values.set(flag, value); + } + + // Three ways to name one browser; naming it twice is always a mistake, never + // a precedence question. + const given = IDENTITY_FLAGS.filter((flag) => values.has(flag)); + if (given.length > 1) { + // "--key and --session"; "--key, --session and --surface" — reported in + // IDENTITY_FLAGS order, not argv order, so the message is stable. + const joined = `${given.slice(0, -1).join(', ')} and ${given[given.length - 1]}`; + return { ok: false, message: `${joined} are mutually exclusive` }; + } + + const key = values.get('--key'); + if (key !== undefined && !KEY_PATTERN.test(key)) { + return { ok: false, message: `--key must match ${KEY_PATTERN} (it becomes part of an agent-browser session name)` }; + } + + const surface = values.get('--surface'); + if (surface !== undefined) return { ok: true, value: { surface, rest } }; + + const session = values.get('--session'); + if (session !== undefined) return { ok: true, value: { session, rest } }; + + const resolvedKey = key ?? 'default'; + return { ok: true, value: { key: resolvedKey, session: sessionForKey(resolvedKey), rest } }; +} + +// agent-browser's URL-navigation verbs. `goto` / `navigate` are documented +// aliases of `open`, so a Dormouse target resolves the same in all three. +const OPEN_SUBCOMMANDS = new Set(['open', 'goto', 'navigate']); + +/** + * Rewrite a forwarded navigation argv so agent-browser receives a URL: + * `surface:` handles resolve via the host port scan, a bare `:port`/`host:port` + * sugars to http. Non-navigation commands and plain URLs pass through unchanged. + * + * The target is matched by shape (not position), which is what lets `open + * --headed surface:3` resolve — dor can't know agent-browser's flag arity, so it + * can't reliably find "the positional". The trade-off is that a *flag value* + * shaped like a target would be grabbed; this is safe because no agent-browser + * `open` flag takes a `surface:`/`:port`/`host:port`-shaped value (`--headers` is + * JSON, `--init-script` a path, `--enable` a feature name), and `inferredHttpUrl` + * rejects a bare-integer host so a stray `n:n` value can't become a URL. Only the + * first special-shaped arg is rewritten — these verbs take a single target. + */ +export async function resolveOpenTargetArgs(rest: string[], options: CliOptions, verbs = OPEN_SUBCOMMANDS): Promise> { + const subcommand = rest.find((arg) => !arg.startsWith('-')); + if (subcommand === undefined || !verbs.has(subcommand)) return { ok: true, value: rest }; + + const index = rest.findIndex((arg) => isSpecialOpenTarget(arg)); + if (index === -1) return { ok: true, value: rest }; + + const raw = rest[index] ?? ''; + let url: string; + if (isSurfaceOpenTarget(raw)) { + // A Surface handle only resolves against a live Dormouse host; outside one + // there is no control endpoint and the error says so. + const client = requireControlClient(options); + if (client instanceof Error) return { ok: false, message: client.message }; + const resolved = await resolveSurfaceOpenTarget(raw, client); + if (!resolved.ok) return resolved; + url = resolved.value; + } else { + // A schemeless :port / host:port needs no host round trip. isSpecialOpenTarget + // matched a non-surface target, so inference here always succeeds; leave argv + // untouched rather than forward a non-URL if that ever changes. + const inferred = inferredHttpUrl(raw); + if (inferred === null) return { ok: true, value: rest }; + url = inferred; + } + + const next = [...rest]; + next[index] = url; + return { ok: true, value: next }; +} + +export function resolveBinaryPath(binary: string, env: CliEnv): string | undefined { + if (binary.includes('/') || binary.includes('\\')) return binary; + const pathVar = env.PATH; + if (!pathVar) return undefined; + const isWindows = process.platform === 'win32'; + const names = isWindows ? WINDOWS_BIN_EXTS.map((ext) => `${binary}${ext}`) : [binary]; + for (const dir of pathVar.split(isWindows ? ';' : ':')) { + if (!dir) continue; + for (const name of names) { + const candidate = `${dir}${isWindows ? '\\' : '/'}${name}`; + if (existsSync(candidate)) return candidate; + } + } + return undefined; +} + +export function isMissingBinaryError(error: unknown): boolean { + return !!error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'; +} + +/** + * Whether the binary can be proven absent without spawning it, given the path + * `resolveBinaryPath` already produced for it. Returns true only when the absence + * is certain; ambiguous cases (no PATH to search) fall through to the spawn, + * which still rejects with ENOENT. + */ +export function browserBinaryIsMissing(binary: string, env: CliEnv, resolvedPath: string | undefined): boolean { + // Explicit path (e.g. a DORMOUSE_AGENT_BROWSER_BIN override): resolveBinaryPath + // hands such a path back verbatim without touching disk, so check it (and + // Windows launcher extensions) directly. + if (binary.includes('/') || binary.includes('\\')) { + return !existsCandidate(binary, process.platform === 'win32'); + } + // Bare name: resolvedPath is the PATH walk's result. Without a PATH to search + // we can't prove anything, so let the spawn decide. + if (!env.PATH) return false; + return resolvedPath === undefined; +} + +function existsCandidate(path: string, isWindows: boolean): boolean { + if (existsSync(path)) return true; + if (!isWindows) return false; + return WINDOWS_BIN_EXTS.some((ext) => existsSync(`${path}${ext}`)); +} + +// The default exec: delegate the spawn/capture/Windows handling to +// spawnAndCapture, and adapt its never-throws result to this call site's +// throw-on-spawn-failure contract (callers catch ENOENT via isMissingBinaryError). +export async function execBrowserProcess(binary: string, args: string[], cwd?: string): Promise { + const result = await spawnAndCapture(binary, args, { cwd }); + if (!result.ok) { + const error: Error & { code?: string } = new Error(result.error.message); + error.code = result.error.code; + throw error; + } + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; +} diff --git a/dor/src/commands/playwright.ts b/dor/src/commands/playwright.ts new file mode 100644 index 000000000..663213469 --- /dev/null +++ b/dor/src/commands/playwright.ts @@ -0,0 +1,87 @@ +/** Playwright's native CLI with Dormouse addressing and a shared browser pane. */ +import { buildCommand } from '@stricli/core'; +import type { BrowserBinding, CliOptions, CliResult, Command, DorCommandContext } from './types.js'; +import { extractSessionFlags, resolveOpenTargetArgs, resolveBinaryPath, browserBinaryIsMissing, execBrowserProcess, isMissingBinaryError } from './browser-cli.js'; +import { fail, requireControlClient, errorMessage, stringParser, callerWorkingDirectory } from './shared.js'; + +export const playwrightCommand: Command = { + name: 'playwright', + command: buildCommand<{ key?: string; session?: string; surface?: string }, string[], DorCommandContext>({ + docs: { + brief: 'Drive a browser surface via your Playwright CLI install (alias: dor pw).', + fullDescription: `Forwards native playwright-cli commands to your installed @playwright/cli. +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 +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. +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 --surface surface:4 goto :5173`, + }, + parameters: { + flags: { + key: { kind: 'parsed', parse: stringParser, optional: true, brief: 'Workspace browser key (default "default").', placeholder: 'name' }, + session: { kind: 'parsed', parse: stringParser, optional: true, brief: 'Raw Playwright session name (alias: -s).', placeholder: 'name' }, + surface: { kind: 'parsed', parse: stringParser, optional: true, brief: 'Existing Playwright surface handle.', placeholder: 'handle' }, + }, + positional: { kind: 'array', parameter: { parse: stringParser, brief: 'Native Playwright CLI arguments.', placeholder: 'args' }, minimum: 0 }, + }, + func: async () => new Error('internal: playwright passthrough was not intercepted'), + }), +}; +const NO_BIND = new Set(['close', 'detach', 'close-all', 'kill-all', 'delete-data', 'list', 'show', 'install', 'install-browser']); +function missing(binary: string): CliResult { + return fail(`playwright-cli is not installed (looked for '${binary}').\n\nInstall it with: npm i -g @playwright/cli\nOr set DORMOUSE_PLAYWRIGHT_BIN to its full path.`); +} +export async function runPlaywrightCli(args: string[], options: CliOptions): Promise { + const parsed = extractSessionFlags(args.map(arg => arg === '-s' ? '--session' : arg.startsWith('-s=') ? `--session=${arg.slice(3)}` : arg)); + if (!parsed.ok) return fail(parsed.message.replaceAll('agent-browser', 'Playwright')); + const flags = parsed.value; + const client = requireControlClient(options); + const nativeCommand = flags.rest.find(arg => !arg.startsWith('-')); + const informational = flags.rest.some(arg => ['--help', '-h', '--version', '-v'].includes(arg)); + const mayBind = !!nativeCommand && !NO_BIND.has(nativeCommand) && !informational; + const env = options.env ?? {}; + let binding: BrowserBinding = { session: flags.session ?? '', cwd: callerWorkingDirectory(undefined, env), binaryPath: resolveBinaryPath(env.DORMOUSE_PLAYWRIGHT_BIN ?? 'playwright-cli', env) }; + if ((flags.surface !== undefined || flags.key !== undefined) && !informational) { + if (client instanceof Error) { + if (flags.surface !== undefined) return fail(client.message); + } else { + if (!client.resolveBrowser) return fail('This Dormouse host does not support Playwright. Update Dormouse and retry.'); + try { + const resolved = await client.resolveBrowser({ provider: 'playwright', ...(flags.surface ? { surface: flags.surface } : { key: flags.key, ...(mayBind ? { proposed: binding } : {}) }) }); + if (resolved.binding) binding = resolved.binding; + else if (flags.surface) return fail('The surface has no Playwright session yet.'); + } catch (error) { return fail(errorMessage(error)); } + } + } + const resolved = await resolveOpenTargetArgs(flags.rest, options, new Set(['open', 'goto'])); + if (!resolved.ok) return fail(resolved.message); + const rest = resolved.value; + const binary = binding.binaryPath ?? env.DORMOUSE_PLAYWRIGHT_BIN ?? 'playwright-cli'; + const binaryPath = resolveBinaryPath(binary, env); + if (!options.execPlaywright && browserBinaryIsMissing(binary, env, binaryPath)) return missing(binary); + const exec = options.execPlaywright ?? execBrowserProcess; + try { + const result = await exec(binary, [`--session=${binding.session}`, ...rest], binding.cwd); + if (result.exitCode === 0 && mayBind && !(client instanceof Error)) { + try { + if (!client.browserSurface) throw new Error('This Dormouse host does not support Playwright.'); + await client.browserSurface({ provider: 'playwright', key: flags.key, session: binding.session, cwd: binding.cwd, ...(binaryPath ? { binaryPath } : {}) }); + } catch (error) { result.stderr += `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; } + } + return result; + } catch (error) { return isMissingBinaryError(error) ? missing(binary) : fail(errorMessage(error)); } +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ceafea495..fd062c3d9 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -8,7 +8,7 @@ export type IdFormat = 'refs' | 'ids' | 'both'; export type SplitDirection = 'left' | 'right' | 'up' | 'down' | 'auto'; export type ResolvedSplitDirection = 'left' | 'right' | 'up' | 'down'; export type SurfaceKind = 'terminal' | 'browser'; -export type SurfaceRenderMode = 'iframe' | 'ab-screencast' | 'ab-popout'; +export type SurfaceRenderMode = 'iframe' | 'ab-screencast' | 'ab-popout' | 'pw-screencast' | 'pw-popout'; /** What each kind is backed by (`docs/specs/glossary.md` → Panes and Surfaces). * The single source of capability gating; kind switches elsewhere go through @@ -256,7 +256,14 @@ export interface ResolveAgentBrowserSessionResponse { session: string; } +export type BrowserAutomationProvider = 'agent-browser' | 'playwright'; +export interface BrowserBinding { session: string; cwd?: string; binaryPath?: string } +export interface ResolveBrowserRequest { provider: BrowserAutomationProvider; key?: string; surface?: string; proposed?: BrowserBinding } +export interface ResolveBrowserResponse { binding: BrowserBinding | null } + export interface AgentBrowserSurfaceRequest { + provider?: BrowserAutomationProvider; + cwd?: string; /** Managed workspace-scoped key; absent when attaching via raw --session. */ key?: string; /** Resolved agent-browser session name — the join key for the surface. */ @@ -277,6 +284,8 @@ export interface AgentBrowserSurfaceResponse { } export interface ControlClient { + browserSurface?(request: AgentBrowserSurfaceRequest): Promise; + resolveBrowser?(request: ResolveBrowserRequest): Promise; listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; @@ -311,6 +320,7 @@ export interface CliOptions { readStdin?: () => Promise; versionMetadata?: VersionMetadata; execAgentBrowser?: AgentBrowserExec; + execPlaywright?: (binary: string, args: string[], cwd?: string) => Promise; } export interface CliResult { diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 5e3b1cf33..04697c89e 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -1,3 +1,4 @@ +import type { ResolveBrowserRequest, ResolveBrowserResponse } from './commands/types.js'; import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { createConnection } from 'node:net'; import type { @@ -122,6 +123,13 @@ export class SocketControlClient implements ControlClient { return this.request(SURFACE_CONTROL_METHODS.iframe, request); } + browserSurface(request: AgentBrowserSurfaceRequest): Promise { + return this.request(SURFACE_CONTROL_METHODS.browser, request); + } + resolveBrowser(request: ResolveBrowserRequest): Promise { + return this.request(SURFACE_CONTROL_METHODS.resolveBrowser, request); + } + agentBrowserSurface(request: AgentBrowserSurfaceRequest): Promise { return this.request(SURFACE_CONTROL_METHODS.agentBrowser, request); } diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 403b80796..f20068663 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -23,6 +23,8 @@ export const SURFACE_CONTROL_METHODS = { kill: 'surface.kill', iframe: 'surface.iframe', agentBrowser: 'surface.agentBrowser', + browser: 'surface.browser', + resolveBrowser: 'surface.resolveBrowser', resolveOpen: 'surface.resolveOpen', resolveAgentBrowser: 'surface.resolveAgentBrowser', } as const; diff --git a/dor/test/playwright.test.mjs b/dor/test/playwright.test.mjs new file mode 100644 index 000000000..dc51151c8 --- /dev/null +++ b/dor/test/playwright.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCli } from '../dist/cli.js'; + +function fixture(binding = null) { + const calls = []; + const options = { + env: { PWD: '/caller', DORMOUSE_PLAYWRIGHT_BIN: '/tools/playwright-cli' }, + client: { + resolveBrowser: async request => { calls.push(['resolve', request]); return { binding }; }, + browserSurface: async request => { calls.push(['surface', request]); return {}; }, + }, + execPlaywright: async (...args) => { calls.push(['exec', ...args]); return { exitCode: 0, stdout: 'native output\n', stderr: '' }; }, + }; + return { calls, options }; +} + +test('pw alias forwards native arguments and binds only to Playwright', async () => { + const { calls, options } = fixture(); + const result = await runCli(['pw', '--key', 'app', 'open', ':5173', '--headed'], options); + assert.equal(result.stdout, 'native output\n'); + assert.equal(result.exitCode, 0); + assert.deepEqual(calls.find(c => c[0] === 'exec'), ['exec', '/tools/playwright-cli', ['--session=dormouse.1.app', 'open', 'http://localhost:5173/', '--headed'], '/caller']); + assert.equal(calls.at(-1)[1].provider, 'playwright'); +}); +test('existing key pins executable, cwd and native session across terminal directories', async () => { + const { calls, options } = fixture({ session: 'gui-123', cwd: '/first-project', binaryPath: '/first/playwright-cli' }); + await runCli(['playwright', 'screenshot', 'relative.png'], options); + assert.deepEqual(calls.find(c => c[0] === 'exec'), ['exec', '/first/playwright-cli', ['--session=gui-123', 'screenshot', 'relative.png'], '/first-project']); +}); +test('raw -s bypasses workspace addressing', async () => { + const { calls, options } = fixture(); + await runCli(['pw', '-s=raw-session', 'goto', ':8080'], options); + assert.equal(calls.some(c => c[0] === 'resolve'), false); + assert.deepEqual(calls[0].slice(2), [['--session=raw-session', 'goto', 'http://localhost:8080/'], '/caller']); + assert.equal(calls.at(-1)[1].key, undefined); +}); +test('surface addressing resolves GUI-created sessions and rejects missing sessions', async () => { + const { calls, options } = fixture({ session: 'gui-123', cwd: '/gui' }); + await runCli(['pw', '--surface', 'surface:3', 'snapshot'], options); + assert.equal(calls[0][1].surface, 'surface:3'); + const missing = fixture(); + assert.equal((await runCli(['pw', '--surface', 'surface:3', 'snapshot'], missing.options)).exitCode, 1); + assert.equal(missing.calls.some(c => c[0] === 'exec'), false); +}); +test('native failures and close do not create or resurrect panes', async () => { + const { calls, options } = fixture(); + await runCli(['pw', 'close'], options); + assert.equal(calls.some(c => c[0] === 'surface'), false); + options.execPlaywright = async () => ({ exitCode: 7, stdout: '', stderr: 'native error' }); + assert.deepEqual(await runCli(['pw', 'open', ':8080'], options), { exitCode: 7, stdout: '', stderr: 'native error' }); +}); +test('viewer failure preserves native command output and exit status', async () => { + const { options } = fixture(); + options.client.browserSurface = async () => { throw new Error('Chromium only'); }; + const result = await runCli(['pw', 'snapshot'], options); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, 'native output\n'); + assert.match(result.stderr, /Chromium only/); +}); +test('identity flags are mutually exclusive', async () => { + const { calls, options } = fixture(); + assert.equal((await runCli(['pw', '--key', 'app', '-s', 'raw', 'snapshot'], options)).exitCode, 1); + assert.deepEqual(calls, []); +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1ee6a0e77..04748af1e 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -14,6 +14,7 @@ USAGE dor kill [--confirm-if-read text|--confirm-dangerously] [--json] dor iframe [--json] [--minimize] [--surface id|ref] dor agent-browser [--key name|--session name|--surface handle] [args...] + dor playwright [--key name] [--session name] [--surface handle] ... dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] dor --help @@ -34,6 +35,7 @@ COMMANDS kill Kill a surface. iframe Open a target in an iframe surface. agent-browser Drive a browser surface via your agent-browser install (alias: dor ab). + playwright Drive a browser surface via your Playwright CLI install (alias: dor pw). list List Dormouse Surfaces. ``` diff --git a/dor/test/snapshots/help/playwright.md b/dor/test/snapshots/help/playwright.md new file mode 100644 index 000000000..dfc52121a --- /dev/null +++ b/dor/test/snapshots/help/playwright.md @@ -0,0 +1,41 @@ +# dor playwright + +Invocation: `dor playwright --help` + +```text +USAGE + dor playwright [--key name] [--session name] [--surface handle] ... + dor playwright --help + +Forwards native playwright-cli commands to your installed @playwright/cli. +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 +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. +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 --surface surface:4 goto :5173 + +FLAGS + [--key] Workspace browser key (default "default"). + [--session] Raw Playwright session name (alias: -s). + [--surface] Existing Playwright surface handle. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + args... Native Playwright CLI arguments. + +``` diff --git a/lib/package.json b/lib/package.json index 91e1ef771..add84a6f3 100644 --- a/lib/package.json +++ b/lib/package.json @@ -20,13 +20,13 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", - "@zxing/browser": "0.2.1", - "@zxing/library": "0.23.0", "@xterm/addon-fit": "0.12.0-beta.301", "@xterm/addon-image": "0.10.0-beta.301", "@xterm/addon-unicode-graphemes": "0.5.0-beta.301", "@xterm/addon-webgl": "0.20.0-beta.300", "@xterm/xterm": "6.1.0-beta.304", + "@zxing/browser": "0.2.1", + "@zxing/library": "0.23.0", "clsx": "^2.1.1", "dor-lib-common": "workspace:*", "fflate": "0.8.3", @@ -36,7 +36,8 @@ "remote-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", - "uqr": "^0.1.3" + "uqr": "^0.1.3", + "ws": "^8.21.3" }, "devDependencies": { "@storybook/addon-docs": "^10.4.0", @@ -45,9 +46,11 @@ "@tailwindcss/vite": "^4.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.2", "chromatic": "^17.0.0", "fake-indexeddb": "^6.2.5", + "playwright-core": "1.63.0-alpha-2026-08-31", "remark-gfm": "^4.0.1", "storybook": "^10.4.0", "tailwindcss": "^4.3.0", diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index e974da415..a1df85e87 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1,3 +1,4 @@ +import { automationProvider, browserPlatform, browserSessionKey, isPopout } from './wall/browser-automation'; import { TerminalContextContext, type TerminalContextOpenOptions, type TerminalContextState } from './wall/wall-context'; import { TERMINAL_CONTEXT_EXIT_MS } from './design'; import { motionIsInstant } from '../lib/ui-geometry'; @@ -21,7 +22,7 @@ const RemotePairingModalHost = lazy(() => ); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; -import { isAllowedAgentBrowserBinary } from '../lib/agent-browser-binary'; +import { isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary } from '../lib/agent-browser-binary'; import { disposeAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; import { NotepadArchiveFailureModal, type NotepadArchiveFailure } from './NotepadArchiveFailure'; @@ -197,11 +198,12 @@ function closeAgentBrowserSession(params: unknown): void { const binaryPath = (params as { binaryPath?: unknown }).binaryPath; // Mark before issuing the close so a popped-out surface's auto-revert sees // the impending teardown and doesn't relaunch the session we're killing. - markAgentBrowserSessionClosed(session); - getPlatform().agentBrowserCommand?.( + const p = params as { renderMode?: string; cwd?: string }; + markAgentBrowserSessionClosed(browserSessionKey(session, p.renderMode, p.cwd)); + browserPlatform(p.renderMode, p.cwd).agentBrowserCommand?.( session, ['close'], - isAllowedAgentBrowserBinary(binaryPath) ? binaryPath : undefined, + (automationProvider(p.renderMode) === 'playwright' ? isAllowedPlaywrightBinary(binaryPath) : isAllowedAgentBrowserBinary(binaryPath)) ? binaryPath as string : undefined, ).catch(() => {}); } @@ -1518,7 +1520,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 === 'ab-screencast' || currentRenderMode === 'ab-popout') && mode === 'iframe') { + 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 = (typeof params?.url === 'string' && params.url) || getAgentBrowserScreenController(id)?.chrome().url; @@ -1543,7 +1545,7 @@ export function Wall({ // what it is waiting for, and the daemon hands it `{session, wsPort, // binaryPath}` as one params refresh. Same shape as the pane context menu's // connect (docs/specs/dor-browser.md → "Pane Context Menu Connect"). - if (currentRenderMode === 'iframe' && (mode === 'ab-screencast' || mode === 'ab-popout')) { + if (mode !== 'iframe' && (currentRenderMode === 'iframe' || automationProvider(currentRenderMode) !== automationProvider(mode))) { const chromeUrl = getAgentBrowserScreenController(id)?.chrome().url; const rawUrl = (typeof chromeUrl === 'string' && chromeUrl) || (typeof params?.url === 'string' ? params.url : ''); @@ -1551,7 +1553,8 @@ export function Wall({ // non-http(s) source but still holds it, and this path would hand it to // a real Chromium tab — which `dor ab open` refuses at the CLI // (`normalizeConcreteOpenUrl`). Same guard, so both sinks agree. - const platform = getPlatform(); + const cwd = typeof params?.cwd === 'string' ? params.cwd : undefined; + const platform = browserPlatform(mode, cwd); // `browserSurfaceUrl('')` is already null (normalizeNavUrl returns '' // for empty input), so one branch covers both dead ends — and warning // matches the ab-* -> iframe branch above, whose own no-URL refusal is @@ -1563,10 +1566,10 @@ export function Wall({ return; } if (!platform.agentBrowserOpen) return; - const headed = mode === 'ab-popout'; + const headed = isPopout(mode); const title = hostPathDisplay(url, true); const eagerId = replaceSurface(id, { - params: { surfaceType: 'browser', renderMode: mode, url, syncEngaged: true }, + params: { surfaceType: 'browser', renderMode: mode, url, cwd, syncEngaged: true }, title, }); if (!eagerId) return; @@ -1574,31 +1577,40 @@ export function Wall({ const eagerSurfaceExists = () => ( !!lath.getMeta(eagerId) && (lath.store.has(eagerId) || eagerDoorExists()) ); - const restoreIframe = () => { + const restorePrevious = () => { if (lath.isDying(eagerId)) return; + const previousParams = { ...params, surfaceType: 'browser', renderMode: currentRenderMode, url, session: undefined, wsPort: undefined }; + let restored: string | undefined; if (lath.store.has(eagerId)) { - replaceSurface(eagerId, { params: { surfaceType: 'browser', renderMode: 'iframe', url }, title }); - return; + restored = replaceSurface(eagerId, { params: previousParams, title }) ?? undefined; + } else if (eagerDoorExists() && lath.getMeta(eagerId)) { + disposeAgentBrowserSurfaceController(eagerId); + lath.store.updateParams(eagerId, previousParams); + lath.store.setTitle(eagerId, title); + restored = eagerId; } - // A minimized Surface is outside the visible tree but its Door + meta - // are still authoritative. Swap the parked body back in place so the - // Door does not remain a session-less "Connecting..." pane. - if (!eagerDoorExists() || !lath.getMeta(eagerId)) return; - disposeAgentBrowserSurfaceController(eagerId); - lath.store.updateParams(eagerId, { surfaceType: 'browser', renderMode: 'iframe', url, syncEngaged: false }); - lath.store.setTitle(eagerId, title); + if (!restored || currentRenderMode === 'iframe') return; + const restoredId = restored; + const previous = browserPlatform(currentRenderMode, cwd); + void previous.agentBrowserOpen?.(url, { headed: isPopout(currentRenderMode) }, typeof params?.binaryPath === 'string' ? params.binaryPath : undefined).then(r => { + if (r.ok && r.session && lath.getMeta(restoredId) && !lath.isDying(restoredId)) { + updateSurfaceParams(restoredId, { session: r.session, wsPort: r.wsPort, binaryPath: r.binaryPath, cwd: r.cwd ?? cwd, nativeIdentity: r.nativeIdentity }); + } else if (r.session) closeAgentBrowserSession({ renderMode: currentRenderMode, ...r }); + }).catch(error => console.warn('[dormouse] could not restore browser provider:', error)); }; - platform.agentBrowserOpen(url, { headed }, lastAgentBrowserBinaryPathRef.current).then((res) => { + platform.agentBrowserOpen(url, { headed }, automationProvider(mode) === 'agent-browser' ? lastAgentBrowserBinaryPathRef.current : undefined).then((res) => { if (!res.ok || !res.session) { console.warn(`[dormouse] failed to swap iframe surface '${id}' to agent-browser:`, res.error ?? '(no session)'); // Nothing came up to bind: give the iframe back if the eager Surface // still exists, whether it is visible or minimized meanwhile. - restoreIframe(); + restorePrevious(); return; } - if (res.binaryPath) lastAgentBrowserBinaryPathRef.current = res.binaryPath; + if (res.binaryPath && automationProvider(mode) === 'agent-browser') lastAgentBrowserBinaryPathRef.current = res.binaryPath; const bound = { session: res.session, + cwd: res.cwd ?? cwd, + nativeIdentity: res.nativeIdentity, ...(res.wsPort !== undefined ? { wsPort: res.wsPort } : {}), ...(res.binaryPath !== undefined ? { binaryPath: res.binaryPath } : {}), }; @@ -1612,7 +1624,7 @@ export function Wall({ updateSurfaceParams(eagerId, bound); }).catch((err) => { console.warn('[dormouse] failed to swap iframe surface to agent-browser:', err); - restoreIframe(); + restorePrevious(); }); } }, @@ -1633,9 +1645,11 @@ export function Wall({ }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, lath, nav]); const contextPortLaunches = useRef(new Map>()); const openContextPort = useCallback(async (id: string, entry: PortUrlEntry, mode: PortMode): Promise => { - const platform = getPlatform(); - if (mode === 'system') { platform.openExternal?.(entry.url); return; } - const key = `${id}:${entry.port}:${mode === 'iframe' ? 'iframe' : 'agent'}`; + const native = getPlatform(); + const cwd = getTerminalPaneState(id)?.cwd?.path; + const platform = browserPlatform(mode, cwd); + if (mode === 'system') { native.openExternal?.(entry.url); return; } + const key = `${id}:${entry.port}:${mode === 'iframe' ? 'iframe' : automationProvider(mode)}`; const pending = contextPortLaunches.current.get(key); if (pending) { await pending; return openContextPort(id, entry, mode); } const operation = (async () => { @@ -1653,17 +1667,17 @@ export function Wall({ } if (mode !== 'iframe' && !platform.agentBrowserOpen) throw new Error('Agent browser is unavailable'); const created = createContentSurface({ minimized: false, reference, preserveSource: true, - params: { surfaceType: 'browser', renderMode: mode, url: entry.url, syncEngaged: true, contextPortKey: key }, title: hostPathDisplay(entry.url, true) }); + 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 (mode === 'iframe') return; - const result = await platform.agentBrowserOpen!(entry.url, { headed: mode === 'ab-popout' }, lastAgentBrowserBinaryPathRef.current); + const result = await platform.agentBrowserOpen!(entry.url, { headed: isPopout(mode) }, automationProvider(mode) === 'agent-browser' ? lastAgentBrowserBinaryPathRef.current : undefined); if (!result.ok || !result.session) { await closeSurface(created.value.id); throw new Error(result.error ?? 'Could not open agent browser'); } - if (result.binaryPath) lastAgentBrowserBinaryPathRef.current = result.binaryPath; - const binding = { session: result.session, wsPort: result.wsPort, binaryPath: result.binaryPath }; + if (result.binaryPath && automationProvider(mode) === 'agent-browser') lastAgentBrowserBinaryPathRef.current = result.binaryPath; + const binding = { nativeIdentity: result.nativeIdentity, cwd: result.cwd ?? cwd, session: result.session, wsPort: result.wsPort, binaryPath: result.binaryPath }; if (!lath.getMeta(created.value.id) || lath.isDying(created.value.id)) { closeAgentBrowserSession({ renderMode: mode, ...binding }); return; } updateSurfaceParams(created.value.id, binding); })(); diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 3267d5ec9..75b96abdc 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -1,9 +1,9 @@ +import { automationProvider, browserPlatform } from './browser-automation'; /** React view for the surface-scoped lifecycle in * `agent-browser-surface-controller.ts`; see docs/specs/dor-browser.md. */ import { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { clsx } from 'clsx'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; -import { getPlatform } from '../../lib/platform'; import { isEditableTarget } from '../../lib/dom'; import type { RenderMode } from './agent-browser-screen'; import { tabDisplayTitle } from './browser-url'; @@ -51,6 +51,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r const syncEngaged = params?.syncEngaged; // poppedOut is derived from the canonical renderMode the shell passes; fall // back to resolving it from params for a direct mount (tests) / legacy blob. + const cli = automationProvider(renderModeProp ?? params?.renderMode) === 'playwright' ? 'dor pw' : 'dor ab'; const seededMode = renderModeProp ?? resolveRenderMode(params); // The surface-scoped controller: get-or-create, keyed by surface id. Survives @@ -100,11 +101,11 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r viewport, updateParameters: (next) => paneWrite.updateParams(id, next), setTitle: (nextTitle) => paneWrite.setTitle(id, nextTitle), - requestIframeSwap: () => { + requestRenderSwap: (mode = 'iframe') => { // The iframe renderer is single-frame: only the active tab survives. // Warn + require a typed confirm when other tabs would be closed. - if (controller.snapshot().tabs.length >= 2) setPendingIframeSwap(true); - else actionsRef.current.onSwapRenderMode(id, 'iframe'); + if (controller.snapshot().tabs.length >= 2) setPendingRenderSwap(mode); + else actionsRef.current.onSwapRenderMode(id, mode); }, }); return () => handle.detach(); @@ -124,7 +125,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // Crossing to the single-frame iframe renderer closes all but the active tab; // when others are open the swap is gated behind a typed confirm (overlay below). - const [pendingIframeSwap, setPendingIframeSwap] = useState(false); + const [pendingRenderSwap, setPendingRenderSwap] = useState(null); const swapConfirmRef = useRef(null); // --- input forwarding (stream-native input_* messages) --- @@ -302,8 +303,8 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // Focus the swap-confirm overlay when it appears so it captures the typed // confirm/cancel keys (the pane's key-forwarder skips in-pane targets). useEffect(() => { - if (pendingIframeSwap) swapConfirmRef.current?.focus(); - }, [pendingIframeSwap]); + if (pendingRenderSwap) swapConfirmRef.current?.focus(); + }, [pendingRenderSwap]); // --- placeholder state (derived from the snapshot) --- @@ -316,13 +317,13 @@ 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 dor ab open `; + if (!streamPort) return `Waiting for browser session ${session} — run ${cli} open `; if (connectionLost || status?.connected === false) { - return `Browser session ${session ?? ''} ended — run dor ab open to restart it, or close this surface.`; + return `Browser session ${session ?? ''} ended — run ${cli} open to restart it, or close this surface.`; } if (!hasFrame) { return status && !status.screencasting - ? 'No page is open — run dor ab open ' + ? `No page is open — run ${cli} open ` : `Connecting to ${session ?? 'browser session'}…`; } return null; @@ -391,7 +392,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r
{!session || relaunching ? 'Opening the browser window…' : 'This browser is running in a separate window.'}
{session && !relaunching &&
- {getPlatform().agentBrowserBringToFront && ( + {browserPlatform(seededMode, params?.cwd).agentBrowserBringToFront && ( ; + className={`inline-flex h-6 ${fit ? 'min-w-0' : 'shrink-0'} items-center justify-center gap-1.5 rounded px-1.5 disabled:opacity-40 aria-pressed:bg-current/10 ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}; } function ContextCopyAction({ children, label, onCopy }: { children: ReactNode; label: string; onCopy: () => Promise }) { @@ -221,7 +224,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{PORT_ACTIONS.map(action => { const unavailable = action.needs && !p[action.needs] ? action.unavailable : null; - return void attempt(() => p.onPort(selected, action.mode))}>{action.icon}{action.text}; + return void attempt(() => p.onPort(selected, action.mode))}>{action.icon}{action.text}; })}
} diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index e5fa8b746..19cb072e7 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -152,6 +152,11 @@ const meta = { expect(box.left).toBeGreaterThanOrEqual(bounds.left); expect(box.right).toBeLessThanOrEqual(bounds.right); } + // A button that fits must not do it by spilling or wrapping its label past its own box. + for (const button of panel.querySelectorAll('button')) { + expect(button.scrollWidth, button.getAttribute('aria-label') ?? '').toBeLessThanOrEqual(button.clientWidth); + expect(button.scrollHeight, button.getAttribute('aria-label') ?? '').toBeLessThanOrEqual(button.clientHeight); + } const terminal = panel.querySelector('.bg-terminal-bg')!; expect(terminal.getBoundingClientRect().height).toBeGreaterThanOrEqual(64); canvasElement.dataset.contextCheck = 'passed'; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 689a989a9..759c29143 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1150, "docs/specs/standalone.md": 11500, - "docs/specs/terminal-context.md": 1050, + "docs/specs/terminal-context.md": 1100, "docs/specs/terminal-escapes.md": 3800, "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2200,