diff --git a/.gitignore b/.gitignore index 80768f5ca..1c79890b3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.cjs standalone/sidecar/agent-browser-host.cjs standalone/sidecar/remote-host.cjs +standalone/sidecar/tool-host.cjs standalone/sidecar/node_modules/ standalone/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index ef762d9b4..dea10fd87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Use one implementation map per spec: either an exhaustive `Files` / `Code Map` s - **`docs/specs/theme.md`** — Theme system: the two-layer CSS variable strategy, the consumed-token resolver, the terminal color contract, and the theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI staged onto every Dormouse terminal's `PATH`: bundling + env contract, `spawnAndCapture` rules for external binaries, control-socket plumbing, the Surface handle model, and the command set. - **`docs/specs/dor-browser.md`** — The unified browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, and the iframe proxy + CSP boundaries. Builds on the handle model in dor-cli.md. -- **`docs/specs/dor-tool.md`** — Dor Tools (design-stage): the `tool` Surface — a terminal and a browser on one Session spine — with its capability-gated verb model and OSC 367 contract. Only the capability gating is implemented. +- **`docs/specs/dor-tool.md`** — Dor Tools: the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs, the port scan that grows the browser, `dormouse.yml` identity, the repo-trust gate, and OSC 367. Behind the `dormouse.flags.tools` flag. - **`docs/specs/vscode.md`** — VS Code host layer: webview hosting, webview ↔ Workspace mapping, persistence ordering, theme integration, CSP, and the build/dogfood pipeline. The transport protocol it speaks lives in transport.md. - **`docs/specs/standalone.md`** — Standalone (Tauri) host layer: the Rust ↔ Node-sidecar bridge, boot sequence, AppBar, persistence, shutdown ordering, and the build/dev workflow. The transport protocol it speaks lives in transport.md. - **`docs/specs/auto-update.md`** — Standalone auto-update: check → user-approved download → install-on-quit, the Baseboard update notice, Windows sidecar teardown, and per-platform quit behavior. @@ -90,7 +90,7 @@ Specs are written ahead of the code on purpose: a new component's spec starts as - **Reservations.** When unbuilt design constrains present code — a reserved wire field, a reserved ref grammar, an additive-evolution guarantee — state that constraint in the body, marked `Reserved:`, pointing at the `## Future` item it serves. Test: if deleting the sentence would let someone break future compatibility today, it belongs in the body. - **Promotion is part of done.** Implementing a staged item is not finished until its text moves above the fold — rewritten from "will" to "is", with `Source of truth:` added — and the built portion is deleted from `## Future`. Never leave completed plan text (build orders, phase lists) below the fold; delete it — git history keeps the record. -The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, one implementation map per spec, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. It also ratchets file size: every spec, rationale file, and this file carries a word budget in `scripts/spec-word-budgets.json`, and growth past it fails the lint — cut, or raise the budget deliberately in the same PR. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. +The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, one implementation map per spec, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. It also ratchets file size: every spec, rationale file, and this file carries a word budget in `scripts/spec-word-budgets.json`, and growth past it fails the lint — cut, or raise the budget deliberately in the same PR. This file's budget is split — conventions prose and each spec-index line are capped separately, so adding a spec never costs another spec's routing line. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. Advisory spec/comment reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index a2898d35a..21ba2d39a 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -119,6 +119,12 @@ on `dor-lib-common`. It owns three concerns: argument containing a literal `%VAR%`** — `cmd.exe` expands it on the way through a `.cmd` shim, an unavoidable batch limitation. Today's forwarded arguments (URLs, selectors, the host's hardcoded `eval` scripts) carry none. +- **`git` is the second caller.** The host resolves a project's upstream for the + Dor Tools trust key (`docs/specs/dor-tool.md` -> Trust). `spawnAndCapture` + exposes no `cwd` and the sidecar's is `/` under a macOS `.app`, so the + directory travels in argv as `git -C ` — a host-resolved + project root, never a raw string off the wire. Both subcommands are + repository-local, so no credential prompt can block on the closed stdin. - **`windowsHide`.** cross-spawn runs `.cmd` shims through `cmd.exe`; without it each spawn flashes a focus-stealing console window — and the panel's screenshot loop spawns one per stream-frame pulse, so a live page would @@ -342,6 +348,13 @@ baseboard. `dor list` rows sort by the Workspace-stable `surface:N` ref, whose registry `Wall` owns and persists with the session, independent of Lath layout order. +`dor tool` runs a command as a Dor Tool — a Surface that grows a browser in +place once the command binds a port. It uses the `ensure` spawn path's +mechanics but **not** its command+cwd matching: a tool has an identity only if +a `dormouse.yml` entry gave it one. Source of truth: `dor/src/commands/tool.ts`, +help snapshot `dor/test/snapshots/help/tool.md`; behavior is owned by +`docs/specs/dor-tool.md`. + **Port enumeration is opt-in.** When the request sets `includePorts` (`dor list --ports` / `--port`), the host calls `PlatformAdapter.getOpenPorts(id)` (`docs/specs/dor-browser.md` → Dev-Server Chip) for each terminal Surface in diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 26b53ac13..b01a358fd 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -1,28 +1,31 @@ # Dor Tools -> Status: design. The `tool` Surface does not exist yet; the only implemented -> piece is the shared capability gating below. Everything else is under +> Status: the `tool` Surface is implemented, behind the `dormouse.flags.tools` +> localStorage flag (`lib/src/lib/feature-flags.ts`) — off by default, so +> nothing is designated a tool and no pane can transform. The glob table, +> `dor open`, reaping, and dehydrate/rehydrate remain under > [Future](#future). > See `docs/specs/glossary.md` for canonical Surface / Session / Pane > vocabulary. Builds on `docs/specs/dor-cli.md` (surface handles, the `ensure` -> spawn path) and `docs/specs/dor-browser.md` (render modes, the iframe proxy); -> this design subsumes the "plugin/backend target axis" staged in that spec's -> Future. +> spawn path) and `docs/specs/dor-browser.md` (render modes, the iframe proxy, +> the Dev-Server Chip port scan); this design subsumes the "plugin/backend +> target axis" staged in that spec's Future. **Pitch**: a Dor Tool is a console app that opens a web port. Dormouse frames it in a pane where the human and the agent both see it and both drive it — the human clicks, the agent sees the click; the agent types, the human sees the -typing. No SDK, no protocol: print one escape sequence, read one env var. +typing. No SDK, no protocol, and in the common case no cooperation: Dormouse +already watches the ports its Sessions bind. ## Capability gating Phase A of the ledger below is implemented, and nothing in it is -`tool`-specific, so it is documented where it belongs rather than restated -here: the capability model and its `hasTerminal` / `hasBrowser` predicates in -`docs/specs/glossary.md` → Panes and Surfaces, the `dor list --json` -`has_terminal` / `has_browser` row fields and the matching `has no terminal` / -`has no browser` failures in `docs/specs/dor-cli.md` → `dor list`. +`tool`-specific, so it is documented where it belongs: the capability model and +its `hasTerminal` / `hasBrowser` predicates in `docs/specs/glossary.md` → Panes +and Surfaces, the `dor list --json` `has_terminal` / `has_browser` row fields +and the matching `has no terminal` / `has no browser` failures in +`docs/specs/dor-cli.md` → `dor list`. Source of truth: `dor/src/commands/types.ts` (the `KIND_CAPABILITIES` table both predicates read, and `SURFACE_KINDS` derived from it so `--kind` parsing @@ -30,267 +33,391 @@ cannot drift), `dor/src/commands/list.ts`, `lib/src/components/wall/use-dor-control.ts` (`requireTerminalSurface` / `requireBrowserSurface`, the host-side gates that emit those failures). -What this spec still owes is the kind that has both — see -[The tool capability set](#the-tool-capability-set). +The kind that has both is [`tool`](#the-tool-capability-set). -## Future +## The tool capability set -**Scope: dor-tools** — what remains, staged, one phase per PR. (Phase A, the -capability refactor, is implemented; see -[Capability gating](#capability-gating).) - -- **B — `dor open`.** User-level table + dispatch only: entries resolve to a - terminal command (the `ensure`/`split` machinery) or an existing **browser - surface** pointing at a host-served viewer page (the iframe-proxy path). No - OSC, no atom, **nothing new persisted** — viewers are plain browser - surfaces, so C1 requires zero snapshot migration. The VS Code route (see - [The table](#the-table)) is complete here, permanently for v1. -- **C0 — OSC 367 + header chip.** Parse/strip/register/sanitize for the serve - verb, plus the inert header-chip affordance in ordinary terminals - (announcement lights a chip; clicking connects via the existing port-connect - flow). Ships standalone value — any announcing tool gets a clickable chip - before the atom exists — and exercises the entire security gate with minimal - UI surface. -- **C1 — the tool atom.** `dor tool`, announce-minted upgrade-in-place, - identity dedupe, the console toggle, `surfaceType: 'tool'`, kill/teardown - (forcing the general per-surface teardown hook `docs/specs/dor-browser.md` - already stages), args-only cold restore. Standalone runs the pipeline behind - a `dormouse.flags.tools` flag; `dor open` is re-plumbed onto the real path. -- **D1 — reaping without cooperation.** Idle-threshold reap + - rehydrate-from-args + `persist: "never"`. Covers every stateless tool with - no new API and no Windows question (a stateless tool can just be killed). -- **D2 — dehydrate/rehydrate.** The `367;dehydrate` verb + - `DORMOUSE_DEHYDRATE` per the contract below (designed day 1; the `dehydrate` - flag is reserved in the serve payload from C0). The Windows graceful-stop - answer is needed here only. -- **Later** — `ab-*` browser rendering: agent GUI-driving of a tool's browser - via the agent-browser render modes. The CLI mechanism it needed — - surface-handle addressing, `dor ab --surface surface:N ` — is shipped - (`docs/specs/dor-cli.md` → Agent-Browser Surface Addressing) and already - reaches any agent-browser-rendered Surface; what remains here is pointing it - at a `tool` Surface's browser. Pocket/remote browser - view (rides the browser-surface staging in `docs/specs/remote-api.md`; - reserve the kind on the wire now). The VS Code full pipeline. An in-pane - terminal/browser strip (decide against the glossary's reserved - multiple-Surfaces-per-Pane). A `boots: web` table hint if the terminal flash - grates. `--has terminal` / `--has browser` filters for `dor list`. A - pre-spawn dedupe fast path. - -### The tool capability set - -`tool` = terminal + browser, the third kind added to the live gating. Verbs -stay gated on the capability they need, exactly as glossary.md defines it, and -the browser verbs stay renderMode-gated as for browser Surfaces (an -iframe-rendered tool cannot be agent-driven). `kill` / `rename` stay universal. -Kinds remain **disjoint** for `dor list --kind`. +`tool` = terminal + browser, the third kind in the live gating. Verbs stay +gated on the capability they need, and browser verbs stay renderMode-gated (an +iframe-rendered tool cannot be agent-driven). `kill` / `rename` stay universal; +kinds remain **disjoint** for `dor list --kind`. - **Identity**: a tool Surface's id is its SessionId (I1 extends to tools). Capabilities and render modes change over its life without changing identity - — the tool counterpart of I10, and stronger than browsers have today. -- **Render swaps bypass `replaceSurface`.** A tool's browser is a param of - the tool's own leaf: swapping `iframe` ⇄ `ab-*` mutates `renderMode` in - place and never routes through the browser-surface replacement path. That is - what makes the invariant above true — the same gesture that replaces a - browser Surface's id (I10) merely updates a tool's params. + — the tool counterpart of I10, stronger than browsers have today. +- **Render swaps bypass `replaceSurface`.** A tool's browser is a param of its + own leaf: swapping `iframe` ⇄ `ab-*` mutates `renderMode` in place instead of + routing through the browser-surface replacement path, which is what makes the + invariant above true. - **Axes**: the tool column of the six-axis table reads terminal-column - semantics for its terminal and browser-column semantics for its browser. + semantics for its terminal, browser-column for its browser. - **Activity**: full machine via the PTY; WATCHING defaults off for tool-spawned commands (`lib/src/lib/watched-commands.ts` rules). -- **Untouched**: input to **either** capability touches — the first - browser-side interaction arms kill-confirm, so an unsaved scratch tool gets - the confirmation letter while an idle just-opened viewer dies silently. +- **Untouched**: input to **either** capability touches. A tool never takes the + untouched blank-shell kill or shell-replacement shortcuts: its command and + browser may already hold live resources before the first human input. -### OSC 367 +## Declaring tools + +A repo declares its tools in a `dormouse.yml` at its root: a name → entry map +whose only required field is the command. + +```yaml +tools: + storybook: + run: pnpm storybook + prespawn_dedupe: [storybook, $PROJECT_ROOT] +``` + +- **`run`** — typed into the spawned shell exactly as `dor ensure` types one. +- **`render`** — `iframe` (default) or `ab-screencast`. The repo declares the + renderer, not the tool: which one suits a tool is a Dormouse-side judgement, + and `ab-screencast` is what makes a tool agent-drivable, since browser verbs + stay renderMode-gated. **The Display modal never offers pop-out on a tool** — + there is no third renderer to land in, so the swap would only re-derive the + one it has. +- **`port`** — `announced` (default) or `auto`, deciding how a port is chosen + when the tool has not announced one; an announcement always wins over either. + See [Serving](#serving) for what each does. +- **`prespawn_dedupe`** — the dedupe key, evaluated before anything spawns (see + [Identity and dedupe](#identity-and-dedupe)). Optional; absence means no + dedupe. +- **`dormouse.yml` holds static facts; OSC 367 carries what changes at + runtime.** A title, or a key that changes when a scratch document is saved, + is [OSC](#osc-367), never a file field. +- **Never give `prespawn_dedupe` a second value shape.** `prespawn_*` is + reserved, and staged additions each take their own field name (rationale). +- **Must reject an unrecognized `$NAME` at parse**, never keep it as a literal + (rationale). Substitutions are a closed set: `$PROJECT_ROOT` (the directory + holding the declaring `dormouse.yml`) and `$CWD` (the caller's resolved PWD); + phase C adds argument substitution. +- **Must reject `$PROJECT_ROOT` in the phase-C user-global file**, where no + project root is defined. +- **Should warn on a repo-local key without `$PROJECT_ROOT`**, naming the file: + it dedupes across every checkout declaring that name. Warning, not error — a + repo-declared machine-wide singleton is legitimate. + +- **An unknown `prespawn_*` field is a parse error**, where an unknown ordinary + field only warns. Silently dropping a dedupe directive is the destructive + failure; failing to parse is the loud one. + +A bare scalar is one element (`prespawn_dedupe: clock`). + +Source of truth: `lib/src/host/tool-registry.ts` (parsing, substitution, key +rendering), `lib/src/host/tool-trust.ts` (discovery — the walk up to the +nearest file), `lib/src/host/tool-host.ts` (the entry both hosts install). The +repo's own `dormouse.yml` is pinned against the parser in +`lib/src/host/tool-registry.test.ts`. + +## Identity and dedupe + +**A tool has an identity if and only if it was given one.** No key is derived +from the command, the cwd, or anything else the host can see; an entry with no +`prespawn_dedupe`, and every `dor tool -- `, spawns a fresh Surface +every time (rationale). + +- **Namespacing is host-enforced.** Keys compare within the tool identity the + host resolved from the spawn; the declared list is scope inside that + namespace, and a key's first element is never trusted as a tool name. + Without this the runtime re-key of [OSC 367](#osc-367) is an impersonation + primitive. +- **Dedupe at spawn time only.** A key matching a live Surface means the new + spawn is redundant by construction, so it never starts: the survivor is + revealed and its handle reported with an `ensure`-style reuse note. +- **A runtime re-key never dedupes.** It re-labels its own Surface and nothing + else — killing either side of a late collision would destroy work. +- **Scope is a slot, not a convention.** Keys are lists so parallel worktrees + differ by `$PROJECT_ROOT` rather than by an author remembering to concatenate + one in (rationale). +- **A key match only reveals**, never transferring state, grants, or input; the + worst case for a spoofed key is a wrong pane getting focus. +- **A match whose command has exited is re-run in place**, keeping the pane's + position and scrollback, and reported as `adopted`. `ensure` stops matching a + dead command because it targets arbitrary shells that may be busy with + something else; a tool Surface is dedicated, so that ambiguity does not exist. +- **Races**: concurrent spawns serialize on the key; first wins. + +Source of truth: the `surface.tool` handler in +`lib/src/components/wall/use-dor-control.ts`; `resolveDedupeKey` in +`lib/src/host/tool-registry.ts`; `toolKeysEqual` in +`lib/src/components/wall/browser-surface.ts`. + +## Trust + +`dormouse.yml` is repo-controlled and its entries execute, so it is inert until +the repo is trusted. The phase-C user-global file needs none of this. + +1. **Keyed on the branch's upstream remote URL**, canonicalized, so every + worktree and clone of one repo shares a grant. A folder grant covers one + project root instead, for a repo with no resolvable remote or a checkout the + user wants scoped. Either key satisfies the check. +2. **Only a gesture in Dormouse's own chrome grants it** — a prompt in the + tool's own pane naming the command, never one rendered as terminal output. The + [naked-prompt test](#cli) signals human intent but is not a security + boundary (rationale). Same shape as the local-approval ceremony in + `docs/specs/remote-security-model.md`. +3. **Agents cannot grant trust.** `dor tool ` against an unapproved repo + creates the Surface and reports `pending`, never minimized — a pane the user + cannot see is a pane they cannot approve, so a requested `--minimize` is + applied after approval instead. A pending Surface is not persisted: it + restores as a plain terminal, since the grant it was asking for was never + made. Its pane shows what would run and waits; approval re-resolves the + entry, so the tool runs with the `render`, `port` and key its file + declares. **Nothing from the repo executes until a human chooses** — no PTY is + spawned, so not even a shell starts. **Declining closes the pane and records + nothing** (rationale). +4. **Anything `prespawn_*` is behind the same gate**, since it executes — the + natural implementation order, probe-then-prompt, is backwards. +5. **The phase-C glob table stays user-global and may only name user-global + tools.** Implicit dispatch reaching repo-local entries is the + `dor open README.md`-in-a-malicious-repo attack. +6. **Never content-hashed.** A `dormouse.yml` that changes under a granted key + does not re-prompt (rationale). +7. **The upstream comes from the repo and is not verified.** `.git/config` is + repo-controlled, so a directory shipping its own `.git` inherits whatever + grant its claimed URL has. **Accepted risk** — cloning is unaffected, since + there the user chose the URL (rationale). +8. **Must serialize grants across host processes** and merge each against the + latest global trust file; concurrent windows cannot overwrite decisions. + +Source of truth: `lib/src/host/tool-trust.ts` (the record and its two key +kinds), `lib/src/host/git-upstream.ts` + `lib/src/host/git-remote-url.ts` (how a +project resolves to an upstream key), and +`lib/src/components/wall/ToolApproval.tsx` (the only grant path). + +## Serving + +A tool's browser appears when Dormouse learns the tool is serving. Two triggers +feed one internal upgrade path; the atom does not care which fired. + +- **The port scan is the primary trigger** and the only one correct under + contention: it reports the port actually **bound**, where an announcement + states intent (rationale). Already shipped for the Dev-Server Chip, scanning + a Session's own process tree. +- **OSC 367 is the disambiguator, never the trigger.** It names *which* of a + multi-port tool's ports to frame, plus ssh transparency, a name, and a + runtime re-key. The hint names the port; the scan supplies the number. +- **`port: announced` frames nothing without OSC 367**; `port: auto` autobinds. +- **Autobind never chooses among ports.** Exactly one bound port is framed; + **two or more frames nothing** and the pane shows the conflict where the + browser would have gone (rationale). +- **Autobind waits for the port set to settle** — one unchanged tick — before it + commits, since ports appear one at a time during boot (rationale). A changed + OSC 367 port re-points a live browser; an unchanged one never overrides + URL-bar navigation. Accepted limit: an unannounced port opening after settle + is not noticed. +- **`dor tool -- ` autobinds**, having nowhere to declare otherwise; + a declared tool opts in with one line. +- **Upgrade requires a tool-designated Session with its spawned command still + in the foreground** (see [Security](#security)). + +**Reserved:** a tool's URL is derived, never restored verbatim (see +[Persistence and hosts](#persistence-and-hosts)) — a precondition for +`prespawn_port` in the scope **dor-tools** [Later](#future), where Dormouse +picks a free port and exports +`DORMOUSE_TOOL_PORT`, so `storybook dev -p ${DORMOUSE_TOOL_PORT:-6006}` cannot +collide across worktrees. It supplements the scan rather than replacing it. + +Source of truth: `lib/src/components/wall/use-tool-serving.ts` (the trigger, +the renderer split, and the agent-browser session binding), +`listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`, +`lib/src/lib/tool-announce-store.ts`. + +## Lifecycle + +**Spawn** — a shell-hosted PTY using the `ensure` spawn path's mechanics +(`dor/src/commands/ensure.ts`: prompt-wait typing, per-shell quoting via +`dor/src/commands/shell-quote.ts`, command-exit tracking) but **not** its +command+cwd matching. Terminal front from spawn; a command that never serves is +a terminal running a TUI, which is a complete outcome. + +**Serving** → the Surface **grows a browser in place**: no replacement, no ref +transfer, no new id — params gain the browser and `surfaceType` flips by +derivation. The pane flips to the browser, terminal behind the header's +far-left chip. Accepted: a fast tool flashes its terminal for ~100ms. + +**Command exit** → the browser retires and the pane flips back to a prompt +above the tool's dying words; re-running revives it on the same Surface. A port +conflict retires with it, so a re-run gets a fresh verdict. +**Kill** → universal, reaping the process and the browser's resources. + +**`surfaceKindFromParams` must test for a tool before it tests for a browser**, +because a serving tool also carries a `renderMode`. The compiler cannot force +that edit — a boolean-derived return type-checks against a widened +`SurfaceKind` — so it is pinned by +`lib/src/components/wall/tool-surface.test.ts`. + +Source of truth: `lib/src/components/wall/ToolPanel.tsx` (both halves mounted, +visibility flipped), `ToolPaneHeader.tsx` (the leading chip plus the delegated +header), `isToolParams` / `toolFace` in `browser-surface.ts`, +`toolLeafMeta` + `shouldParkOnMinimize` in `lath-wall-engine.ts`. + +## CLI + +- **`dor tool -- `** — designate an arbitrary command as a tool. No + key, always a fresh Surface; distinct from `dor split` because it arms the + [serving](#serving) trigger. +- **`dor tool `** — run a `dormouse.yml` entry with whatever + `prespawn_dedupe` it declares. +- **Always splits focus-neutrally** and returns a handle. Taking over the + calling pane when a human types the invocation alone at a prompt is designed + but not built — see [Future](#future). +- **A keyed invocation that matches reveals and reports**, in both placements, + so the calling pane never appears to do nothing. +- `dor list`: rows report `kind: tool` with `render_mode`; JSON carries command + + cwd + url. The location column shows the cwd, pending the announce name. + +Source of truth: `dor/src/commands/tool.ts` and its help snapshot +`dor/test/snapshots/help/tool.md`; `surface.tool` in `dor/src/protocol.ts`. + +## OSC 367 `DOR` on a phone keypad. Verb-multiplexed (the OSC 633 pattern): one registry entry, extensible without burning numbers. Tools emit ST; the parser accepts BEL. Registered in `docs/specs/terminal-escapes.md`, parsed and stripped at the PTY data boundary (`lib/src/lib/terminal-protocol.ts`), replay-filtered like -the other reports, payload sanitized and size-capped under the same rules +the other reports, sanitized and size-capped under the rules `docs/specs/alert.md` applies to OSC 9/99/777. ``` -ESC ] 367 ; serve ; {"port":4242,"name":"…","identity":"…","dehydrate":true,"persist":"respawn","v":1} ESC \ +ESC ] 367 ; serve ; {"port":4242,"name":"…","key":["…"],"dehydrate":true,"persist":"respawn","v":1} ESC \ ESC ] 367 ; dehydrate ; {"v":1, …} ESC \ ``` -- `serve` — the announcement. `port` (host derives - `http://localhost:/`), optional `name` (feeds the existing - title-candidates channel of `docs/specs/terminal-state.md`; priority stays - user pin > announce name > command), optional `identity` (dedupe key, below), - `dehydrate` capability flag, `persist` restart policy (`respawn` default | - `never`), contract version. **Re-emittable, last-write-wins** — a scratch - tool that saves re-announces with its file as identity. -- `dehydrate` — emitted on the graceful-stop signal; captured, size-capped, - stored in the pane's persisted params. -- **No third verb, ever.** Titles are OSC 0/2, progress is OSC 9;4: the - existing escape registry is the rest of the API. The moment a `progress` or - `title` verb exists, tools have grown a protocol and the pitch is false. -- Transport: ssh-transparent (the reason this is an OSC, not a control-socket - call — the socket does not exist over ssh); tmux swallows unknown OSCs - without `allow-passthrough` (tool-author docs, one line). Safe to emit - unconditionally — well-behaved terminals drop unknown OSCs, so no capability - sniffing is needed; checking `DORMOUSE_SURFACE_ID` is an optimization only. +- `serve` — refines what the scan found, never mints a tool. `port` names which + port to frame; `name` is **reserved**: parsed, + sanitized, and recorded, but nothing consumes it yet — it will feed the title + candidates of `docs/specs/terminal-state.md` (priority user pin > announce + name > command), see [Future](#future); `key` re-keys under the host's namespace; `dehydrate` capability + flag; `persist` (`respawn` default | `never`); contract version. + **Re-emittable, last-write-wins.** +- `dehydrate` — emitted on graceful stop; captured, size-capped, stored in the + pane's persisted params. +- **Never add a third verb.** Titles are OSC 0/2, progress is OSC 9;4; the + existing escape registry is the rest of the API. +- **Safe to emit unconditionally** — well-behaved terminals drop unknown OSCs, + so checking `DORMOUSE_SURFACE_ID` is an optimization only. ssh-transparency is + why this is an OSC and not a control-socket call; tmux needs + `allow-passthrough` (tool-author docs, one line). +- No replay filter: 367 elicits no response, and replaying the hint after a + reconnect is what restores it. - Before freezing: sweep xterm ctlseqs and the iTerm2/kitty/WezTerm/ConEmu - private ranges to confirm 367 is clean. Runners-up: 3676 (`DORM`), 4242. - -### Lifecycle - -**Spawn**: shell-hosted PTY through the `ensure` spawn path -(`dor/src/commands/ensure.ts` semantics: prompt-wait typing, per-shell quoting -via `dor/src/commands/shell-quote.ts`, command-exit tracking). Terminal -front from spawn — startup logs beat any spinner, and a command that never -announces is simply a terminal running a TUI: a complete outcome, not a -degraded one. A "TUI tool" is a registry entry whose command never announces. - -**Announce** → the same Surface **grows a browser** in place: no replacement, -no ref transfer, no new id — params gain the browser and `surfaceType` flips -by derivation. The pane flips to the browser; the terminal sits behind a -toggle on the header's far-left chip. Accepted: a fast tool flashes its -terminal for ~100ms; the flip animation makes it read as teaching the -terminal-plus-browser pairing. - -**Command exit** → the browser is retired and the pane flips back to the -terminal — a shell prompt above the tool's dying words, the correct debugging -posture. Re-running the command re-announces and revives the browser on the -same Surface. - -**Kill** → universal; reaps the process and the browser's backing resources. - -### Identity and dedupe - -Identity is computed by the party that understands it — the tool. The host -cannot know that `README.md`, `./readme.md`, and a symlink are one document, or -that a diagram editor is ephemeral until saved and *becomes* its save-file -afterward. - -- **Scope**: dedupe matches on *(tool name as the host knows it from the - spawn)* × *(identity string from the OSC)*. The payload cannot claim to be a - different tool. Identityless tools are never deduped — scratch semantics. -- **On match**: the new spawn is redundant — graceful-stop it, tear the pane - down through the existing untouched-kill path (no confirmation; untouched by - construction), reveal the survivor, and report the survivor's handle with an - `ensure`-style reuse note. -- **Races**: concurrent spawns serialize at announce; first wins. -- **Containment**: an identity match only ever *reveals* a surface — it never - transfers state, grants, or input. Worst case for a spoofed identity is a - wrong pane getting focus. -- **Blessed pattern**: announce-and-let-Dormouse-dedupe. A tool doing VS - Code-style internal forwarding (second invocation hands off and exits) looks - to Dormouse like a failed tool; warn against it. + private ranges. Runners-up: 3676 (`DORM`), 4242. + +Source of truth: `lib/src/lib/tool-announce.ts`, `lib/src/lib/osc-sanitize.ts` +(shared with OSC 9/99/777), `lib/src/lib/tool-announce-store.ts`, and the `367` +arm of `lib/src/lib/terminal-protocol.ts`. The harness's own announcement is +pinned by `standalone/scripts/dev-agent-browser-announce.test.mjs`. + +## Security + +Three gates, one per actor: + +1. **Repo-controlled config executes only after a human approves the repo** — + see [Trust](#trust). +2. **Only a tool-designated Session upgrades in place**, designation being the + `dor tool` spawn. Elsewhere the [serving](#serving) trigger is ignored and + the announcement lights the inert Dev-Server Chip, whose click is the + gesture that connects. **Output alone never creates surfaces.** +3. **Upgrade requires the spawned command to still be the foreground process**, + so an exited tool's pane cannot be re-pointed by whatever runs next. + +**Accepted risk — content-driven announce inside a blessed tool.** A tool +rendering hostile bytes (a pager on a malicious file) passes the foreground +gate, so embedded bytes can name an attacker-chosen port and re-point the +browser at a service already listening locally, under the tool's name. The +residual is a mislabeled view of the user's own service, inert without further +gestures (rationale). Escalations if that changes: gesture-gate re-announces +that move the port, or constrain the framed port to the session's process tree +— not the default, since it breaks tools wrapping double-forking daemons. + +## Persistence and hosts + +`PersistedSurfaceType` includes `'tool'`. Its `PersistedPane` row carries the +command plus stable tool metadata (name, declared renderer and port strategy, +and optional key); cwd remains the pane's normal field. The Lath leaf carries +the equivalent render params. Because `'tool'` is a new type rather than an +edit to an existing one, no snapshot migration is required. + +**The URL is never persisted.** A tool's port is whatever it bound this time, +so the URL is re-derived from the [scan](#serving) after respawn. A restored +tool is a terminal running its command until it serves again — the same state a +cold spawn passes through. + +The dehydrated payload is in-session state, not persisted params. Cold restore +follows each host's session-restore story: `persist: "never"` rows drop silently +and the default respawns from bare args. Remote: the terminal rides protocol-v1 +as-is; the browser inherits the staged browser-surface gap. + +**`dor tool` is never routed to a native editor** — a verb returning a handle +on one host and a note on another is one command with two types (rationale). +Handing a target to the host's editor is a separate additive verb. + +Source of truth: `PersistedSurfaceType` in `lib/src/lib/session-types.ts`; +`toolControl` in `lib/src/lib/platform/types.ts` with its host implementations +(`vscode-ext/src/tool-host.ts`, and `tool_control` in +`standalone/src-tauri/src/lib.rs` bridging to `standalone/sidecar/main.js`). + +## Future + +**Scope: dor-tools** — what remains, staged, one phase per PR. The atom (both +`dor tool` forms, `dormouse.yml`, trust, the serving trigger, OSC 367, and +`ab-*` rendering) is implemented and described above. + +- **C — glob table + `dor open`.** The user-global tools file, glob rules + (pattern → tool name), `dor open ` as sugar over `dor tool`, argument + substitution in `prespawn_dedupe` so per-target viewers do not collapse into + one pane, and the loopback file/viewer endpoint a local *file* needs (the + iframe proxy instruments only `http://` upstreams). +- **D1 — reaping without cooperation.** Idle-threshold reap + + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, + no Windows question. +- **D2 — dehydrate/rehydrate.** The `367;dehydrate` verb + + `DORMOUSE_DEHYDRATE`; the `dehydrate` flag is reserved in the serve payload + from the shipped `serve` payload. The Windows graceful-stop is needed here + only. +- **Pane take-over.** `dor tool` typed alone at a prompt should run in that + pane rather than splitting — typing a command at a prompt is how a terminal + works. The gate is three conditions the host can already read (sole command on + the OSC 633 line, pane at a prompt, pane not already a tool); what it needs is + the handshake, since `dor` is itself the foreground process when it answers, + so the command can only be typed once its own shell returns to a prompt. +- **The announced `name`.** Wire the reserved [OSC 367](#osc-367) `name` into + the title-candidates channel and `dor list`'s location column. +- **Later** — `prespawn_*` beyond the dedupe literal: a computed key, and + `prespawn_port`. Pocket/remote browser view (rides the browser-surface + staging in `docs/specs/remote-api.md`; reserve the kind on the wire now). The + VS Code pipeline. An in-pane terminal/browser strip (decide against the + glossary's reserved multiple-Surfaces-per-Pane). A `boots: web` hint if the + terminal flash grates. `--has terminal` / `--has browser` for `dor list`. ### Dehydrate and rehydrate For tools announcing `dehydrate: true`. Reap on an idle threshold while -`Doored` / `Hidden` — including Surfaces of an inactive Workspace — never on -the minimize itself (reattach must not cost a boot every time), or under -memory pressure. The headline use case is Workspaces, not shutdown: a user can -keep many tools across many Workspaces, and an inactive Workspace full of -dehydratable tools drops to zero processes — relieving exactly the -parked-surface pressure the workspaces rollout projects -(`docs/specs/layout.md` Stage 4; `MAX_PARKED_SURFACES` in +`Doored` / `Hidden` — including an inactive Workspace's Surfaces — **never on +the minimize itself** (reattach must not cost a boot) or under memory pressure. +The headline case is Workspaces: an inactive one full of dehydratable tools +drops to zero processes, relieving the parked-surface pressure the workspaces +rollout projects (`docs/specs/layout.md` Stage 4; `MAX_PARKED_SURFACES` in `docs/specs/tiling-engine.md`). -**This is an in-session mechanism.** The dehydrated payload lives with the -running host. Whether it survives a full host quit/restart follows each host's -session-persistence story (`docs/specs/transport.md`); this spec takes no -position on quit/restore — the Workspace case alone justifies the mechanism. - -The flow: - -1. Host sends the graceful-stop signal (grace window). -2. Tool emits `367;dehydrate;{json}` on the way out; host captures and - persists it. -3. Rehydrate = respawn the command with `DORMOUSE_DEHYDRATE` in the env, - rendered per-shell by the shell-quote module. - -Degradation tiers, Lath-restore-token style: dehydrated state → bare args → -error. **Args-only restart is the mandatory floor; the dehydrate payload is -fidelity, never correctness.** The payload is small, versioned JSON — never a -document (the standalone session blob has bloated storage before). A hung tool -cannot block anything: request, grace, kill anyway, fall back to args. Open -question: the Windows graceful-stop (no SIGTERM to console apps; candidates: -an opt-in input sequence, or dehydrate-on-every-announce as the Windows -fallback). - -### CLI - -- `dor tool [args]` — launch a registered tool by name. **Fresh - instance every time**; there is no `--key` — identity lives in the OSC. -- `dor open ` — sugar over `dor tool`: glob table → tool name → render - the template with the resolved absolute target → same launch path. Reuse - arrives via the standard identity convention: target-dispatched tools - announce `realpath(target)`. -- **cwd**: the caller's PWD resolves the argument (existing `--cwd` - machinery); the session's cwd is `dirname(target)` (or the target directory), - falling back to caller PWD only when the tool has no path target. Templates - render absolute paths, so the rendered command and cwd are deterministic - functions of the target — reuse and cold restore both become - caller-independent, and relative assets (a markdown image whose src is - `diagram.png`) resolve for the tool itself. -- `dor list`: rows report `kind: tool` with the browser's `render_mode`; the - location column shows the **target**, else the announce name (cwd and - localhost URLs are plumbing); JSON carries target + cwd + url. - -### The table - -User-level config **only** — a project-local table is arbitrary code execution -via `dor open README.md` in a malicious repo. Host-resolved, not CLI-resolved: -one source of truth, reachable by GUI gestures (file drop) as well as the CLI. -Two sections: named tools (name → command template) and glob rules (pattern → -tool name). Entries may dispatch to plain terminal commands (`*.*` → a pager) — -the atom is minted by the announcement, not by the table. - -Hosts: VS Code v1 routes `dor open` to the native editor (an in-pane md/code -viewer competes with the editor, which the native-first principle forbids) and -reports which route it took — an agent in VS Code loses sight of what it -opened, which is accepted for v1 and is the eventual argument for the full -pipeline there. - -### Security - -Auto-upgrade on announce is honored **only in tool-pipeline sessions and only -while the spawned command is the foreground process** (command-exit tracking -knows). Everywhere else — ordinary terminals, post-exit — the announcement -lights an inert affordance: a chip in the pane header, the Dev-Server Chip -pattern (the declared upgrade of the port scan), and clicking it is the user -gesture that connects. Output alone never creates surfaces. +**This is an in-session mechanism.** The payload lives with the running host; +whether it survives a host quit follows each host's session-persistence story +(`docs/specs/transport.md`). The flow: host sends the graceful-stop signal → +tool emits `367;dehydrate;{json}` on the way out → rehydrate respawns with +`DORMOUSE_DEHYDRATE` in the env. -**Accepted risk — content-driven announce inside a blessed tool.** A tool -rendering hostile bytes (a pager on a malicious file) passes the foreground -gate — the pager *is* the foreground process — so embedded bytes can announce -an attacker-chosen localhost port and re-point the browser at a service -already listening on the user's machine, under the tool's name. This is -accepted, deliberately: the blast radius is the dedupe containment applied to -ports — an announce only ever reveals/frames, it never transfers input -authority, grants, or state; the iframe proxy dials upstream as a fresh client -with no browser cookie authority; and the link-local/cloud-metadata SSRF guard -stands regardless. The residual is a mislabeled view of the user's own local -service, inert without further user gestures. If field reports change this -calculus, the escalations are gesture-gating re-announces that change the -port, or constraining the framed port to one owned by the session's process -tree — the latter is not the default because it would break tools that wrap -double-forking daemons (agent-browser-style), whose port the process-tree scan -cannot see. - -### Persistence and hosts - -`PersistedSurfaceType` gains `'tool'`; params -`{command, args, cwd, renderMode, url?, identity?, persist?}` -(`docs/specs/transport.md` owns the persisted shapes; -`lib/src/lib/session-types.ts`). The dehydrated payload is in-session state, -not part of the persisted params (see -[Dehydrate and rehydrate](#dehydrate-and-rehydrate)). Cold restore follows each -host's session-restore story: where sessions restore, `persist: "never"` rows -are dropped silently (a clock, a calculator) and the default respawns from bare -args — the args-only floor is what makes taking no position on quit/restore -safe. Remote: the terminal is a Session and rides protocol-v1 as-is; the -browser inherits the staged browser-surface gap. +**Args-only restart is the mandatory floor; the payload is fidelity, never +correctness.** Degradation is Lath-restore-token style — dehydrated state → +bare args → error. Small versioned JSON, never a document. A hung tool blocks +nothing: request, grace, kill anyway, fall back to args. ### Open questions -Beyond the two raised inline (the [OSC 367](#osc-367) collision sweep, the -Windows graceful-stop): the dehydrate idle-threshold default; whether `persist` -belongs in the announce or the table (currently the announce — self-knowledge, -like identity); the final marketing noun ("Dor Tools" carries the -LLM-tool-use collision-avoidance; the spec says "tool" throughout). +The [OSC 367](#osc-367) collision sweep before the contract is frozen (xterm +ctlseqs plus the iTerm2/kitty/WezTerm/ConEmu private ranges; runners-up 3676 +and 4242); the Windows graceful-stop for D2; the dehydrate idle-threshold +default; whether `persist` belongs in the announce or the file (currently the +announce — self-knowledge, like a runtime re-key); the final marketing noun +("Dor Tools" carries the LLM-tool-use collision-avoidance; the spec says +"tool" throughout). diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md new file mode 100644 index 000000000..7a10440c4 --- /dev/null +++ b/docs/specs/dor-tool.rationale.md @@ -0,0 +1,51 @@ +# Dor Tools — Rationale + +> Informative evidence for [dor-tool.md](dor-tool.md), keyed by its headings; nothing here is normative. + +## Declaring tools + +**Why `prespawn_*` spends a field name per addition instead of overloading one.** The tempting compaction is a single `prespawn_dedupe` that means a literal key when it is a list and a command to run when it is a string. YAML defeats it: authors habitually collapse a one-element sequence to a scalar, so `prespawn_dedupe: storybook` is exactly as natural a spelling of `["storybook"]` as it is of "run `storybook`". Guessing wrong in that direction *runs the tool* to answer a question about the tool — the same hazard that keeps a probe off any command not written to be probed. Distinct field names cost one word and remove the guess. + +**Why an unknown `$NAME` is an error rather than a literal.** The two failure modes are not symmetric. Forgetting the field entirely is loud: two tools start, fight over a port, and one visibly fails. A `$PROJECTROOT` typo kept as a constant string is silent, and it makes every checkout on the machine share one key — so the second worktree's tool kills the first. Parse-time rejection converts the silent destructive case into a startup error. + +## Identity and dedupe + +**Why no key is derived from the command.** Three independent reasons, any one sufficient: + +- Command strings are not stable keys. `pnpm storybook`, `pnpm run storybook`, and `pnpm storybook --quiet` are three strings for one tool, so a derived key would dedupe depending on spelling — and an agent generating the string will not spell it identically twice. Dedupe that fires unpredictably is worse than dedupe that never fires. +- `dor ensure` already *is* command+cwd idempotency. Absorbing it into `dor tool` would be a second spelling of a shipped command with fuzzier semantics. It would also inherit `ensure`'s hard dependency on OSC 633 shell integration, which fails outright on a shell without it; keeping it off the base path lets `dor tool` work there. +- Declaring a tool to get a short name is a different intention from wanting one instance of it. Coupling them means editing the config silently changes runtime behavior, and a hand-written key documents its own scope to the next reader where an implicit one cannot. + +**Why keys are lists rather than strings.** Parallel worktree development is the case that decides it. A tool-declared identity *string* — the obvious design, and what an earlier draft of this spec specified — has every checkout announcing `storybook`, so Dormouse treats the second worktree's server as a redundant spawn and kills it. A list makes scope a slot that `$PROJECT_ROOT` fills, rather than something an author must remember to concatenate into a string. + +**Why a runtime re-key cannot dedupe.** Spawn-time dedupe is safe because the loser is redundant by construction: it has done nothing yet. That stops being true once a key can change. A scratch document edited for ten minutes and then saved over a path another pane already holds is a genuine collision between two Surfaces that both hold work, and killing either destroys it. Re-labelling is the only resolution that cannot lose data. + +## Trust + +**Why the approval gesture must live in Dormouse's chrome.** The naked-prompt test reads the pane's own OSC 633 command line, which is a good signal of human intent and a poor security boundary: an agent holding the control token can `dor send` keystrokes that are byte-identical to typing. A dialog rendered as terminal output is forgeable the same way. A click in Dormouse's own UI is not reachable from inside a PTY, which is why the remote pairing ceremony uses the same shape. + +**Why the key is the upstream rather than the path.** Path-keyed trust asks once per checkout, and worktree-heavy work makes that constant: `dormouse` and `dormouse.phase-b` are the same code from the same place, and approving each separately teaches nothing. The upstream URL is the identity the user actually reasons about. The cost is that the answer comes from `.git/config`, which the directory itself controls and nothing can verify — so a directory shipping its own `.git` can claim a URL you have granted. That is accepted rather than mitigated: closing it would need either a network round-trip (which proves the URL exists, not that this checkout came from it) or a nominated-parent-directory setting, and the vector requires being handed a directory rather than cloning one, at which point running its build tooling is already the larger exposure. + +**Why declining records nothing.** A remembered denial was there so a hostile repo could not re-ask on every invocation. Once the prompt lives in a pane the user closed deliberately, that pressure is gone — and a persisted denial keyed on an upstream would silently disable tools across every worktree of a repo, with nothing in the product able to list or revoke it. Re-asking is recoverable; a permanent invisible block is not. + +**Why trust is not content-hashed.** Hash-pinning a `dormouse.yml` re-prompts on every edit and every `git pull` that touches the file. On a repo whose maintainer edits it regularly that is a dialog seen daily, and a dialog seen daily is answered reflexively — the control stops controlling anything. The residual, a trusted repo that later gains a hostile entry, is exposure already accepted from `package.json` scripts, `.vscode/tasks.json`, and git hooks in that same repo. The gate exists for first contact, which a key over the whole repo already covers. + +## Serving + +**Why two ports is a refusal rather than a tie-break.** Dormouse already declines to guess among several ports everywhere else: the Dev-Server Chip renders only when *exactly one* terminal owns a port, and `surface.resolveOpen` — behind `dor iframe surface:N` — fails and lists the candidates. The tool path was the outlier, silently taking the numerically lowest. A tie-break has no honest rule to apply: lowest-numbered is arbitrary, and first-bound is not even observable from scan snapshots. Refusing is the only answer that cannot be quietly wrong, and it costs the user one config line to resolve. + +**Why the conflict is shown in the browser's place.** With no port framed there is nothing in the pane's second half, and the pane would otherwise sit on its terminal with no indication that Dormouse had decided anything. Putting the explanation exactly where the browser would have appeared makes the absence self-describing, and the header chip still flips back to the terminal. + +**Why autobind waits a tick.** Ports appear one at a time during boot. The standalone harness binds its dev bridge (1422) before vite (1420), so a scan landing between the two sees only the bridge; framing on first sighting would keep it. Waiting for one unchanged tick costs ~1.5s and catches every boot-time case. After serving, only a changed OSC port triggers a scan; remembering the last applied announcement prevents the poll from undoing URL-bar navigation. Scanning every framed tool forever would pay a shell-out per tool per tick to catch a case that essentially only happens at startup. + +**Why the scan outranks the announcement.** An announcement states intent; the scan states the result, and the two diverge exactly when it matters. Storybook launched with `-p 6006` in a second worktree auto-increments to 6007, so a hardcoded announcement would frame a port belonging to the *other* checkout. Vite under `strictPort: true` (`standalone/vite.config.ts`) does not start at all. The repo's existing answer to contention, `scripts/free-dev-port.mjs`, kills whatever holds the port. A trigger built on the announcement inherits all three problems; one built on the scan inherits none, and works on software nobody patched. + +**What the announcement is still needed for.** Multi-port tools. `pnpm dev:standalone:ab` binds vite, the dev bridge, and the sidecar's control socket, and no scan can guess which one to frame. ssh is the other case: the control socket does not exist across it, and neither does the host's view of the remote process tree. + +## Security + +**Why the content-driven announce risk is accepted.** The blast radius is the containment rule applied to ports: an announce reveals and frames, never transferring input authority, grants, or state. The iframe proxy dials upstream as a fresh client with no browser cookie authority, and the link-local/cloud-metadata SSRF guard stands regardless. Two properties of this design narrow it further than an announce-triggered one: the scan supplies the port, so an announced port that nothing bound frames nothing at all, and a runtime re-key cannot dedupe, so it cannot reach another pane. + +## Persistence and hosts + +**Why `dor tool` is not routed to the VS Code editor, despite native-first.** Every other `dor` verb returns a handle the caller can address afterwards. A verb that returns a handle on standalone and a "told the editor" note on VS Code is one command with two return types: `dor open x.md && dor read surface:N` would work on one host and silently no-op on the other, which is worse for an agent than the command not existing. Native-first governs chrome and theming; Dormouse already renders browser surfaces inside VS Code, as does the built-in Simple Browser. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 0054cd971..de22a9a65 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -113,7 +113,7 @@ When a session is minimized, it becomes a **door** on the baseboard, showing the - **Click** (any mode) or **Enter** (command mode): restore the session into the content area as a pane and enter passthrough; the terminal gets focus immediately. - **m** / **d** (command mode): restore into a pane but stay in command mode — the inverse of `m`/`d` on a pane, making them toggles. -- **x** / **k** (command mode): restore into a pane, then show the kill confirmation (an untouched Surface is killed outright — see [Kill confirmation](#kill-confirmation)). +- **x** / **k** (command mode): restore into a pane, then show the kill confirmation (an untouched plain terminal is killed outright — see [Kill confirmation](#kill-confirmation)). - **Arrow keys** navigate to and between doors (see [Spatial navigation](#spatial-navigation)). A reattach that stays in command mode defers its follow-up (focus, kill, replace) to `requestAnimationFrame` and skips it if the pane vanished in between. @@ -185,7 +185,7 @@ Pressing `x`/`k` (or clicking the kill button, which first leaves passthrough) s **Confirmation must be staged in a ref synchronously, not only in React state** — a second confirm keydown arriving before React flushes would otherwise pass the guard and kill twice (`lath.isDying` is the second line of defense). Source of truth: `acceptKill` in `lib/src/components/Wall.tsx` and the modal in `lib/src/components/KillConfirm.tsx`. -**Untouched sessions skip this confirmation.** A newly spawned shell starts `untouched: true`; the first user-originated PTY input flips it to false. Inputs that count: printable keys, Enter, control keys, keyboard CSI such as arrows/history, paste, and file-drop path insertion. Replay-shaped terminal reports and stripped mouse-report-only input do not count (the untouched gate checks `inputIsReplayTerminalReport`; the broader synthetic-report check gates input recording and alert attention, not this flag). Killing an untouched pane runs the normal kill animation/dispose path immediately; killing an untouched door first reattaches it only far enough to reuse the same pane removal path, then kills it with no overlay. +**Only untouched plain terminals skip this confirmation.** A new shell starts `untouched: true`; the first user-originated PTY input flips it to false. Inputs that count: printable keys, Enter, control keys, keyboard CSI such as arrows/history, paste, and file-drop path insertion. Replay-shaped terminal reports and stripped mouse-report-only input do not count (the untouched gate checks `inputIsReplayTerminalReport`; the broader synthetic-report check gates input recording and alert attention, not this flag). Killing one runs the normal kill animation/dispose path immediately; killing its door first reattaches only far enough to reuse that path, then kills it without an overlay. Tools track untouched but never take this fast path; they may own live resources before input (`docs/specs/dor-tool.md` → "The tool capability set"). ## Selection overlay @@ -294,7 +294,7 @@ For a terminal Surface the pane ID is its session ID. `TerminalPane` calls `getO - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with the saved cwd. It replays no transcript — scrollback is not persisted (`docs/specs/transport.md` → "What is persisted"). Used on cold start from a saved Snapshot (Link: Cold → Live). - **Agent resume**: a restored pane the host captured a resume invocation for re-runs it automatically. See "Agent resume on cold restore" below. - **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. -- **Shell selection replacement**: the standalone Settings dialog's Shell row and the VS Code shell picker send `dormouse:new-terminal` with `replaceUntouched` when the selected shell type changes. **A shell is identified by executable path plus ordered arguments**, so WSL distributions and Windows Developer shells sharing an executable stay distinct. `Wall` always mints a new session id and a fresh `surface:N` ref. If the selected pane or door is untouched, the new terminal takes over the same leaf via a Lath `replace` op (an atomic identity swap; doors first reattach through the normal restore path), the old untouched session is disposed, and the replaced Surface's ref is retired. If the selected terminal is touched or nothing is selected, the request spawns a new pane beside the selection. Announced spawns show a transient pane-anchored notice such as `Switched to zsh` or `Opened bash`. +- **Shell selection replacement**: the standalone Settings dialog's Shell row and the VS Code shell picker send `dormouse:new-terminal` with `replaceUntouched` when the selected shell type changes. **A shell is identified by executable path plus ordered arguments**, so WSL distributions and Windows Developer shells sharing an executable stay distinct. `Wall` always mints a new session id and a fresh `surface:N` ref. If an untouched plain-terminal pane or door is selected, the new terminal takes over its leaf via a Lath `replace` op (an atomic identity swap; doors first reattach through the normal restore path), the old session is disposed, and its ref is retired. Tools are never shell-replaced. If the selected terminal is touched or nothing is selected, the request spawns a new pane beside the selection. Announced spawns show a transient pane-anchored notice such as `Switched to zsh` or `Opened bash`. - **Replay-time terminal reports must be dropped; user input must not be.** During **resume** replay xterm.js may emit replies to OSC/CSI/DCS queries embedded in buffered output, and the registry drops those before they reach the new shell. The filter covers query/focus reports only — never arrows, function keys, or bracketed paste. - **mount / unmount (DOM)**: `mountElement` reparents the persistent DOM element into a container; `unmountElement` removes it. The Registry entry survives. - **Dispose**: `disposeSession` kills the PTY, disposes xterm, removes the registry entry. Only called on explicit kill (`x`). diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index e0ca71aed..fbc1557f8 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -8,6 +8,8 @@ Mouse and clipboard behavior for the terminal across macOS, Linux, and Windows: selection, copy, paste, and their coexistence with mouse-driven TUI programs. This spec owns the mouse-override icon and banner, the selection overlay and popup, and every paste path into a terminal; `docs/specs/layout.md` owns where the header icon sits, and `docs/specs/terminal-escapes.md` registers the sequences involved. +For tools, these rules apply while the terminal is forward; the browser or conflict view owns the keys otherwise. + ## Background: The Two Mouse Regimes At any moment, mouse events in the terminal belong to one of two consumers: diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index ba24c23fd..a1d06d8f6 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -24,7 +24,7 @@ A focused cross-origin iframe surface swallows the gesture before the window lis | `-` or `"` | Split top/bottom | Create a pane below, select it, and enter passthrough. | | `z` | Zoom and focus | Elevate the selected pane and enter passthrough; leaving passthrough or focusing elsewhere ends zoom. Pressing it on the pane that already owns zoom unzooms instead. | | `m` or `d` | Minimize / reattach | Minimize the selected pane to the baseboard, or reattach a minimized door (staying in command mode). | -| `k` or `x` | Kill | Kill the selected pane or door. Prompts for a random letter to confirm; untouched (never-typed-in) panes and doors are killed immediately without the prompt. | +| `k` or `x` | Kill | Kill the selected pane or door. Prompts for a random letter to confirm; an untouched (never-typed-in) plain terminal — pane or door — is killed immediately without the prompt. | | `,` | Rename | Enter rename mode for the selected pane's title. | | `a` | Toggle alert | Dismiss or toggle the bell alert for the selected pane. Meaningful only for a terminal Surface — a browser surface has no bell to ring (`docs/specs/glossary.md`). Doors are excluded. | | `t` | Toggle todo | Toggle the TODO marker on or off for the selected pane's Surface — a terminal Session or a browser surface. Doors are excluded. | diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 460ed0db5..db0941b2e 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -55,6 +55,8 @@ For replay (`pty:replay`) the frontend re-parses the buffered raw stream during | `OSC 633 ; E ; [; ] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 633 ; P ; Cwd= ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 777 ; notify ; ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#terminal-reports) | +| `OSC 367 ; serve ; <json> ST` | Dor Tool announcement: names which bound port to frame, plus an optional title and re-key | [dor-tool.md](dor-tool.md#osc-367) | +| `OSC 367 ; <any other verb> ST` | Reserved for the staged `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) | | `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 1337 ; <anything else> ST` | Unsupported iTerm2 extension; consumed and ignored. | This spec | | `OSC 50 ; <font> ST` | Unsupported dynamic font change; consumed and ignored. | This spec | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index e1c2d18c0..f224c4b91 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -159,7 +159,7 @@ Source of truth: the canonical persisted-session interfaces in `lib/src/lib/sess **Workspace-scoped dor refs.** A `PersistedSession` may record `surfaceRefs`, a map from stable Surface id to the Workspace-local `dor` short ref (`surface:N`), plus `surfaceRefsNext`, the next number to hand out. The map belongs to the Workspace session, not to the layout: reordering, minimizing, reattaching, zooming, and browser render swaps preserve the ref. A killed Surface's entry is *dropped* from the map, and `surfaceRefsNext` is persisted independently rather than derived from it, so a retired `surface:N` is never reused for a different Surface — a target naming it fails instead of resolving to the wrong pane (`docs/specs/dor-cli.md` → Handle Model). On load the counter is clamped above the highest ref in the map, so a stale or absent counter cannot hand out a live ref's number. Old snapshots without the fields allocate refs from the restored Surfaces on first mount. Source of truth: `Wall.tsx` owns the runtime registry and `session-save.ts` writes it. -**Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` (the default, omitted from the row to keep terminal snapshots byte-identical) or `'browser'`. This is the discriminator that routes restore/resume. `restoreSession` skips terminal restoration for a browser pane, so it does not mint a stray PTY + xterm for each browser pane id (`session-restore.ts`); the resume plan keeps browser panes (and minimized browser doors) even though they have no live PTY, so the saved layout's pane set still matches and is not discarded (`reconnect.ts` gates the session's Lath layout on its leaf set). A browser pane rebuilds from the persisted layout (visible) or `PersistedDoor.params` (minimized); its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane` — `surfaceType` alone is enough to route restore. A pane lacking `surfaceType` reads as `'terminal'`. +**Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` (the default, omitted from the row to keep terminal snapshots byte-identical), `'browser'`, or `'tool'`. This discriminator routes restore/resume. Browser panes skip terminal restoration and rebuild from Lath/door params. Tool panes restore a PTY and re-run their persisted command; their pane row carries stable tool metadata while the URL and agent-browser resources are always re-derived. A pane lacking `surfaceType` reads as `'terminal'`. Source of truth: `session-save.ts` and `session-restore.ts`. **Workspace/Window containers (implemented, dormant behind the `dormouse.flags.workspaces` flag; rollout ledger in `docs/specs/layout.md` `## Future`).** A `PersistedWorkspace` is a `WorkspaceId`, a user-facing `name`, and that Workspace's `PersistedSession`. The standalone Window's top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions: the ordered `PersistedWorkspace` list plus the active `WorkspaceId`. VS Code does **not** use it; each webview persists exactly one bare `PersistedSession` — its single Workspace — through the same per-surface state API as today (`workspaceState` for the view, `vscode.setState()` per editor panel; see `docs/specs/vscode.md`). @@ -184,7 +184,7 @@ Every read goes through `readPersistedSession()` / `readPersistedWindow()`. Both ### What is persisted -Structure only: panes (id, cwd, title, `untouched`, `surfaceType`, TODO/alert blob), doors and their Lath restore tokens, the Lath layout, and the Workspace's `dor` surface refs. **Scrollback is never persisted by any writer**, and neither is the recovery command (see above). +Structure only: panes (id, cwd, title, `untouched`, `surfaceType`, TODO/alert blob, plus a tool's command and stable metadata), doors and their Lath restore tokens, the Lath layout, and the Workspace's `dor` surface refs. **Scrollback is never persisted by any writer**, and neither is the recovery command (see above). ### Retiring the transcripts already on disk diff --git a/dor/src/cli.ts b/dor/src/cli.ts index bec0c14ed..ed6559fa1 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -17,6 +17,7 @@ import { readCommand } from './commands/read.js'; import { sendCommand } from './commands/send.js'; import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; +import { toolCommand } from './commands/tool.js'; import { versionCommand } from './commands/version.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; import type { @@ -70,12 +71,15 @@ export type { SurfacePort, SurfaceRenderMode, SurfaceView, + ToolSurfaceRequest, + ToolSurfaceResponse, VersionMetadata, } from './commands/types.js'; const COMMANDS = [ splitCommand, ensureCommand, + toolCommand, versionCommand, skillCommand, sendCommand, @@ -90,6 +94,7 @@ const COMMANDS = [ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, + tool: toolCommand.command, version: versionCommand.command, skill: skillCommand.command, send: sendCommand.command, diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index 834650b4b..20a66e420 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -119,7 +119,7 @@ function buildListCommand(): Command['command'] { return buildCommand<ListFlags, [], DorCommandContext>({ docs: { brief: 'List Dormouse Surfaces.', - customUsage: ['[--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]'], + customUsage: [`[--kind ${SURFACE_KINDS.join('|')}] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]`], fullDescription: FULL_DESCRIPTION, }, parameters: { flags }, diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts new file mode 100644 index 000000000..ff250f731 --- /dev/null +++ b/dor/src/commands/tool.ts @@ -0,0 +1,198 @@ +/** `dor tool` — run a command as a Dor Tool (`docs/specs/dor-tool.md`). */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ParseResult, + ToolSurfaceResponse, +} from './types.js'; +import { + callerWorkingDirectory, + errorMessage, + renderJson, + requireControlClient, + stringParser, + writeStderr, + writeStdout, +} from './shared.js'; + +interface ToolFlags { + readonly json?: boolean; + readonly minimize?: boolean; + readonly fresh?: boolean; + readonly surface?: string; + readonly cwd?: string; +} + +// A named tool waits on the same shell-integration handshake `dor ensure` does, +// plus a `dormouse.yml` read; both are bounded well under this. +const TOOL_TIMEOUT_MS = 20_000; + +const FLAGS_WITH_VALUES = new Set(['--cwd', '--surface']); +const BOOLEAN_FLAGS = new Set(['--json', '--minimize', '--fresh']); + +/** + * `dor tool` takes either a registered name or a `--` command tail, never both. + * stricli cannot express that, so the shape is checked before it parses — the + * same pre-parse contract `dor ensure` uses. Keep the flag lists above in sync + * with `parameters.flags`. + */ +export function validateToolArgs(args: string[]): ParseResult<void> { + const delimiterIndex = args.indexOf('--'); + const head = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex); + + const positionals: string[] = []; + for (let index = 0; index < head.length; index += 1) { + const arg = head[index]; + if (BOOLEAN_FLAGS.has(arg)) continue; + if (FLAGS_WITH_VALUES.has(arg)) { + const value = head[index + 1]; + if (!value || value.startsWith('-')) return { ok: false, message: `${arg} requires a value` }; + index += 1; + continue; + } + if (arg.startsWith('-')) return { ok: false, message: `unknown option '${arg}'` }; + positionals.push(arg); + } + + if (delimiterIndex === -1) { + if (positionals.length === 0) { + return { ok: false, message: 'dor tool requires a tool name or -- <command...>' }; + } + // Arguments for a named tool wait for phase C, where substitution has to + // reach the dedupe key; accepting them now would key a per-target tool on + // its name alone and collapse every target into one pane. + if (positionals.length > 1) { + return { ok: false, message: `dor tool <name> takes no arguments (got '${positionals[1]}')` }; + } + return { ok: true, value: undefined }; + } + + // `dor tool <name> -- <command>` would leave two sources for one command. + if (positionals.length > 0) { + return { ok: false, message: `unexpected argument '${positionals[0]}' before --` }; + } + if (args.slice(delimiterIndex + 1).join(' ').trim() === '') { + return { ok: false, message: 'dor tool requires a command after --' }; + } + return { ok: true, value: undefined }; +} + +export const toolCommand: Command = { + name: 'tool', + preParse: validateToolArgs, + helpPatches: [ + { + scope: 'root', + findReplace: [ + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path]<TO-EOL>', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] <name>\n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- <command>...\n', + ], + }, + { + scope: 'command-usage', + findReplace: [ + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path]<TO-EOL>', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] <name>\n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- <command>...\n', + ], + }, + { + scope: 'command-detail', + remove: ['\nARGUMENTS<TO-EOL><LS>name<TO-EOL>'], + }, + ], + command: buildCommand<ToolFlags, string[], DorCommandContext>({ + docs: { + brief: 'Run a command as a Dor Tool.', + fullDescription: `Runs a command in a new surface and watches the ports it opens. When the command starts serving, the surface grows a browser in place — same surface, same id, no second pane — and the pane flips to it with the terminal behind the header's far-left chip. When the command exits the browser retires and the pane flips back. + +Two forms. \`dor tool <name>\` runs an entry from the nearest dormouse.yml, walking up from the working directory. \`dor tool -- <command>\` designates any command as a tool without a registry entry. A named tool takes no extra arguments yet. + +A tool has an identity if and only if its dormouse.yml entry gave it one, via prespawn_dedupe. With a key, a second invocation whose key matches reveals the running surface instead of starting a duplicate. Without one — and for every \`dor tool -- <command>\` — each invocation creates a fresh surface. Nothing is keyed on the command or the working directory: run the same command twice and you get two tools. + +--fresh ignores a declared key and always creates. + +A dormouse.yml is repo-controlled and its entries execute, so it is inert until you approve it in Dormouse itself. For an unapproved repo the surface is created and reports "pending": its pane shows what would run and waits for you to allow the upstream, allow just this folder, or close it. Nothing from the repo runs until you choose, and declining records nothing. + +Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read. + +Where the tool lands: it always splits without taking focus and prints the new surface's handle, whether a human typed it or a script did. Taking over the calling pane when the invocation is typed alone at a prompt is designed but not built. + +--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. + +Text output: + created surface:3 "pnpm storybook" + existing surface:3 "pnpm storybook" + +JSON output: + { + "status": "created", + "surface_id": "pane-def", + "surface_ref": "surface:3", + "command": "pnpm storybook", + "cwd": "/Users/me/projects/site", + "minimized": false, + "key": ["storybook", "/Users/me/projects/site"] + }`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, + fresh: { kind: 'boolean', brief: 'Ignore a declared key and always create.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, + cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for the tool file and the command.', optional: true, placeholder: 'path' }, + }, + positional: { + kind: 'array', + minimum: 0, + parameter: { parse: stringParser, brief: 'Registered tool name.', placeholder: 'name' }, + }, + }, + func: runToolCommand, + }), +}; + +async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest: string[]): Promise<void | Error> { + // `--` is discarded by stricli, so the two forms are indistinguishable from + // the positionals alone; `hasArgumentEscape` is captured pre-parse for it. + const named = !this.hasArgumentEscape; + if (named && rest.length === 0) { + return new Error('dor tool requires a tool name or -- <command...>'); + } + + const client = requireControlClient(this.options, TOOL_TIMEOUT_MS); + if (client instanceof Error) return client; + + try { + const response = await client.toolSurface({ + ...(named ? { name: rest[0] } : { command: rest }), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }); + // Lint output is advisory and must not pollute a `--json` parse. + for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); + writeStdout(this, renderToolResponse(response, flags.json === true)); + return undefined; + } catch (error) { + return new Error(errorMessage(error)); + } +} + +function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { + if (json) { + return renderJson({ + status: response.status, + surface_id: response.surfaceId, + surface_ref: response.surfaceRef, + command: response.command, + cwd: response.cwd, + minimized: response.minimized, + key: response.key, + }); + } + return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.command)}\n`; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ceafea495..571a95e43 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -7,17 +7,20 @@ import type { 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 SurfaceKind = 'terminal' | 'browser' | 'tool'; export type SurfaceRenderMode = 'iframe' | 'ab-screencast' | 'ab-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 * the predicates below. `Record<SurfaceKind, ...>` on purpose: adding a kind - * (the staged `tool`, which has both) must be a compile error here, not a - * silent `false`. */ + * must be a compile error here, not a silent `false`. */ const KIND_CAPABILITIES: Record<SurfaceKind, { terminal: boolean; browser: boolean }> = { terminal: { terminal: true, browser: false }, browser: { terminal: false, browser: true }, + // A tool is one Session with both: the PTY running the command, and the + // browser it grows once it serves (`docs/specs/dor-tool.md`). Verbs gate on + // the capability they need, so both sides of a row populate. + tool: { terminal: true, browser: true }, }; /** Every kind, derived from the table so `--kind` parsing and its help @@ -142,6 +145,47 @@ export interface EnsureSurfaceResponse { minimized: boolean; } +/** + * `dor tool`. Two forms, differing only in whether the tool has an identity: + * `name` runs a `dormouse.yml` entry with whatever `prespawn_dedupe` it + * declares; `command` designates an arbitrary command as a tool with no key. + * Exactly one is set. Host-resolved on purpose — the CLI never reads the tool + * file, so a caller cannot hand the host a command while claiming the file + * authorized it (`docs/specs/dor-tool.md` -> Trust). + */ +export interface ToolSurfaceRequest { + /** Registered tool name (`dor tool <name>`). */ + name?: string; + /** Raw argv (`dor tool -- <command>`); the host quotes it for the shell. */ + command?: string[]; + /** Ignore any declared key and always create — `--fresh`. */ + fresh: boolean; + minimized: boolean; + /** Working directory: resolves the tool file and runs the command. */ + cwd: string; + /** Surface to split when creating. */ + surface?: string; +} + +export interface ToolSurfaceResponse { + /** + * `existing` is a key match on a live tool: the redundant spawn never + * started. `adopted` is a key match whose command had exited — the Surface is + * reused and the command re-run in place, keeping its position and scrollback. + */ + status: 'created' | 'existing' | 'adopted' | 'pending'; + surfaceId: string; + surfaceRef: string; + /** The rendered command, as typed into the shell. */ + command: string; + cwd: string; + minimized: boolean; + /** The resolved dedupe key, or null when the tool has no identity. */ + key: string[] | null; + /** Non-fatal `dormouse.yml` lint output, printed to stderr by the CLI. */ + warnings?: string[]; +} + export interface SendSurfaceRequest { surface: string; input: string; @@ -280,6 +324,7 @@ export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise<ListSurfacesResponse>; splitSurface(request: SplitSurfaceRequest): Promise<SplitSurfaceResponse>; ensureSurface(request: EnsureSurfaceRequest): Promise<EnsureSurfaceResponse>; + toolSurface(request: ToolSurfaceRequest): Promise<ToolSurfaceResponse>; sendSurface(request: SendSurfaceRequest): Promise<SendSurfaceResponse>; readSurface(request: ReadSurfaceRequest): Promise<ReadSurfaceResponse>; awaitSurface(request: AwaitSurfaceRequest): Promise<AwaitSurfaceResponse>; diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 54d021ae1..6fa1158b5 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -24,6 +24,8 @@ import type { SendSurfaceResponse, SplitSurfaceRequest, SplitSurfaceResponse, + ToolSurfaceRequest, + ToolSurfaceResponse, } from './commands/types.js'; import { SURFACE_CONTROL_METHODS, type SurfaceControlMethod } from './protocol.js'; import type { DorControlResult } from './protocol.js'; @@ -93,6 +95,10 @@ export class SocketControlClient implements ControlClient { return this.request<EnsureSurfaceResponse>(SURFACE_CONTROL_METHODS.ensure, request); } + toolSurface(request: ToolSurfaceRequest): Promise<ToolSurfaceResponse> { + return this.request<ToolSurfaceResponse>(SURFACE_CONTROL_METHODS.tool, request); + } + sendSurface(request: SendSurfaceRequest): Promise<SendSurfaceResponse> { return this.request<SendSurfaceResponse>(SURFACE_CONTROL_METHODS.send, request); } diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 403b80796..c1c381417 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -17,6 +17,7 @@ export const SURFACE_CONTROL_METHODS = { list: 'surface.list', split: 'surface.split', ensure: 'surface.ensure', + tool: 'surface.tool', send: 'surface.send', read: 'surface.read', await: 'surface.await', diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 07e0354e7..acf8b278c 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -139,6 +139,27 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { ...(command ? { command } : {}), }; }, + async toolSurface(request) { + this.requests.push({ method: 'toolSurface', request }); + // Mirror the host: a named tool renders from the (fixture) registry, a + // `--` tail is quoted argv. `storybook` is the keyed entry, so it is the + // one that can come back as an existing match. + const named = typeof request.name === 'string'; + const command = named + ? `pnpm ${request.name}` + : buildShellCommandForKind('posix', request.command); + const keyed = named && request.name === 'storybook' && !request.fresh; + return { + status: keyed ? 'existing' : 'created', + surfaceId: '44444444-4444-4444-8444-444444444444', + surfaceRef: 'surface:4', + command, + cwd: request.cwd, + minimized: request.minimized, + key: keyed ? ['storybook', '/work/site'] : null, + ...(named && request.name === 'noisy' ? { warnings: ['dormouse.yml: tools.noisy: ignoring unknown field \'colour\''] } : {}), + }; + }, async ensureSurface(request) { this.requests.push({ method: 'ensureSurface', request }); // Mirror the host: quote the argv for the target shell, and key on the @@ -1383,3 +1404,94 @@ test('ensure missing command output', async () => { test('split conflicting direction output', async () => { await snapshot('split-conflicting-direction', await runCli(['split', '--left', '--right'], { client: fixtureClient() })); }); + +test('tool named form text output', async () => { + await snapshot( + 'tool-named', + await runCli(['tool', 'storybook'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool command form text output', async () => { + await snapshot( + 'tool-command', + await runCli(['tool', '--', 'pnpm', 'dev'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool json output carries the resolved key', async () => { + await snapshot( + 'tool-json', + await runCli(['tool', '--json', 'storybook'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool sends the name, never a command', async () => { + const client = fixtureClient(); + await runCli(['tool', 'storybook'], { client, env: { PWD: '/work/site' } }); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests, [{ + method: 'toolSurface', + request: { + name: 'storybook', + fresh: false, + minimized: false, + surface: undefined, + cwd: '/work/site', + }, + }]); +}); + +test('tool rejects arguments after a name', async () => { + await snapshot('tool-name-args', await runCli(['tool', 'storybook', 'extra'], { client: fixtureClient() })); +}); + +test('tool -- sends argv as a command, never a name', async () => { + const client = fixtureClient(); + await runCli(['tool', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } }); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests, [{ + method: 'toolSurface', + request: { + command: ['pnpm', 'dev'], + fresh: false, + minimized: false, + surface: undefined, + cwd: '/work/site', + }, + }]); +}); + +test('tool --fresh forwards the opt-out', async () => { + const client = fixtureClient(); + await runCli(['tool', '--fresh', 'storybook'], { client, env: { PWD: '/work/site' } }); + assert.equal(client.requests[0].request.fresh, true); +}); + +test('tool prints dormouse.yml warnings to stderr, keeping --json parseable', async () => { + const result = await runCli(['tool', '--json', 'noisy'], { + client: fixtureClient(), + env: { PWD: '/work/site' }, + }); + assert.match(result.stderr, /ignoring unknown field 'colour'/); + assert.deepEqual(JSON.parse(result.stdout).status, 'created'); +}); + +test('tool with neither a name nor a command tail', async () => { + await snapshot('tool-missing-target', await runCli(['tool'], { client: fixtureClient() })); +}); + +test('tool rejects a name and a command tail together', async () => { + await snapshot( + 'tool-name-and-tail', + await runCli(['tool', 'storybook', '--', 'pnpm', 'dev'], { client: fixtureClient() }), + ); +}); + +test('tool rejects an empty command tail', async () => { + await snapshot('tool-empty-tail', await runCli(['tool', '--'], { client: fixtureClient() })); +}); + +test('tool rejects an unknown option', async () => { + await snapshot('tool-unknown-option', await runCli(['tool', '--nope', 'storybook'], { client: fixtureClient() })); +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1ee6a0e77..4756f0c41 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -6,6 +6,8 @@ Invocation: `dor --help` USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [-- <command>...] dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- <command>... + dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] <name> + dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- <command>... dor version [--json] dor skill [--install] [--json] dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] @@ -14,7 +16,7 @@ USAGE dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] dor iframe [--json] [--minimize] [--surface id|ref] <target> dor agent-browser [--key name|--session name|--surface handle] [args...] - dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] + dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser|tool] [--port number] [--ports] [--view paned|zoomed|minimized] dor --help Dormouse bundles the dor CLI into every terminal it launches. @@ -26,6 +28,7 @@ FLAGS COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface is running a command. + tool Run a command as a Dor Tool. version Print the dor CLI version. skill Print the Dormouse agent skill, or install its bootstrap stub. send Send text or key input to a terminal surface. diff --git a/dor/test/snapshots/help/list.md b/dor/test/snapshots/help/list.md index cd6c072b2..ca444401e 100644 --- a/dor/test/snapshots/help/list.md +++ b/dor/test/snapshots/help/list.md @@ -4,7 +4,7 @@ Invocation: `dor list --help` ```text USAGE - dor list [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] + dor list [--kind terminal|browser|tool] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] dor list --help Lists every Surface in the current Workspace — terminals and browser Surfaces, including minimized ones (view "minimized"). diff --git a/dor/test/snapshots/help/tool.md b/dor/test/snapshots/help/tool.md new file mode 100644 index 000000000..0ceb96d8c --- /dev/null +++ b/dor/test/snapshots/help/tool.md @@ -0,0 +1,51 @@ +# dor tool + +Invocation: `dor tool --help` + +```text +USAGE + dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] <name> + dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- <command>... + dor tool --help + +Runs a command in a new surface and watches the ports it opens. When the command starts serving, the surface grows a browser in place — same surface, same id, no second pane — and the pane flips to it with the terminal behind the header's far-left chip. When the command exits the browser retires and the pane flips back. + +Two forms. `dor tool <name>` runs an entry from the nearest dormouse.yml, walking up from the working directory. `dor tool -- <command>` designates any command as a tool without a registry entry. A named tool takes no extra arguments yet. + +A tool has an identity if and only if its dormouse.yml entry gave it one, via prespawn_dedupe. With a key, a second invocation whose key matches reveals the running surface instead of starting a duplicate. Without one — and for every `dor tool -- <command>` — each invocation creates a fresh surface. Nothing is keyed on the command or the working directory: run the same command twice and you get two tools. + +--fresh ignores a declared key and always creates. + +A dormouse.yml is repo-controlled and its entries execute, so it is inert until you approve it in Dormouse itself. For an unapproved repo the surface is created and reports "pending": its pane shows what would run and waits for you to allow the upstream, allow just this folder, or close it. Nothing from the repo runs until you choose, and declining records nothing. + +Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read. + +Where the tool lands: it always splits without taking focus and prints the new surface's handle, whether a human typed it or a script did. Taking over the calling pane when the invocation is typed alone at a prompt is designed but not built. + +--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. + +Text output: + created surface:3 "pnpm storybook" + existing surface:3 "pnpm storybook" + +JSON output: + { + "status": "created", + "surface_id": "pane-def", + "surface_ref": "surface:3", + "command": "pnpm storybook", + "cwd": "/Users/me/projects/site", + "minimized": false, + "key": ["storybook", "/Users/me/projects/site"] + } + +FLAGS + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--fresh] Ignore a declared key and always create. + [--surface] Surface to split when creating. + [--cwd] Working directory for the tool file and the command. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/tool-command.snap b/dor/test/snapshots/tool-command.snap new file mode 100644 index 000000000..2376a1436 --- /dev/null +++ b/dor/test/snapshots/tool-command.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +created surface:4 "pnpm dev" + +stderr: diff --git a/dor/test/snapshots/tool-empty-tail.snap b/dor/test/snapshots/tool-empty-tail.snap new file mode 100644 index 000000000..e0e608bb7 --- /dev/null +++ b/dor/test/snapshots/tool-empty-tail.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor tool requires a command after -- diff --git a/dor/test/snapshots/tool-json.snap b/dor/test/snapshots/tool-json.snap new file mode 100644 index 000000000..f4a8c14ab --- /dev/null +++ b/dor/test/snapshots/tool-json.snap @@ -0,0 +1,16 @@ +exitCode: 0 +stdout: +{ + "status": "existing", + "surface_id": "44444444-4444-4444-8444-444444444444", + "surface_ref": "surface:4", + "command": "pnpm storybook", + "cwd": "/work/site", + "minimized": false, + "key": [ + "storybook", + "/work/site" + ] +} + +stderr: diff --git a/dor/test/snapshots/tool-missing-target.snap b/dor/test/snapshots/tool-missing-target.snap new file mode 100644 index 000000000..ef6cffb5f --- /dev/null +++ b/dor/test/snapshots/tool-missing-target.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor tool requires a tool name or -- <command...> diff --git a/dor/test/snapshots/tool-name-and-tail.snap b/dor/test/snapshots/tool-name-and-tail.snap new file mode 100644 index 000000000..302e1eddd --- /dev/null +++ b/dor/test/snapshots/tool-name-and-tail.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unexpected argument 'storybook' before -- diff --git a/dor/test/snapshots/tool-name-args.snap b/dor/test/snapshots/tool-name-args.snap new file mode 100644 index 000000000..515ced20b --- /dev/null +++ b/dor/test/snapshots/tool-name-args.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor tool <name> takes no arguments (got 'extra') diff --git a/dor/test/snapshots/tool-named.snap b/dor/test/snapshots/tool-named.snap new file mode 100644 index 000000000..0e4a2de0d --- /dev/null +++ b/dor/test/snapshots/tool-named.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +existing surface:4 "pnpm storybook" + +stderr: diff --git a/dor/test/snapshots/tool-unknown-option.snap b/dor/test/snapshots/tool-unknown-option.snap new file mode 100644 index 000000000..778275819 --- /dev/null +++ b/dor/test/snapshots/tool-unknown-option.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unknown option '--nope' diff --git a/dormouse.yml b/dormouse.yml new file mode 100644 index 000000000..476f6ac96 --- /dev/null +++ b/dormouse.yml @@ -0,0 +1,28 @@ +# Dor Tools for this repo (docs/specs/dor-tool.md). +# +# `dor tool <name>` runs one of these in a pane that grows a browser once the +# command starts serving. Repo-controlled, so Dormouse asks you to approve this +# directory once before it will run anything here. +tools: + storybook: + run: pnpm storybook + # Storybook never announces, so autobind: frame the one port it opens. If it + # ever opened a second, Dormouse would show that instead of guessing. + port: auto + # Scoped to the checkout: parallel worktrees each get their own Storybook, + # and each frames the port it actually bound (6006, then 6007, ...). Without + # $PROJECT_ROOT both worktrees would share one key and the second would + # reveal the first instead of starting. + prespawn_dedupe: [storybook, $PROJECT_ROOT] + + standalone-harness: + run: pnpm dev:standalone:ab + # A real browser rather than an iframe, so an agent can drive the harness + # with `dor ab --surface surface:N <verb>`. + render: ab-screencast + # The harness binds the dev bridge (1422) *before* vite (1420), so a scan can + # catch the bridge alone and autobind would refuse the pair a tick later. + # It announces vite's port via OSC 367 instead; the scan still supplies the + # number it actually got, which is what survives an env override. + port: announced + prespawn_dedupe: [standalone-harness, $PROJECT_ROOT] diff --git a/lib/package.json b/lib/package.json index 9a100954f..c6b1d47bf 100644 --- a/lib/package.json +++ b/lib/package.json @@ -32,7 +32,8 @@ "react-dom": "^19.2.6", "server-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "yaml": "^2.9.0" }, "devDependencies": { "@storybook/addon-docs": "^10.4.0", diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 8028d2476..43b95fd01 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -17,6 +17,8 @@ import { FakePtyAdapter } from '../lib/platform/fake-adapter'; import type { PlatformAdapter } from '../lib/platform/types'; import * as terminalRegistry from '../lib/terminal-registry'; import { UNNAMED_PANEL_TITLE } from '../lib/terminal-registry'; +import { pendingShellOpts } from '../lib/terminal-store'; +import { setToolsEnabled } from '../lib/feature-flags'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -713,6 +715,92 @@ describe('Wall on the Lath engine', () => { } }); + it('requires confirmation before killing an untouched tool', async () => { + const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockImplementation((id) => id === 'tool-a'); + try { + await act(async () => { + root.render(<Wall + restoredLathLayout={{ + version: 1, + tree: { root: { kind: 'leaf', id: 'tool-a' } }, + leafMeta: { + 'tool-a': { + component: 'tool', + tabComponent: 'tool', + title: 'storybook', + params: { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolName: 'storybook', + toolRender: 'iframe', + toolPort: 'announced', + }, + }, + }, + }} + initialMode="command" + showBaseboard + />); + }); + await flush(); + + await act(async () => { + container.querySelector<HTMLButtonElement>('[data-lath-leaf="tool-a"] [aria-label="Kill"]')!.click(); + }); + await flush(); + + expect(container.textContent).toContain('Confirm kill'); + expect(container.querySelector('[data-lath-leaf="tool-a"]')).not.toBeNull(); + } finally { + untouchedSpy.mockRestore(); + } + }); + + it('does not shell-replace an untouched tool', async () => { + const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockImplementation((id) => id === 'tool-a'); + try { + await act(async () => { + root.render(<Wall + restoredLathLayout={{ + version: 1, + tree: { root: { kind: 'leaf', id: 'tool-a' } }, + leafMeta: { + 'tool-a': { + component: 'tool', + tabComponent: 'tool', + title: 'storybook', + params: { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolName: 'storybook', + toolRender: 'iframe', + toolPort: 'announced', + }, + }, + }, + }} + initialMode="command" + showBaseboard + />); + }); + await flush(); + + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:new-terminal', { + detail: { name: 'zsh', replaceUntouched: true }, + })); + }); + await flush(); + + expect(container.querySelector('[data-lath-leaf="tool-a"]')).not.toBeNull(); + expect(leafCount()).toBe(2); + } finally { + untouchedSpy.mockRestore(); + } + }); + it('ignores zoom keyboard requests while a door is selected', async () => { const onEvent = vi.fn(); await act(async () => { @@ -909,6 +997,403 @@ describe('Wall on the Lath engine', () => { } }); + it('keeps an approved tool deferred until trust lookup and shell staging finish', async () => { + setToolsEnabled(true); + let toolId: string | undefined; + const trustGate = Promise.withResolvers<{ status: 'trust-recorded' }>(); + const resolvedGate = Promise.withResolvers<{ + status: 'ok'; + projectRoot: string; + path: string; + name: string; + run: string; + render: 'iframe'; + port: 'announced'; + key: null; + warnings: string[]; + }>(); + const toolControl = vi.fn((request: { op: 'lookup' | 'trust' }) => { + if (request.op === 'trust') return trustGate.promise; + if (toolControl.mock.calls.length === 1) { + return Promise.resolve({ + status: 'untrusted' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + }); + } + return resolvedGate.promise; + }); + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = toolControl; + + try { + await act(async () => { + root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" showBaseboard />); + }); + await flush(); + + let response: { ok: boolean; result?: { surfaceId: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + expect(response?.ok).toBe(true); + toolId = response!.result!.surfaceId; + expect(container.querySelector(`[data-session-id="${toolId}"]`)).toBeNull(); + + const allow = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Always allow for folder')); + expect(allow).toBeDefined(); + await act(async () => { + allow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + allow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(1); + expect(container.querySelector(`[data-session-id="${toolId}"]`)).toBeNull(); + + await act(async () => { trustGate.resolve({ status: 'trust-recorded' }); }); + await flush(); + expect(container.querySelector(`[data-session-id="${toolId}"]`)).toBeNull(); + + await act(async () => { + resolvedGate.resolve({ + status: 'ok', + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe', + port: 'announced', + key: null, + warnings: [], + }); + }); + await flush(); + expect(container.querySelector(`[data-session-id="${toolId}"]`)).not.toBeNull(); + expect(pendingShellOpts.get(toolId)?.untouched).toBe(true); + } finally { + if (toolId) pendingShellOpts.delete(toolId); + setToolsEnabled(false); + } + }); + + it('starts an approved tool before applying its deferred minimize', async () => { + setToolsEnabled(true); + let toolId: string | undefined; + let consumedOpts: (typeof pendingShellOpts extends Map<string, infer T> ? T : never) | undefined; + const getTerminalSpy = vi.spyOn(terminalRegistry, 'getOrCreateTerminal').mockImplementation((id) => { + consumedOpts = pendingShellOpts.get(id); + pendingShellOpts.delete(id); + fake.spawnPty(id); + return {} as ReturnType<typeof terminalRegistry.getOrCreateTerminal>; + }); + let lookupCount = 0; + const toolControl = vi.fn(async (request: { op: 'lookup' | 'trust' }) => { + if (request.op === 'trust') return { status: 'trust-recorded' as const }; + lookupCount += 1; + if (lookupCount === 1) { + return { + status: 'untrusted' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + }; + } + return { + status: 'ok' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe' as const, + port: 'announced' as const, + key: null, + warnings: [], + }; + }); + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = toolControl; + + try { + await act(async () => { + root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" showBaseboard />); + }); + await flush(); + + let response: { ok: boolean; result?: { surfaceId: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: true, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + toolId = response!.result!.surfaceId; + expect(fake.hasPty(toolId)).toBe(false); + + const allow = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Always allow for folder'))!; + await act(async () => { allow.click(); }); + await flush(); + + expect(fake.hasPty(toolId)).toBe(true); + expect(getTerminalSpy).toHaveBeenCalledWith(toolId); + expect(consumedOpts).toMatchObject({ cwd: '/repo', command: 'pnpm storybook', untouched: true }); + expect(pendingShellOpts.has(toolId)).toBe(false); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).not.toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)?.hasAttribute('data-lath-parked')).toBe(true); + } finally { + if (toolId && fake.hasPty(toolId)) act(() => fake.killPty(toolId)); + getTerminalSpy.mockRestore(); + setToolsEnabled(false); + } + }); + + it('reveals a pending approval created against a minimized reference', async () => { + setToolsEnabled(true); + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = vi.fn(async () => ({ + status: 'untrusted' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + })); + + try { + await act(async () => { + root.render( + <Wall + initialPaneIds={['pane-a']} + initialDoors={[{ id: 'reference-door', title: 'Reference' }]} + initialMode="command" + showBaseboard + />, + ); + }); + await flush(); + + let response: { ok: boolean; result?: { surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { + name: 'storybook', + cwd: '/repo', + surface: 'surface:2', + minimized: false, + fresh: false, + }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + + expect(response).toMatchObject({ ok: true, result: { minimized: false } }); + const toolId = response!.result!.surfaceId; + expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)?.hasAttribute('data-lath-parked')).toBe(false); + expect(container.textContent).toContain('Always allow for folder'); + } finally { + setToolsEnabled(false); + } + }); + + it('reports a reused pending tool as visible after reattaching it', async () => { + setToolsEnabled(true); + const toolId = 'pending-tool-door'; + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = vi.fn(async () => ({ + status: 'untrusted' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + })); + + try { + await act(async () => { + root.render( + <Wall + initialPaneIds={['pane-a']} + initialDoors={[{ + id: toolId, + title: 'storybook', + component: 'tool', + tabComponent: 'tool', + params: { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolName: 'storybook', + toolPending: { + name: 'storybook', + run: 'pnpm storybook', + path: '/repo/dormouse.yml', + projectRoot: '/repo', + cwd: '/repo', + minimized: false, + upstreamUrl: null, + }, + }, + }]} + initialMode="command" + showBaseboard + />, + ); + }); + await flush(); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).not.toBeNull(); + + let response: { ok: boolean; result?: { status: string; surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + + expect(response).toMatchObject({ + ok: true, + result: { status: 'pending', surfaceId: toolId, minimized: false }, + }); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); + } finally { + setToolsEnabled(false); + } + }); + + it('reports a reused minimized tool as visible after reattaching it', async () => { + setToolsEnabled(true); + const toolId = 'tool-door'; + terminalRegistry.applyTerminalSemanticEvents(toolId, [ + { type: 'commandLine', commandLine: 'pnpm storybook' }, + { type: 'commandStart' }, + ]); + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = vi.fn(async () => ({ + status: 'ok' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe' as const, + port: 'announced' as const, + key: ['/repo'], + warnings: [], + })); + + try { + await act(async () => { + root.render( + <Wall + initialPaneIds={['pane-a']} + initialDoors={[{ + id: toolId, + title: 'storybook', + component: 'tool', + tabComponent: 'tool', + params: { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolName: 'storybook', + toolRender: 'iframe', + toolPort: 'announced', + toolKey: ['storybook', '/repo'], + }, + }]} + initialMode="command" + showBaseboard + />, + ); + }); + await flush(); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).not.toBeNull(); + + let response: { ok: boolean; result?: { status: string; surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + + expect(response).toMatchObject({ + ok: true, + result: { status: 'existing', surfaceId: toolId, minimized: false }, + }); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); + } finally { + act(() => terminalRegistry.removeTerminalPaneState(toolId)); + setToolsEnabled(false); + } + }); + + it('rejects a non-integrated shell before offering tool approval', async () => { + setToolsEnabled(true); + terminalRegistry.setDefaultShellOpts({ shell: 'C:\\Windows\\System32\\cmd.exe' }); + const toolControl = vi.fn(async () => ({ + status: 'untrusted' as const, + projectRoot: 'C:\\repo', + path: 'C:\\repo\\dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + })); + (fake as FakePtyAdapter & Pick<PlatformAdapter, 'toolControl'>).toolControl = toolControl; + + try { + await act(async () => { + root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" showBaseboard />); + }); + await flush(); + + let response: { ok: boolean; error?: string } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: 'C:\\repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + + expect(response?.ok).toBe(false); + expect(response?.error).toContain('requires OSC 633 shell integration'); + expect(container.textContent).not.toContain('Always allow for folder'); + expect(leafCount()).toBe(1); + } finally { + terminalRegistry.setDefaultShellOpts(null); + setToolsEnabled(false); + } + }); + // A Door created by `dor split` against another Door is the one Surface that never // was a pane, so it exercises the store's `addDoor` registration rather than the // meta a minimize retains. Every Door reader goes through `lath.getMeta`, so a diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index b3e20a516..f32fcf5fa 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -53,7 +53,7 @@ import type { PersistedDoor, PersistedSurfaceRefs } from '../lib/session-types'; import type { DropTarget, RestoreToken } from '../lib/lath/ops'; import type { Edge } from '../lib/lath/model'; import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; -import { resolveRenderMode, agentBrowserSessionFromParams, browserUrlFromParams, surfaceKindFromParams } from './wall/browser-surface'; +import { resolveRenderMode, agentBrowserSessionFromParams, browserUrlFromParams, isToolParams, namespacedToolKey, surfaceKindFromParams, toolPendingFromParams } from './wall/browser-surface'; import { hostPathDisplay } from './wall/browser-url'; import { WorkspaceSelectionOverlay } from './wall/WorkspaceSelectionOverlay'; import { LathHost } from './wall/LathHost'; @@ -66,6 +66,8 @@ import { edgeForDorDirection, directionForArrow, } from './wall/lath-wall-engine'; +import type { LeafMeta } from '../lib/lath/persistence'; +import { useToolServing } from './wall/use-tool-serving'; import type { WallNav } from './wall/keyboard/types'; import { useWallKeyboard } from './wall/use-wall-keyboard'; import { useSessionPersistence } from './wall/use-session-persistence'; @@ -363,6 +365,10 @@ export function Wall({ const [shellSpawnNotice, setShellSpawnNotice] = useState<ShellSpawnNoticeState | null>(null); const shellSpawnNoticeCounterRef = useRef(0); const shellSpawnNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + // Keep the approval prompt mounted until its shell launch is fully staged. + // The Set suppresses duplicate button clicks without exposing the terminal + // half early (toolPending is the render-time no-PTY guard). + const toolApprovalsInFlightRef = useRef<Set<string>>(new Set()); // Use refs so the capture-phase listener always sees latest state without re-registering const modeRef = useRef(mode); @@ -915,6 +921,8 @@ export function Wall({ cwd, requireIntegration, focusNeutral, + leafMeta, + deferTerminal, }: { command?: string; direction: DorResolvedSplitDirection; @@ -922,11 +930,19 @@ export function Wall({ reference: DorSurface; cwd?: string; requireIntegration?: boolean; + /** Leaf metadata for the new Surface; defaults to a plain terminal. `dor + * tool` passes a tool leaf, which is a shell-hosted PTY exactly like a + * terminal but renders both capabilities. */ + leafMeta?: LeafMeta; // `dor ensure` and `dor split -- <command>` must never move focus: the split // is created in the background, leaving the caller's selection, mode, and DOM // focus intact. Under Lath every add is inherently background (nothing // re-parents or activates). focusNeutral?: boolean; + /** Create the leaf but stage no shell and spawn no PTY. `dor tool` uses it + * for a pane awaiting approval: nothing from the repo may run until a human + * chooses (docs/specs/dor-tool.md -> Trust rule 3). */ + deferTerminal?: boolean; }): ParseResult<{ id: string; ref: string; @@ -948,7 +964,10 @@ export function Wall({ const sourceCwd = getTerminalPaneState(referenceId).cwd; const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined); - if (command) { + if (deferTerminal) { + // No pending shell opts at all: the terminal must not spawn when the leaf + // mounts, and must not inherit a cwd it will never use. + } else if (command) { // Spawn a real interactive shell and type the command into it once it // reaches a prompt (see typeCommandWhenPromptReady in the lifecycle), rather // than launching `shell -c command`. A `-c` invocation has no prompt behind @@ -959,7 +978,10 @@ export function Wall({ shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd, - untouched: false, + // Starting the command is Dormouse orchestration, not user input. A + // tool stays untouched until its terminal or browser receives input; + // other commanded splits retain their established conservative state. + untouched: leafMeta?.component === 'tool', command, ...(requireIntegration ? { requireIntegration: true } : {}), }); @@ -983,11 +1005,11 @@ export function Wall({ index: direction === 'left' || direction === 'up' ? 0 : 1, fingerprint: null, }; - getOrCreateTerminal(newId); + if (!deferTerminal) getOrCreateTerminal(newId); // This Surface is born minimized — it never has a pane to detach — so register // its meta directly, keeping the store the authority for EVERY Door // (docs/specs/tiling-engine.md → "Parked leaves"). - lath.store.addDoor(newId, terminalLeafMeta()); + lath.store.addDoor(newId, leafMeta ?? terminalLeafMeta()); addMinimizedSplitDoor(referenceId, { id: newId, token }, !focusNeutral); onEventRef.current?.({ type: 'split', @@ -1002,7 +1024,7 @@ export function Wall({ // types straight into it; `dor split -- <command>` and `dor ensure` // (focus-neutral) leave selection put. const edge = edgeForDorDirection(direction); - lath.store.addLeaf(newId, terminalLeafMeta(), { refId: referenceId, edge }); + lath.store.addLeaf(newId, leafMeta ?? terminalLeafMeta(), { refId: referenceId, edge }); const selectedNew = settleAddSelection(!!focusNeutral, false, newId); onEventRef.current?.({ type: 'split', @@ -1010,7 +1032,7 @@ export function Wall({ source: 'dor', }); if (minimized) { - getOrCreateTerminal(newId); + if (!deferTerminal) getOrCreateTerminal(newId); minimizePane(newId, { select: selectedNew }); } return { ok: true, value: { id: newId, ref: surfaceRefForId(newId), minimized } }; @@ -1133,6 +1155,7 @@ export function Wall({ const shouldReplaceUntouched = detail.replaceUntouched === true && selectedPaneVisible && + !isToolParams(lath.getMeta(selectedPaneId!)?.params) && isUntouched(selectedPaneId!); const shellName = detail.name?.trim() || 'terminal'; @@ -1147,7 +1170,12 @@ export function Wall({ return; } - if (detail.replaceUntouched === true && selectedDoor && isUntouched(selectedDoor.id)) { + if ( + detail.replaceUntouched === true && + selectedDoor && + !isToolParams(lath.getMeta(selectedDoor.id)?.params) && + isUntouched(selectedDoor.id) + ) { handleReattachRef.current(selectedDoor, { enterPassthrough: false, afterRestore: { @@ -1177,6 +1205,76 @@ export function Wall({ }, [generatePaneId, surfaceRefForId, forgetSurfaceRef, selectPane, enterTerminalMode, showShellSpawnNotice, lath, nav]); // --- dor control plane (the `dor` CLI's webview handler) --- + // Approving a pending tool: record the grant, then start the command in the + // pane that has been showing the prompt. The two steps are ordered so a + // failed write never leaves a running command in an unapproved repo. + const resolveToolApproval = useCallback(async (id: string, choice: 'upstream' | 'folder' | 'decline') => { + const meta = lath.getMeta(id); + const pending = toolPendingFromParams(meta?.params); + if (!pending) return; + if (choice === 'decline') { + // A refusal writes nothing: it closes the pane and leaves no record, so a + // reflexive decline cannot permanently disable tools for this repo. + killPaneImmediately(id); + return; + } + if (toolApprovalsInFlightRef.current.has(id)) return; + toolApprovalsInFlightRef.current.add(id); + + try { + const platform = getPlatform(); + await platform.toolControl?.({ + op: 'trust', + kind: choice, + projectRoot: pending.projectRoot, + }); + + // Re-resolve now that the grant exists. The untrusted lookup deliberately + // withholds `render` / `port` / `key` — they live only in the `ok` arm — so + // asking again is what gives an approved tool the config its dormouse.yml + // declared, rather than silently running it as a keyless default iframe. + const cwd = typeof meta?.params?.cwd === 'string' ? meta.params.cwd : pending.projectRoot; + const resolved = await platform.toolControl?.({ op: 'lookup', name: pending.name, cwd }); + if (resolved?.status !== 'ok') { + killPaneImmediately(id); + return; + } + + lath.store.updateParams(id, { + command: resolved.run, + toolRender: resolved.render, + toolPort: resolved.port, + ...(resolved.key ? { toolKey: namespacedToolKey(resolved.name, resolved.key) } : {}), + }); + // Hand the leaf its command only now. The approval marker stays in place + // until after this write, so TerminalPanel cannot consume default options + // while the host calls above are pending. + const defaults = getDefaultShellOpts(); + setPendingShellOpts(id, { + shell: defaults?.shell, + args: defaults?.args, + cwd, + untouched: true, + command: resolved.run, + requireIntegration: true, + }); + lath.store.updateParams(id, { toolPending: undefined }); + // The launch asked for this, and it was withheld so the prompt could be seen. + if (pending.minimized) { + // Minimizing detaches the leaf before it can mount, so the PTY that + // consumes the staged opts has to be created here — the same reason + // `createSplitSurface` spawns before `addDoor` / `minimizePane`. + getOrCreateTerminal(id); + minimizePane(id); + } + } finally { + toolApprovalsInFlightRef.current.delete(id); + } + }, [lath, killPaneImmediately, minimizePane]); + + // A tool grows its browser when its command starts serving. + useToolServing({ lath, doorsRef }); + const { connectPort } = useDorControl({ lath, nav, @@ -1224,7 +1322,10 @@ export function Wall({ const wallActions: WallActions = useMemo(() => ({ onKill: (id: string) => { exitTerminalMode(); - if (isUntouched(id)) { + // `untouched` makes a blank terminal disposable. A tool can already own + // a long-running command and browser resources before its first human + // input, so it always takes the conservative confirmation path. + if (isUntouched(id) && !isToolParams(lath.getMeta(id)?.params)) { killPaneImmediately(id); return; } @@ -1308,6 +1409,32 @@ export function Wall({ const params = nav.paneParams(id); const currentRenderMode = surfaceRenderModeFromParams(params); + // A tool's browser is a param of the tool's own leaf, so a swap mutates + // `renderMode` in place and never routes through `replaceSurface` + // (docs/specs/dor-tool.md -> The tool capability set). Replacing would + // mint a new id, drop the terminal half and the tool's identity, and + // orphan the PTY still running the command. iframe ⇄ ab-* is a re-open of + // the same URL, which the serving trigger performs on the next tick. + if (isToolParams(params)) { + if (mode === currentRenderMode) return; + // Swapping away from an ab-rendered tool still has to release the + // session and the controller — `replaceSurface` does both on the + // non-tool path, and this branch skips it by design. Once `session` is + // gone from params nothing can reach the daemon again, not even a kill. + closeAgentBrowserSession(params); + disposeAgentBrowserSurfaceController(id); + lath.store.updateParams(id, { + toolRender: mode === 'iframe' ? 'iframe' : 'ab-screencast', + // Drop the browser so `useToolServing` re-derives it under the new + // renderer; the URL is re-derived from the scan, never carried over. + renderMode: undefined, + url: undefined, + session: undefined, + wsPort: undefined, + }); + return; + } + // 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') { @@ -1374,7 +1501,16 @@ export function Wall({ resolveSurfaceRef: surfaceRefForId, // The pane context menu's "connect a port" action: act like `dor ab open`. onConnectPort: connectPort, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, connectPort, lath, nav]); + // Pin the terminal forward past serving, or release it. Visibility only — + // ToolPanel keeps both halves mounted (docs/specs/dor-tool.md). + onResolveToolApproval: (id: string, choice: 'upstream' | 'folder' | 'decline') => { + void resolveToolApproval(id, choice); + }, + onToggleToolTerminal: (id: string) => { + const showing = lath.getMeta(id)?.params?.showTerminal === true; + lath.store.updateParams(id, { showTerminal: showing ? undefined : true }); + }, + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, connectPort, resolveToolApproval, lath, nav]); const wallActionsRef = useRef(wallActions); wallActionsRef.current = wallActions; diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 9b289438d..4e44da139 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -79,6 +79,12 @@ export const ALERT_SPEECH_TRACKING_CLASS = 'tracking-[0.12em]'; // stay at the call site; the surface recipe is shared so they can't drift. export const POPUP_SURFACE_CLASS = 'z-[1000] rounded border border-border bg-surface-raised font-mono text-foreground shadow-md'; +// A pane filling its whole area with a centered message instead of content: the +// iframe surface's connecting/error states, a tool's port conflict, and a tool +// awaiting approval. They sit on the terminal ground because they stand in for +// a surface, not for chrome. Stacking direction and gap stay at the call site. +export const PANE_MESSAGE_CLASS = 'flex h-full w-full items-center justify-center bg-terminal-bg px-6 text-center text-sm'; + // `ComponentProps<'div'>` rather than `HTMLAttributes<HTMLDivElement>` so `ref` // is among the props (React 19 ref-as-prop): an anchored menu needs the row // itself measured, not a wrapper around it. diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index 9daf53f00..af01bccca 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -5,6 +5,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; +import * as terminalRegistry from '../../lib/terminal-registry'; import type { AgentBrowserPopResult, AgentBrowserStreamStatusResult, PlatformAdapter } from '../../lib/platform/types'; import type { PaneProps } from './pane-props'; import { AgentBrowserPanel, HIDDEN_PARK_DELAY_MS } from './AgentBrowserPanel'; @@ -760,3 +761,45 @@ describe('AgentBrowserPanel tab strip actions', () => { expect(screenshot).toHaveBeenCalled(); }); }); + +describe('the pop-out affordance on a tool (regression: PR #493 review)', () => { + // The second of the two screen-registration sites (the other is + // `IframePanel`): a tool declaring `render: ab-screencast` mounts this panel, + // so the gate has to be here too. Why it exists is at the gate itself, in + // `agent-browser-surface-controller.ts`. + function withPopOutCapableHost() { + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserPopOut'>; + platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + platform.agentBrowserPopOut = vi.fn(async (): Promise<AgentBrowserPopResult> => ({ ok: true, wsPort: 1 })); + setPlatform(platform); + } + + it('offers pop-out on a plain browser surface', async () => { + withPopOutCapableHost(); + await renderPanel(paneProps('ab-plain', { surfaceType: 'browser', session: 's', renderMode: 'ab-screencast' })); + expect(getAgentBrowserScreenController('ab-plain')?.canPopOut).toBe(true); + }); + + it('never offers it on a tool', async () => { + withPopOutCapableHost(); + await renderPanel(paneProps('ab-tool', { surfaceType: 'tool', session: 's', renderMode: 'ab-screencast' })); + expect(getAgentBrowserScreenController('ab-tool')?.canPopOut).toBe(false); + }); + + it('marks the Session touched on browser-side input', async () => { + const markTouched = vi.spyOn(terminalRegistry, 'markSessionTouched').mockImplementation(() => {}); + withPopOutCapableHost(); + await renderPanel(paneProps('ab-tool-input', { + surfaceType: 'tool', + session: 's', + renderMode: 'ab-screencast', + })); + + const panel = container.firstElementChild as HTMLElement; + await act(async () => { + panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + }); + + expect(markTouched).toHaveBeenCalledWith('ab-tool-input'); + }); +}); diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 18d2ac95c..5429228db 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -5,9 +5,10 @@ import { clsx } from 'clsx'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; import { getPlatform } from '../../lib/platform'; import { isEditableTarget } from '../../lib/dom'; +import { markSessionTouched } from '../../lib/terminal-registry'; import type { RenderMode } from './agent-browser-screen'; import { tabDisplayTitle } from './browser-url'; -import { resolveRenderMode } from './browser-surface'; +import { isToolParams, resolveRenderMode } from './browser-surface'; import { MOUSE_BUTTONS, MOUSE_BUTTON_MASKS, modifiers } from './agent-browser-input'; import { acquireAgentBrowserSurfaceController, @@ -32,6 +33,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // The engine-tracked `title` prop is unused here: the live title is derived // from the stream (controller → paneWrite.setTitle), never read back. const params = rawParams as AgentBrowserPanelParams | undefined; + const isTool = isToolParams(params); const actions = useContext(WallActionsContext); const actionsRef = useRef(actions); actionsRef.current = actions; @@ -232,6 +234,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r if (!canvas) return; const onWheel = (e: WheelEvent) => { if (!interactiveRef.current) return; + if (isTool) markSessionTouched(id); e.preventDefault(); const point = toDevice(e); if (!point) return; @@ -249,10 +252,11 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r }; canvas.addEventListener('wheel', onWheel, { passive: false }); return () => canvas.removeEventListener('wheel', onWheel); - }, [controller, toDevice]); + }, [controller, id, isTool, toDevice]); const onKeyDown = (e: React.KeyboardEvent) => { if (!interactiveRef.current) return; + if (isTool) markSessionTouched(id); e.preventDefault(); controller.handleKeyDownLike(e); }; @@ -288,7 +292,10 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // lives outside the pane — notably the header's URL editor. if (isEditableTarget(e.target)) return; e.preventDefault(); - if (e.type === 'keydown') controller.handleKeyDownLike(e); + if (e.type === 'keydown') { + if (isTool) markSessionTouched(id); + controller.handleKeyDownLike(e); + } else controller.sendKeyUp(e); }; window.addEventListener('keydown', forward, true); @@ -297,7 +304,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r window.removeEventListener('keydown', forward, true); window.removeEventListener('keyup', forward, true); }; - }, [controller, interactive]); + }, [controller, id, interactive, isTool]); // 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). @@ -331,6 +338,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r tabIndex={-1} className={`flex h-full w-full flex-col overflow-hidden bg-terminal-bg outline-none ${TERMINAL_BOTTOM_RADIUS_CLASS}`} onMouseDown={() => { + if (isTool) markSessionTouched(id); actions.onClickPanel(id); // Deferred so it lands after the browser's own focus handling for this // mousedown (same trick as enterTerminalMode's focusSession). diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index cd13be040..5deff9365 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -5,6 +5,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; +import * as terminalRegistry from '../../lib/terminal-registry'; import type { PlatformAdapter } from '../../lib/platform/types'; import type { PaneProps } from './pane-props'; import { IframePanel } from './IframePanel'; @@ -137,6 +138,31 @@ describe('IframePanel', () => { expect(getAgentBrowserScreenController('iframe-proxied')?.chrome().url).toBe('http://example.test/other/?q=1#frag'); }); + it('marks a tool touched when its proxied browser receives input', async () => { + const markTouched = vi.spyOn(terminalRegistry, 'markSessionTouched').mockImplementation(() => {}); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'createIframeProxyUrl'>; + platform.createIframeProxyUrl = vi.fn(async () => ({ + ok: true, + url: 'http://127.0.0.1:61234/app', + upstream: 'http://example.test/app', + })); + setPlatform(platform); + await renderPanel(stubActions(), { + id: 'iframe-tool', + title: 'Tool', + params: { surfaceType: 'tool', url: 'http://example.test/app' }, + }); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { + origin: 'http://127.0.0.1:61234', + data: { __dormouse: 'pointerdown' }, + })); + }); + + expect(markTouched).toHaveBeenCalledWith('iframe-tool'); + }); + it('re-resolves the proxy on Back after an observed in-frame navigation', async () => { const updateParameters = vi.fn(); const platform = new FakePtyAdapter() as FakePtyAdapter & Pick<PlatformAdapter, 'agentBrowserOpen' | 'createIframeProxyUrl'>; @@ -169,3 +195,36 @@ describe('IframePanel', () => { expect(createProxy.mock.calls.length).toBeGreaterThan(callsBeforeBack); }); }); + +describe('the pop-out affordance on a tool (regression: PR #493 review)', () => { + // A tool's `render` is `iframe` or `ab-screencast`, so pop-out has no + // renderer to land in: offering it tears the browser down and re-derives the + // same screencast, so the user asks for a native window and gets a reload. + // `FakePtyAdapter` has no `agentBrowserPopOut`, so both cases would read + // `false` off the stock fake — attach one first, or the assertion is vacuous. + function withPopOutCapableHost() { + const platform = new FakePtyAdapter() as FakePtyAdapter & { agentBrowserPopOut: () => Promise<unknown> }; + platform.agentBrowserPopOut = async () => ({ ok: true }); + setPlatform(platform); + } + + it('offers pop-out on a plain browser surface', async () => { + withPopOutCapableHost(); + await renderPanel(stubActions({}), { + id: 'iframe-plain', + title: 'Plain', + params: { surfaceType: 'browser', url: 'http://example.test/app' }, + }); + expect(getAgentBrowserScreenController('iframe-plain')?.canPopOut).toBe(true); + }); + + it('never offers it on a tool', async () => { + withPopOutCapableHost(); + await renderPanel(stubActions({}), { + id: 'iframe-tool', + title: 'storybook', + params: { surfaceType: 'tool', url: 'http://localhost:6006/' }, + }); + expect(getAgentBrowserScreenController('iframe-tool')?.canPopOut).toBe(false); + }); +}); diff --git a/lib/src/components/wall/IframePanel.tsx b/lib/src/components/wall/IframePanel.tsx index df1125ce0..98c47d13d 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -1,8 +1,8 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; +import { PANE_MESSAGE_CLASS, TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; import { getPlatform } from '../../lib/platform'; import { registerProxyOrigin } from '../../lib/iframe-proxy-registry'; -import { registerSurfaceFocusHandle } from '../../lib/terminal-registry'; +import { markSessionTouched, registerSurfaceFocusHandle } from '../../lib/terminal-registry'; import type { IframeProxyResult } from '../../lib/platform/types'; import type { PaneProps } from './pane-props'; import { usePaneChrome } from './use-pane-chrome'; @@ -14,6 +14,7 @@ import { type ScreenActions, type ScreenRegistration, } from './agent-browser-screen'; +import { isToolParams } from './browser-surface'; import { hostPathDisplay } from './browser-url'; // Sandbox the proxied frame so a tool's `if (top !== self) top.location = …` @@ -83,6 +84,7 @@ export function IframePanel({ id, title, params }: PaneProps) { const iframeRef = useRef<HTMLIFrameElement>(null); usePaneChrome(id, elRef); const sourceUrl = typeof params?.url === 'string' ? params.url : ''; + const isTool = isToolParams(params); const [liveUrl, setLiveUrl] = useState(sourceUrl); // A new-tab/window request from the proxy shim, pending the user's choice to // open it as a new pane (docs/specs/dor-browser.md → "Iframe Shim"). @@ -222,12 +224,15 @@ export function IframePanel({ id, title, params }: PaneProps) { chromeActions, hostCapable: false, // embed→popout spawns the new agent-browser headed and mounts it - // popped-out, so it needs both spawn and pop-out host capabilities. - canPopOut: !!getPlatform().agentBrowserPopOut, + // popped-out, so it needs both spawn and pop-out host capabilities. Never + // for a tool, which has no third renderer to land in + // (docs/specs/dor-tool.md -> Declaring tools); the other registration + // site is `agent-browser-surface-controller.ts`. + canPopOut: !isTool && !!getPlatform().agentBrowserPopOut, }); registrationRef.current = registration; return () => { registration.dispose(); registrationRef.current = null; }; - }, [id, swapCapable, screenActions, chromeActions]); + }, [id, swapCapable, screenActions, chromeActions, isTool]); // Keep the header's URL current as navigation and in-frame location changes // land. The iframe src is still driven only by sourceUrl. useEffect(() => { @@ -255,6 +260,7 @@ export function IframePanel({ id, title, params }: PaneProps) { if (e.origin !== proxyOrigin) return; const data = e.data as { __dormouse?: unknown; url?: unknown } | null; if (data?.__dormouse === 'pointerdown') { + if (isTool) markSessionTouched(id); actions.onClickPanel(id); return; } @@ -272,7 +278,7 @@ export function IframePanel({ id, title, params }: PaneProps) { }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); - }, [id, proxyOrigin, actions, liveUrl, sourceUrl, observeFrameUrl]); + }, [id, proxyOrigin, actions, liveUrl, sourceUrl, observeFrameUrl, isTool]); // Raw fallback frames have no injected shim, but focusing a cross-origin // iframe still blurs the parent window while the document itself remains @@ -282,12 +288,13 @@ export function IframePanel({ id, title, params }: PaneProps) { if (resolution.kind !== 'raw') return; const onWindowBlur = () => { if (document.hasFocus() && document.activeElement === iframeRef.current) { + if (isTool) markSessionTouched(id); actions.onClickPanel(id); } }; window.addEventListener('blur', onWindowBlur); return () => window.removeEventListener('blur', onWindowBlur); - }, [id, resolution.kind, actions]); + }, [id, resolution.kind, actions, isTool]); // Register a focus handle so onClickPanel → enterTerminalMode can focus the // frame like any other surface, and exitTerminalMode can hand focus back. @@ -326,7 +333,10 @@ export function IframePanel({ id, title, params }: PaneProps) { // with the frame, collapsing the offset to ~0. It's identity, so // getBoundingClientRect (overlay measurement) is unaffected. style={{ transform: 'translateZ(0)' }} - onMouseDown={() => actions.onClickPanel(id)} + onMouseDown={() => { + if (isTool) markSessionTouched(id); + actions.onClickPanel(id); + }} > {src ? ( <iframe @@ -380,7 +390,7 @@ export function IframePanel({ id, title, params }: PaneProps) { } function PanelMessage({ resolution, url }: { resolution: Resolution; url: string }) { - const base = 'flex h-full w-full items-center justify-center bg-terminal-bg px-6 text-center text-sm text-muted'; + const base = `${PANE_MESSAGE_CLASS} text-muted`; if (resolution.kind === 'resolving') { return <div className={base}>Connecting to <span className="ml-1 font-semibold">{url}</span>…</div>; diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index 35e9c56b6..9a89f9953 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -27,6 +27,8 @@ import { nowMs, type LathWallEngine } from './lath-wall-engine'; import { type DragController, createDragController } from './lath-drag-controller'; import { TerminalPanel } from './TerminalPanel'; import { BrowserPanel } from './BrowserPanel'; +import { ToolPanel } from './ToolPanel'; +import { ToolPaneHeader } from './ToolPaneHeader'; import { TerminalPaneHeader } from './TerminalPaneHeader'; import { SurfacePaneHeader } from './SurfacePaneHeader'; import { AlertSpeechIndicator } from './AlertSpeechIndicator'; @@ -92,10 +94,14 @@ export type LathComponentsOverride = { const BODY_COMPONENTS: Record<string, ComponentType<PaneProps>> = { terminal: TerminalPanel, browser: BrowserPanel, + // A tool is both, one Session deep; ToolPanel keeps each mounted and flips + // visibility (docs/specs/dor-tool.md). + tool: ToolPanel, }; const TAB_COMPONENTS: Record<string, ComponentType<PaneProps>> = { terminal: TerminalPaneHeader, surface: SurfacePaneHeader, + tool: ToolPaneHeader, }; /** For a terminal Surface the pane id is its session id (docs/specs/layout.md). */ @@ -109,6 +115,8 @@ function TerminalLeafOverlay({ id }: PaneProps) { // one way plus a surface-kind branch in the render path. const OVERLAY_COMPONENTS: Record<string, ComponentType<PaneProps>> = { terminal: TerminalLeafOverlay, + // A tool has a PTY, so it rings like a terminal whichever half is forward. + tool: TerminalLeafOverlay, }; type DragState = { diff --git a/lib/src/components/wall/ToolApproval.tsx b/lib/src/components/wall/ToolApproval.tsx new file mode 100644 index 000000000..c7861fb04 --- /dev/null +++ b/lib/src/components/wall/ToolApproval.tsx @@ -0,0 +1,68 @@ +/** + * The approval a tool waits on before it runs + * (`docs/specs/dor-tool.md` -> Trust). + * + * `dormouse.yml` is repo-controlled and its entries execute, so this is the only + * thing that grants trust. It is rendered in the tool's own pane rather than as a + * modal for two reasons: several pending tools can coexist without fighting over + * one dialog, and "close" has something to close. It is still Dormouse's own + * chrome — a click here is not reachable from inside a PTY, which a prompt + * printed into the terminal would be, since `dor send` can forge keystrokes. + * + * The pane holds no PTY while this is showing. Nothing from the repo has run. + */ +import { PANE_MESSAGE_CLASS, modalActionButton } from '../design'; +import { toolPendingFromParams } from './browser-surface'; +import type { PaneProps } from './pane-props'; + +export function ToolApproval({ params, id, onResolve }: PaneProps & { + onResolve: (id: string, choice: 'upstream' | 'folder' | 'decline') => void; +}) { + const pending = toolPendingFromParams(params); + if (!pending) return null; + + return ( + <div className={`${PANE_MESSAGE_CLASS} flex-col gap-4`}> + <div className="flex flex-col gap-1 font-mono text-muted"> + <div className="text-foreground">dor tool {pending.name}</div> + <div>will launch</div> + <code className="rounded bg-app-bg px-2 py-1 text-foreground">{pending.run}</code> + <div>and then open a browser</div> + </div> + + <div className="flex w-full max-w-[30rem] flex-col gap-2"> + {/* Omitted when git named no remote: there is no URL to key a grant on, + so the folder is the only honest scope. */} + {pending.upstreamUrl ? ( + <button + type="button" + className={modalActionButton({ tone: 'primary' })} + onClick={() => onResolve(id, 'upstream')} + > + Always allow for upstream {pending.upstreamUrl} + </button> + ) : null} + <button + type="button" + className={modalActionButton()} + onClick={() => onResolve(id, 'folder')} + > + Always allow for folder {pending.projectRoot} + </button> + <button + type="button" + className={modalActionButton()} + onClick={() => onResolve(id, 'decline')} + > + Disallow and close + </button> + </div> + + <div className="max-w-[30rem] text-xs text-muted/80"> + {pending.path} decides what this runs. Allowing the upstream covers every + worktree of it; allowing the folder covers this checkout only. Declining + records nothing. + </div> + </div> + ); +} diff --git a/lib/src/components/wall/ToolPaneHeader.tsx b/lib/src/components/wall/ToolPaneHeader.tsx new file mode 100644 index 000000000..342e3db28 --- /dev/null +++ b/lib/src/components/wall/ToolPaneHeader.tsx @@ -0,0 +1,55 @@ +/** + * Header for a `tool` Surface (`docs/specs/dor-tool.md` -> Lifecycle). + * + * A leading chip toggles which half is forward, then the header for whichever + * half that is: the terminal's while the tool is booting or pinned back, the + * browser chrome once it serves. Delegating rather than reimplementing keeps + * one header per capability — a tool's browser gets the same URL editor, nav + * buttons, and Display modal a plain browser Surface has, minus pop-out: a + * tool has no third renderer to land in (`docs/specs/dor-tool.md` -> + * Declaring tools). + */ +import { useContext } from 'react'; +import { Terminal, Globe } from '@phosphor-icons/react'; +import { chromeButton } from '../design'; +import { SurfacePaneHeader } from './SurfacePaneHeader'; +import { TerminalPaneHeader } from './TerminalPaneHeader'; +import { toolFace, toolSecondFace } from './browser-surface'; +import { WallActionsContext } from './wall-context'; +import type { PaneProps } from './pane-props'; + +export function ToolPaneHeader(props: PaneProps) { + const actions = useContext(WallActionsContext); + const face = toolFace(props.params); + const secondHalfForward = face !== 'terminal'; + // A tool that has neither served nor hit a port conflict has nothing to + // toggle to: the chip would offer a second half that is empty. The conflict + // counts, so the user can read the explanation and flip back. + const canToggle = toolSecondFace(props.params) !== null; + + return ( + <div className="flex h-full min-w-0 flex-1 items-center"> + {canToggle ? ( + <button + type="button" + className={`${chromeButton()} ml-1 shrink-0`} + title={secondHalfForward ? 'Show terminal' : 'Show browser'} + aria-label={secondHalfForward ? 'Show terminal' : 'Show browser'} + aria-pressed={!secondHalfForward} + onClick={(event) => { + // The header row is also the drag handle and the select target. + event.stopPropagation(); + actions.onToggleToolTerminal?.(props.id); + }} + > + {secondHalfForward ? <Terminal size={13} weight="bold" /> : <Globe size={13} weight="bold" />} + </button> + ) : null} + <div className="flex h-full min-w-0 flex-1 items-center"> + {/* Browser chrome only for a real browser: a conflict has no URL to + edit and nothing to navigate. */} + {face === 'browser' ? <SurfacePaneHeader {...props} /> : <TerminalPaneHeader {...props} />} + </div> + </div> + ); +} diff --git a/lib/src/components/wall/ToolPanel.test.tsx b/lib/src/components/wall/ToolPanel.test.tsx new file mode 100644 index 000000000..0b9d33703 --- /dev/null +++ b/lib/src/components/wall/ToolPanel.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToolPanel } from './ToolPanel'; + +vi.mock('./TerminalPanel', () => ({ + TerminalPanel: () => <div data-testid="terminal">terminal</div>, +})); +vi.mock('./BrowserPanel', () => ({ + BrowserPanel: ({ parked }: { parked?: boolean }) => ( + <div data-testid="browser" data-parked={String(parked === true)}>browser</div> + ), +})); + +const booting = { surfaceType: 'tool', command: 'pnpm storybook', cwd: '/repo' }; +const serving = { ...booting, url: 'http://localhost:6006/', renderMode: 'iframe' }; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function show(params: Record<string, unknown>) { + act(() => { + root.render(<ToolPanel id="p1" title="t" params={params} />); + }); +} + +/** The wrapper the visibility is applied to. */ +function half(testId: string): HTMLElement { + const el = container.querySelector<HTMLElement>(`[data-testid="${testId}"]`); + if (!el?.parentElement) throw new Error(`no ${testId}`); + return el.parentElement; +} + +describe('ToolPanel', () => { + it('keeps both halves mounted, whichever is forward', () => { + show(booting); + expect(container.querySelector('[data-testid="terminal"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="browser"]')).not.toBeNull(); + show(serving); + expect(container.querySelector('[data-testid="terminal"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="browser"]')).not.toBeNull(); + }); + + it('hides with visibility, never display', () => { + // A display:none container measures zero, so the fit addon would resize the + // PTY to a degenerate size and reflow the output of the command still + // running behind the browser. + show(serving); + const terminal = half('terminal'); + expect(terminal.style.visibility).toBe('hidden'); + expect(terminal.style.display).not.toBe('none'); + expect(terminal.hasAttribute('hidden')).toBe(false); + }); + + it('shows the terminal and hides the browser before the tool serves', () => { + show(booting); + expect(half('terminal').style.visibility).toBe('visible'); + expect(half('browser').style.visibility).toBe('hidden'); + }); + + it('flips once it serves, and back when the header pins the terminal', () => { + show(serving); + expect(half('browser').style.visibility).toBe('visible'); + show({ ...serving, showTerminal: true }); + expect(half('terminal').style.visibility).toBe('visible'); + expect(half('browser').style.visibility).toBe('hidden'); + }); + + it('parks the browser while it is hidden, so a screencast stops decoding', () => { + show(booting); + expect(container.querySelector<HTMLElement>('[data-testid="browser"]')?.dataset.parked).toBe('true'); + show(serving); + expect(container.querySelector<HTMLElement>('[data-testid="browser"]')?.dataset.parked).toBe('false'); + }); + + it('keeps the hidden half out of the accessibility tree', () => { + show(serving); + expect(half('terminal').getAttribute('aria-hidden')).toBe('true'); + expect(half('browser').getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('the port-conflict face', () => { + const conflicted = { surfaceType: 'tool', command: 'x', cwd: '/repo', toolPortConflict: [6006, 6007] }; + + it('shows the conflict where the browser would have gone', () => { + // With several ports there is nothing to frame, so the second half explains + // why rather than sitting empty or framing a guess. + show(conflicted); + expect(half('terminal').style.visibility).toBe('hidden'); + expect(container.textContent).toContain('opened 2 ports'); + expect(container.textContent).toContain('localhost:6006'); + expect(container.textContent).toContain('localhost:6007'); + }); + + it('mounts no browser for a conflict', () => { + show(conflicted); + expect(container.querySelector('[data-testid="browser"]')).toBeNull(); + }); + + it('flips back to the terminal when the chip pins it', () => { + show({ ...conflicted, showTerminal: true }); + expect(half('terminal').style.visibility).toBe('visible'); + }); +}); + +describe('the pending-approval face', () => { + const pending = { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolPending: { + name: 'storybook', + run: 'pnpm storybook', + path: '/repo/dormouse.yml', + projectRoot: '/repo', + minimized: false, + upstreamUrl: 'https://github.com/diffplug/dormouse', + }, + }; + + it('mounts no terminal, so no shell runs in an unapproved repo', () => { + // The load-bearing assertion: both halves stay mounted for every other + // face, and mounting TerminalPanel here would spawn a PTY before the human + // has allowed anything. + show(pending); + expect(container.querySelector('[data-testid="terminal"]')).toBeNull(); + expect(container.querySelector('[data-testid="browser"]')).toBeNull(); + }); + + it('names the command it is asking about', () => { + show(pending); + expect(container.textContent).toContain('dor tool storybook'); + expect(container.textContent).toContain('pnpm storybook'); + }); + + it('offers the upstream and the folder', () => { + show(pending); + const labels = [...container.querySelectorAll('button')].map((b) => b.textContent ?? ''); + expect(labels.some((l) => l.includes('upstream https://github.com/diffplug/dormouse'))).toBe(true); + expect(labels.some((l) => l.includes('folder'))).toBe(true); + expect(labels.some((l) => l.includes('Disallow and close'))).toBe(true); + }); + + it('omits the upstream button when git resolved no remote', () => { + show({ ...pending, toolPending: { ...pending.toolPending, upstreamUrl: null } }); + const labels = [...container.querySelectorAll('button')].map((b) => b.textContent ?? ''); + expect(labels.some((l) => l.includes('upstream'))).toBe(false); + expect(labels.some((l) => l.includes('folder'))).toBe(true); + }); + + it('takes precedence over the terminal pin, since there is no terminal yet', () => { + show({ ...pending, showTerminal: true }); + expect(container.querySelector('[data-testid="terminal"]')).toBeNull(); + }); +}); diff --git a/lib/src/components/wall/ToolPanel.tsx b/lib/src/components/wall/ToolPanel.tsx new file mode 100644 index 000000000..d87ed44ed --- /dev/null +++ b/lib/src/components/wall/ToolPanel.tsx @@ -0,0 +1,74 @@ +/** + * The body of a `tool` Surface: one Session with a terminal and, once it + * serves, a browser (`docs/specs/dor-tool.md` -> Lifecycle). + */ +import { useContext } from 'react'; +import { BrowserPanel } from './BrowserPanel'; +import { TerminalPanel } from './TerminalPanel'; +import { ToolApproval } from './ToolApproval'; +import { ToolPortConflict } from './ToolPortConflict'; +import { toolFace } from './browser-surface'; +import { WallActionsContext } from './wall-context'; +import type { PaneProps } from './pane-props'; + +/** + * Both halves stay mounted for the Surface's whole life — unmounting the + * terminal would drop the xterm buffer the command is still writing to, and + * unmounting the browser would reload the framed document on every flip. + * + * So the flip is `visibility`, never `hidden`/`display: none`: a display-none + * container has no box, so the fit addon would measure zero and resize the PTY + * to a degenerate size, reflowing the output of the command still running + * behind the browser. Both halves are absolutely positioned over the same area, + * so each always measures the pane's real dimensions. `inert` keeps the hidden + * half out of the tab order and the accessibility tree. + */ +function Half({ shown, children }: { shown: boolean; children: React.ReactNode }) { + return ( + <div + className="absolute inset-0" + style={{ visibility: shown ? 'visible' : 'hidden' }} + aria-hidden={!shown} + inert={!shown} + > + {children} + </div> + ); +} + +export function ToolPanel(props: PaneProps) { + const face = toolFace(props.params); + const actions = useContext(WallActionsContext); + + // Rendered alone, not as one of two halves: mounting TerminalPanel would spawn + // a shell in a repo the user has not approved yet. Nothing runs until they do. + if (face === 'pending-approval') { + return ( + <ToolApproval + {...props} + onResolve={(id, choice) => void actions.onResolveToolApproval?.(id, choice)} + /> + ); + } + + const showSecond = face !== 'terminal'; + return ( + <div className="relative h-full w-full"> + <Half shown={!showSecond}> + <TerminalPanel {...props} /> + </Half> + <Half shown={showSecond}> + {/* A conflict and a browser are mutually exclusive by construction — + autobind writes a conflict only when it declined to write a URL — so + swapping the second half's content loses no browser state. */} + {face === 'port-conflict' ? ( + <ToolPortConflict {...props} /> + ) : ( + /* Parked while hidden, so a screencast idles instead of decoding + frames nobody is looking at (`useSurfaceVisibility`). */ + <BrowserPanel {...props} parked={props.parked || !showSecond} /> + )} + </Half> + </div> + ); +} diff --git a/lib/src/components/wall/ToolPortConflict.tsx b/lib/src/components/wall/ToolPortConflict.tsx new file mode 100644 index 000000000..888712677 --- /dev/null +++ b/lib/src/components/wall/ToolPortConflict.tsx @@ -0,0 +1,38 @@ +/** + * Shown in a tool's browser half when autobind refused to choose + * (`docs/specs/dor-tool.md` -> Serving). + * + * It sits where the browser would have gone on purpose: with several ports + * bound there is nothing to frame, so the pane's second half explains why + * rather than sitting empty or silently framing a guess. + */ +import { PANE_MESSAGE_CLASS } from '../design'; +import { toolPortConflictFromParams } from './browser-surface'; +import type { PaneProps } from './pane-props'; + +export function ToolPortConflict({ params }: PaneProps) { + const ports = toolPortConflictFromParams(params) ?? []; + + return ( + <div className={`${PANE_MESSAGE_CLASS} flex-col gap-3 text-muted`}> + <div className="text-foreground"> + This tool opened {ports.length} ports, so Dormouse did not frame any of them. + </div> + <ul className="flex flex-col gap-0.5 font-mono text-xs"> + {ports.map((port) => ( + <li key={port}>localhost:{port}</li> + ))} + </ul> + <div className="flex flex-col gap-1 text-xs text-muted/80"> + <div> + Have the tool announce its port, or set{' '} + <code className="rounded bg-app-bg px-1 py-0.5">port: announced</code> in dormouse.yml. + </div> + <div> + <code className="rounded bg-app-bg px-1 py-0.5">port: auto</code> frames a port only when + there is exactly one. + </div> + </div> + </div> + ); +} diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index d61412f24..1e881783e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -19,6 +19,7 @@ import { openAgentBrowserScreenModal, } from './agent-browser-screen'; import { hostPathDisplay, tabDisplayTitle } from './browser-url'; +import { isToolParams } from './browser-surface'; import { clearAgentBrowserSessionClosed, isAgentBrowserSessionClosed } from './agent-browser-sessions'; import { EDIT_OPS, @@ -161,6 +162,8 @@ const EMPTY_TABS: StreamTab[] = []; export class AgentBrowserSurfaceController { readonly id: string; + /** Gates the pop-out affordance; see `ensureStarted`. */ + private readonly isTool: boolean; // --- params (mirrors of the persisted blob) --- private session: string | undefined; @@ -289,6 +292,9 @@ export class AgentBrowserSurfaceController { constructor(id: string, params: AgentBrowserSurfaceParams) { this.id = id; + // A Surface's kind never changes over its life (a tool's capabilities come + // and go, its identity does not), so this is safe to seed once. + this.isTool = isToolParams(params); this.session = params.session; this.binaryPath = params.binaryPath; this.wsPort = params.wsPort; @@ -406,7 +412,11 @@ export class AgentBrowserSurfaceController { chrome: this.chrome, chromeActions: this.chromeActions, hostCapable: !!getPlatform().agentBrowserCommand, - canPopOut: !!getPlatform().agentBrowserPopOut, + // Never for a tool, whose `render` is `iframe` or `ab-screencast`: the + // swap would tear the browser down and re-derive the same screencast, so + // asking for a native window would get a reload + // (`docs/specs/dor-tool.md` -> Declaring tools). + canPopOut: !this.isTool && !!getPlatform().agentBrowserPopOut, }); this.lastPublishedScreen = null; this.publishScreen(); diff --git a/lib/src/components/wall/browser-surface.ts b/lib/src/components/wall/browser-surface.ts index 6f5e5b8cb..144530a80 100644 --- a/lib/src/components/wall/browser-surface.ts +++ b/lib/src/components/wall/browser-surface.ts @@ -12,6 +12,12 @@ type BrowserParamsLike = { renderMode?: unknown; session?: unknown; url?: unknown; + /** Tool only: the header chip pinning the terminal forward past serving. */ + showTerminal?: unknown; + /** Tool only: the ports found when autobind refused to choose. */ + toolPortConflict?: unknown; + /** Tool only: the approval this Surface is waiting on before it runs. */ + toolPending?: unknown; }; function asParams(params: unknown): BrowserParamsLike { @@ -31,10 +37,114 @@ export function isAgentBrowserParams(params: unknown): boolean { return p.renderMode === 'ab-screencast' || p.renderMode === 'ab-popout'; } -/** Whether params describe any browser surface (vs a terminal): the unified - * 'browser' type, or anything carrying a renderMode. */ +/** Whether params describe a `tool` Surface — one Session with a terminal and, + * once it serves, a browser (`docs/specs/dor-tool.md`). Checked before the + * browser test below, because a serving tool also carries a `renderMode`. */ +export function isToolParams(params: unknown): boolean { + return asParams(params).surfaceType === 'tool'; +} + +/** The ports autobind found when it refused to choose among them, or null. + * Derived state, never persisted — see `persistableLeafMeta`. */ +export function toolPortConflictFromParams(params: unknown): number[] | null { + const value = asParams(params).toolPortConflict; + return Array.isArray(value) && value.length > 0 && value.every((p) => typeof p === 'number') + ? (value as number[]) + : null; +} + +/** What a pending tool is waiting to be allowed to run. */ +export interface ToolPending { + readonly name: string; + readonly run: string; + readonly path: string; + readonly projectRoot: string; + /** Requested at launch; applied after approval, since a pane the user cannot + * see is a pane they cannot approve. */ + readonly minimized: boolean; + readonly upstreamUrl: string | null; +} + +/** The approval a tool Surface is waiting on, or null once it may run. */ +export function toolPendingFromParams(params: unknown): ToolPending | null { + const value = asParams(params).toolPending; + if (!value || typeof value !== 'object') return null; + const pending = value as Record<string, unknown>; + const strings = ['name', 'run', 'path', 'projectRoot'] as const; + if (!strings.every((field) => typeof pending[field] === 'string')) return null; + if (typeof pending.minimized !== 'boolean') return null; + if (pending.upstreamUrl !== null && typeof pending.upstreamUrl !== 'string') return null; + return pending as unknown as ToolPending; +} + +/** + * Which of a tool's faces is forward. A three-state answer rather than a + * boolean because the header and the body must agree: a port conflict occupies + * the browser's place (there is nothing to frame, so the pane shows *why* + * where the browser would have been) but has no URL to edit, so it must not + * get browser chrome. Which halves are *mounted* never changes; see + * `ToolPanel.tsx`. + * + * `browser` and `port-conflict` are mutually exclusive by construction — + * autobind writes a conflict only when it declined to write a URL. + */ +export type ToolFace = 'terminal' | 'browser' | 'port-conflict' | 'pending-approval'; + +/** What occupies the tool's second half, ignoring the terminal pin, or null + * when it has none yet — the header chip's gate as well as `toolFace`'s + * browser branch, so both read the mutual exclusion from one place. */ +export function toolSecondFace(params: unknown): 'browser' | 'port-conflict' | null { + if (!isToolParams(params)) return null; + if (toolPortConflictFromParams(params) !== null) return 'port-conflict'; + return browserUrlFromParams(params) !== null ? 'browser' : null; +} + +export function toolFace(params: unknown): ToolFace { + if (!isToolParams(params)) return 'terminal'; + // Checked before everything, including the terminal pin: until the human + // approves, there is no terminal to show — nothing has spawned. + if (toolPendingFromParams(params) !== null) return 'pending-approval'; + if (asParams(params).showTerminal === true) return 'terminal'; + return toolSecondFace(params) ?? 'terminal'; +} + +/** Whether a tool Surface's params carry `key`. A null or absent key never + * matches — not even another null: a tool has an identity if and only if it + * was given one, so two identityless tools are two tools + * (`docs/specs/dor-tool.md` -> Identity and dedupe). */ +export function toolKeysEqual(paramsKey: unknown, key: readonly string[] | null): boolean { + if (key === null || !Array.isArray(paramsKey)) return false; + return paramsKey.length === key.length && paramsKey.every((element, index) => element === key[index]); +} + +/** + * Namespace a declared key under the tool identity the *host* resolved from the + * spawn (`docs/specs/dor-tool.md` -> Identity and dedupe). + * + * Two things depend on this, and both break without it. Scope-only keys are + * legal — the spec calls the declared list "scope inside that namespace" — so + * `docs` and `api` both declaring `[$PROJECT_ROOT]` must stay distinct. And a + * key that arrives at runtime over OSC 367 comes from process output: without a + * namespace it could name another tool's key, and the next `dor tool <that + * tool>` would adopt — and Ctrl+C and re-run — the announcing pane instead. + * + * `null` for an identityless tool, which never matches anything, so an OSC + * re-key cannot mint an identity for a `dor tool -- <command>`. + */ +export function namespacedToolKey( + toolName: string | null, + key: readonly string[] | null, +): string[] | null { + if (!toolName || key === null) return null; + return [toolName, ...key]; +} + +/** Whether params describe a plain browser surface (vs a terminal): the unified + * 'browser' type, or anything carrying a renderMode. A tool is neither — it is + * its own kind, and `isToolParams` answers for it. */ export function isBrowserParams(params: unknown): boolean { const p = asParams(params); + if (isToolParams(params)) return false; return p.surfaceType === 'browser' || typeof p.renderMode === 'string'; } @@ -46,6 +156,7 @@ export function isBrowserParams(params: unknown): boolean { * kind is `use-session-persistence.ts`, where this return flows into the * narrower `PersistedSurfaceType`. */ export function surfaceKindFromParams(params: unknown): SurfaceKind { + if (isToolParams(params)) return 'tool'; return isBrowserParams(params) ? 'browser' : 'terminal'; } diff --git a/lib/src/components/wall/connect-port.ts b/lib/src/components/wall/connect-port.ts index c4501a4b2..c249e336a 100644 --- a/lib/src/components/wall/connect-port.ts +++ b/lib/src/components/wall/connect-port.ts @@ -18,8 +18,8 @@ import type { ParseResult } from 'dor/commands/types'; // keeps cross-spawn (the package's Node-only default export) out of the webview. import { sessionForKey } from 'dor-lib-common/agent-browser'; -/** The host capabilities `connectPortToDefaultBrowser` needs — the same two the - * CLI path leans on, narrowed so tests can stub them without a full adapter. */ +/** The host capabilities this module needs — the same two the CLI path leans + * on, narrowed so tests can stub them without a full adapter. */ type ConnectPlatform = Pick<PlatformAdapter, 'agentBrowserCommand' | 'agentBrowserStreamStatus'>; export type ConnectPortResult = { ok: true } | { ok: false; message: string }; @@ -53,14 +53,47 @@ export async function connectPortToDefaultBrowser({ // Pane appears NOW, session-less — the controller can't race the daemon boot. const eager = ensureEagerSurface(session); if (!eager.ok) return { ok: false, message: eager.message }; + return attachAgentBrowserSession({ + url, + platform, + session, + binaryPath, + surfaceId: eager.value.surfaceId, + refreshSurface, + }); +} +/** + * Open `url` in `session` and hand `surfaceId` the resulting `{session, wsPort, + * binaryPath}` as one params write — the tail both the context-menu connect and + * the tool serving trigger (`use-tool-serving.ts`) share. + * + * The surface gets its `session` whether or not the open succeeded, so a failed + * pane's placeholder names the session instead of sitting session-less. + */ +export async function attachAgentBrowserSession({ + url, + platform, + session, + binaryPath, + surfaceId, + refreshSurface, +}: { + url: string; + platform: ConnectPlatform; + session: string; + binaryPath?: string; + surfaceId: string; + refreshSurface: (surfaceId: string, patch: Record<string, unknown>) => void; +}): Promise<ConnectPortResult> { + if (!platform.agentBrowserCommand) { + return { ok: false, message: 'opening a browser surface is not supported on this host' }; + } // 'open' is on the host's subcommand allowlist; the CLI boots the daemon/browser // if it isn't already running. const opened = await platform.agentBrowserCommand(session, ['open', url], binaryPath); if (opened.exitCode !== 0) { - // The pane stays; hand it the session so its placeholder names the session - // instead of sitting sessionless. - refreshSurface(eager.value.surfaceId, { session }); + refreshSurface(surfaceId, { session }); return { ok: false, message: opened.stderr.trim() || `agent-browser open exited ${opened.exitCode}` }; } // Best-effort stream port so the panel connects straight to the live screencast; @@ -70,9 +103,9 @@ export async function connectPortToDefaultBrowser({ const status = await platform.agentBrowserStreamStatus(session, binaryPath); if (status.ok) wsPort = status.wsPort; } - // One params write reconciles the session-less pane: setting `session` connects - // the controller (the daemon is up now, so its recovery is safe to run). - refreshSurface(eager.value.surfaceId, { + // Setting `session` connects the controller (the daemon is up now, so its + // recovery is safe to run). + refreshSurface(surfaceId, { session, ...(wsPort !== undefined ? { wsPort } : {}), ...(binaryPath !== undefined ? { binaryPath } : {}), diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts index e15bccbac..e42674923 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts @@ -20,13 +20,13 @@ vi.mock('../../../lib/mouse-selection', () => ({ setSelection: vi.fn(), })); -function makeCtx(overrides: { surfaceType?: string } = {}): WallKeyboardCtx { +function makeCtx(params?: Record<string, unknown>): WallKeyboardCtx { return { selectedIdRef: { current: 'pane-a' }, // Surface-type lookup now flows through the engine-neutral `nav` seam; an // absent params reads as a terminal. nav: { - paneParams: () => (overrides.surfaceType ? { surfaceType: overrides.surfaceType } : undefined), + paneParams: () => params, findInDirection: () => null, hasPane: () => false, panes: () => [], @@ -81,13 +81,33 @@ describe('handleMouseSelectionKeys', () => { vi.mocked(doPaste).mockClear(); const e = fakeEvent(document.createElement('div'), { key: 'v', metaKey: true }); - const handled = handleMouseSelectionKeys(e, makeCtx({ surfaceType: 'agent-browser' })); + const handled = handleMouseSelectionKeys(e, makeCtx({ surfaceType: 'browser' })); expect(handled).toBe(false); expect(e.defaultPrevented).toBe(false); expect(doPaste).not.toHaveBeenCalled(); }); + it('routes clipboard keys only when a tool has its terminal forward', async () => { + const { doPaste } = await import('../../../lib/clipboard'); + vi.mocked(doPaste).mockClear(); + const terminal = { surfaceType: 'tool', command: 'pnpm storybook' }; + const browser = { ...terminal, url: 'http://localhost:6006/', renderMode: 'iframe' }; + + const terminalEvent = fakeEvent(document.createElement('div'), { key: 'v', metaKey: true }); + expect(handleMouseSelectionKeys(terminalEvent, makeCtx(terminal))).toBe(true); + expect(doPaste).toHaveBeenCalledWith('pane-a'); + + vi.mocked(doPaste).mockClear(); + const browserEvent = fakeEvent(document.createElement('div'), { key: 'v', metaKey: true }); + expect(handleMouseSelectionKeys(browserEvent, makeCtx(browser))).toBe(false); + expect(doPaste).not.toHaveBeenCalled(); + + const pinnedEvent = fakeEvent(document.createElement('div'), { key: 'v', metaKey: true }); + expect(handleMouseSelectionKeys(pinnedEvent, makeCtx({ ...browser, showTerminal: true }))).toBe(true); + expect(doPaste).toHaveBeenCalledWith('pane-a'); + }); + it('extends the selection to the hint token on "e" during a drag', async () => { const { getMouseSelectionState, extendSelectionToToken } = await import('../../../lib/mouse-selection'); const hintToken = { start: 0, end: 4 }; diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts index 2e6aae170..ea94bafa5 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts @@ -7,6 +7,8 @@ import { setSelection as setMouseSelection, } from '../../../lib/mouse-selection'; import { hasCopyModifier, hasPasteModifier } from './chords'; +import { hasTerminal } from 'dor/commands/types'; +import { surfaceKindFromParams, toolFace } from '../browser-surface'; import type { WallKeyboardCtx } from './types'; /** @@ -29,7 +31,7 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx) // These chords copy/paste against a terminal's pty and mouse selection. // Non-terminal surfaces (agent-browser, iframe) own their clipboard keys — // e.g. AgentBrowserPanel forwards cmd-V to the embedded page — so yield. - if (surfaceTypeForId(ctx, sid) !== 'terminal') return false; + if (!hasActiveTerminal(ctx, sid)) return false; const mouseState = getMouseSelectionState(sid); const sel = mouseState.selection; @@ -80,7 +82,12 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx) /** `paneParams` reads the store, which holds a Surface's params whether it is a pane * or a Door, so a minimized Surface needs no separate lookup. */ -function surfaceTypeForId(ctx: WallKeyboardCtx, id: string): string { - const params = ctx.nav.paneParams(id) as { surfaceType?: unknown } | undefined; - return typeof params?.surfaceType === 'string' ? params.surfaceType : 'terminal'; +function hasActiveTerminal(ctx: WallKeyboardCtx, id: string): boolean { + const params = ctx.nav.paneParams(id); + const kind = surfaceKindFromParams(params); + if (!hasTerminal(kind)) return false; + // A tool owns both capabilities, so only its forward half owns keyboard + // clipboard/selection handling. Pending approval and the second half have no + // active xterm even though the Surface kind is terminal-capable. + return kind !== 'tool' || toolFace(params) === 'terminal'; } diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index a1a359b69..3630f7f09 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -100,6 +100,18 @@ describe('handlePaneShortcuts kill behavior', () => { expect(ctx.setConfirmKill).toHaveBeenCalledWith({ id: 'pane-a', char: 'Q' }); }); + it('keeps confirmation for untouched tool panes', () => { + terminalRegistryMocks.isUntouched.mockReturnValue(true); + const ctx = makeCtx({ + nav: makeNav({ paneParams: () => ({ surfaceType: 'tool' }) }), + }); + + expect(handlePaneShortcuts(keydown('x'), ctx, { current: null })).toBe(true); + + expect(ctx.killPaneImmediately).not.toHaveBeenCalled(); + expect(ctx.setConfirmKill).toHaveBeenCalledWith({ id: 'pane-a', char: 'Q' }); + }); + it('reattaches untouched doors into an immediate kill path', () => { terminalRegistryMocks.isUntouched.mockReturnValue(true); const reattach = vi.fn(); @@ -130,6 +142,23 @@ describe('handlePaneShortcuts kill behavior', () => { { enterPassthrough: false, afterRestore: 'confirm-kill' }, ); }); + + it('reattaches untouched tool doors into the confirmation path', () => { + terminalRegistryMocks.isUntouched.mockReturnValue(true); + const reattach = vi.fn(); + const ctx = makeCtx({ + nav: makeNav({ paneParams: () => ({ surfaceType: 'tool' }) }), + selectedTypeRef: { current: 'door' }, + handleReattachRef: { current: reattach }, + }); + + expect(handlePaneShortcuts(keydown('x'), ctx, { current: null })).toBe(true); + + expect(reattach).toHaveBeenCalledWith( + { id: 'pane-a', title: 'Pane A' }, + { enterPassthrough: false, afterRestore: 'confirm-kill' }, + ); + }); }); describe('handlePaneShortcuts Cmd-Arrow swap (nav seam)', () => { diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 9a7ce0cd3..ff5e4fec8 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -5,6 +5,7 @@ import { toggleSessionTodo, } from '../../../lib/terminal-registry'; import { randomKillChar } from '../../KillConfirm'; +import { isToolParams } from '../browser-surface'; import { ARROW_OPPOSITES, isArrowKey, type NavHistoryRef, type WallKeyboardCtx } from './types'; function findAlertButtonForSession(id: string): HTMLButtonElement | null { @@ -81,17 +82,18 @@ export function handlePaneShortcuts( if ((e.key === 'k' || e.key === 'x') && sid) { e.preventDefault(); e.stopPropagation(); + const isTool = isToolParams(ctx.nav.paneParams(sid)); if (ctx.selectedTypeRef.current === 'door') { const item = ctx.doorsRef.current.find((d) => d.id === sid); if (item) { ctx.handleReattachRef.current(item, { enterPassthrough: false, - afterRestore: isUntouched(sid) ? 'kill-immediately' : 'confirm-kill', + afterRestore: !isTool && isUntouched(sid) ? 'kill-immediately' : 'confirm-kill', }); } return true; } - if (isUntouched(sid)) { + if (!isTool && isUntouched(sid)) { ctx.killPaneImmediately(sid); return true; } diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 104abfdda..73ab8fd7b 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -8,7 +8,7 @@ import type { WallActions } from '../wall-context'; export interface WallNav { /** Nearest pane id in the arrow's direction, or null. */ findInDirection(id: string, dir: 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'): string | null; - /** A visible pane's params (surface-type classification), or undefined. */ + /** A Surface's params (including a Door's surface-type classification), or undefined. */ paneParams(id: string): Record<string, unknown> | undefined; /** Whether `id` is a live visible pane. */ hasPane(id: string): boolean; diff --git a/lib/src/components/wall/lath-wall-engine.ts b/lib/src/components/wall/lath-wall-engine.ts index 1d454b012..480ab733d 100644 --- a/lib/src/components/wall/lath-wall-engine.ts +++ b/lib/src/components/wall/lath-wall-engine.ts @@ -92,6 +92,43 @@ export function browserLeafMeta(title: string, params: Record<string, unknown>): return { component: 'browser', tabComponent: 'surface', title, params }; } +/** Meta for a `tool` leaf — one Session with a terminal and, once it serves, a + * browser (`docs/specs/dor-tool.md`). Its own tab component, because the + * header follows whichever half is forward. */ +export function toolLeafMeta(title: string, params: Record<string, unknown>): LeafMeta { + return { component: 'tool', tabComponent: 'tool', title, params }; +} + +/** + * A tool's browser is derived, never restored: its port is whatever the command + * bound *this* run, so a persisted `url` would frame a dead address — and a + * persisted agent-browser `session` would name a daemon that is gone + * (`docs/specs/dor-tool.md` -> Persistence and hosts). A restored tool is a + * terminal running its command until it serves again, which is the same state a + * cold spawn passes through. Everything else about the leaf persists. + */ +export function persistableLeafMeta(meta: LeafMeta): LeafMeta { + if (meta.component !== 'tool' || !meta.params) return meta; + // A tool still awaiting approval persists as a plain empty terminal. Keeping + // it a tool would restore a pane that spawns a shell in a repo nobody + // approved, with no gesture at all — and the prompt cannot be restored either, + // since the grant it was asking for was never made + // (`docs/specs/dor-tool.md` -> Trust rule 3). + if (meta.params.toolPending !== undefined) { + return { component: 'terminal', tabComponent: 'terminal', title: meta.title }; + } + const { + url: _url, + session: _session, + wsPort: _wsPort, + renderMode: _renderMode, + showTerminal: _showTerminal, + toolPortConflict: _toolPortConflict, + ...rest + } = meta.params; + return { ...meta, params: rest }; +} + /** Hydration-only Door-row projection; runtime metadata stays in the store. */ export function leafMetaFromPersistedDoor(item: PersistedDoor): LeafMeta { return { @@ -107,7 +144,9 @@ export function leafMetaFromPersistedDoor(item: PersistedDoor): LeafMeta { * screencast canvas); terminals do not — the PTY holds their state and the registry * replays it (docs/specs/tiling-engine.md → "Parked leaves"). */ export function shouldParkOnMinimize(meta: LeafMeta): boolean { - return meta.component === 'browser'; + // A tool parks for the same reason a browser does: once it serves, its + // framed document lives in the pane's DOM and no registry can replay it. + return meta.component === 'browser' || meta.component === 'tool'; } export type LathWallEngine = { @@ -214,7 +253,13 @@ export function createLathWallEngine( }, getMeta: (id) => snapshot().leafMeta.get(id), - serializeLayout: () => lathLayoutFromStore(snapshot()), + serializeLayout: () => { + const snap = snapshot(); + return lathLayoutFromStore({ + tree: snap.tree, + leafMeta: new Map([...snap.leafMeta].map(([id, meta]) => [id, persistableLeafMeta(meta)])), + }); + }, seed(lathBlob, initialPaneIds, generatePaneId, doors) { // Doors ride into `leafMeta` beside the tree's leaves: a restored Door is a diff --git a/lib/src/components/wall/tool-surface.test.ts b/lib/src/components/wall/tool-surface.test.ts new file mode 100644 index 000000000..6d44f1b43 --- /dev/null +++ b/lib/src/components/wall/tool-surface.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { hasBrowser, hasTerminal } from 'dor/commands/types'; +import { + isBrowserParams, + isToolParams, + namespacedToolKey, + resolveRenderMode, + surfaceKindFromParams, + toolFace, + toolKeysEqual, + toolPendingFromParams, +} from './browser-surface'; +import { persistableLeafMeta, shouldParkOnMinimize, toolLeafMeta } from './lath-wall-engine'; +import { TOOLS_FLAG_KEY, isToolsEnabled, setToolsEnabled } from '../../lib/feature-flags'; + +const booting = { surfaceType: 'tool', command: 'pnpm storybook', cwd: '/repo' }; +const serving = { ...booting, url: 'http://localhost:6006/', renderMode: 'iframe' }; + +describe('tool params classification', () => { + it('classifies a tool as its own kind, before and after it serves', () => { + expect(surfaceKindFromParams(booting)).toBe('tool'); + expect(surfaceKindFromParams(serving)).toBe('tool'); + }); + + it('never classifies a serving tool as a browser, despite its renderMode', () => { + // The ordering that matters: `isBrowserParams` matches anything carrying a + // renderMode, so the tool test has to come first. + expect(isToolParams(serving)).toBe(true); + expect(isBrowserParams(serving)).toBe(false); + }); + + it('leaves plain terminals and browsers where they were', () => { + expect(surfaceKindFromParams(undefined)).toBe('terminal'); + expect(surfaceKindFromParams({ cwd: '/repo' })).toBe('terminal'); + expect(surfaceKindFromParams({ surfaceType: 'browser', url: 'https://x' })).toBe('browser'); + expect(surfaceKindFromParams({ renderMode: 'ab-screencast' })).toBe('browser'); + }); + + it('reports both capabilities, so row fields populate on both sides', () => { + const kind = surfaceKindFromParams(serving); + expect(hasTerminal(kind)).toBe(true); + expect(hasBrowser(kind)).toBe(true); + }); +}); + +describe('which half of a tool is forward', () => { + it('shows the terminal until the tool serves', () => { + expect(toolFace(booting)).toBe('terminal'); + }); + + it('shows the browser once it serves', () => { + expect(toolFace(serving)).toBe('browser'); + }); + + it('shows the terminal again when the header chip pins it', () => { + expect(toolFace({ ...serving, showTerminal: true })).toBe('terminal'); + }); + + it('shows the terminal after the command exits and the url is retired', () => { + expect(toolFace({ ...serving, url: undefined })).toBe('terminal'); + }); + + it('never claims a non-tool shows a tool browser', () => { + expect(toolFace({ surfaceType: 'browser', url: 'https://x' })).toBe('terminal'); + }); + + it('defaults a tool with no explicit renderMode to the iframe', () => { + expect(resolveRenderMode(booting)).toBe('iframe'); + }); +}); + +describe('tool key matching', () => { + it('matches element-wise', () => { + expect(toolKeysEqual(['a', '/r'], ['a', '/r'])).toBe(true); + expect(toolKeysEqual(['a', '/r'], ['a', '/s'])).toBe(false); + expect(toolKeysEqual(['a'], ['a', '/r'])).toBe(false); + }); + + it('never matches a keyless tool against anything, including another keyless one', () => { + expect(toolKeysEqual(undefined, ['a'])).toBe(false); + expect(toolKeysEqual(['a'], null)).toBe(false); + expect(toolKeysEqual(undefined, null)).toBe(false); + }); +}); + +describe('tool leaf meta', () => { + it('routes to the tool body and header', () => { + const meta = toolLeafMeta('storybook', booting); + expect(meta.component).toBe('tool'); + expect(meta.tabComponent).toBe('tool'); + }); + + it('parks on minimize, because a served document lives in the pane DOM', () => { + expect(shouldParkOnMinimize(toolLeafMeta('storybook', serving))).toBe(true); + // ...and a terminal still does not: the PTY holds its state and the + // registry replays it. + expect(shouldParkOnMinimize({ component: 'terminal', tabComponent: 'terminal', title: 't' })).toBe(false); + }); +}); + +describe('the tools flag', () => { + it('is off by default, so nothing is ever designated a tool', () => { + setToolsEnabled(false); + expect(isToolsEnabled()).toBe(false); + }); + + it('turns on and off through the documented localStorage key', () => { + setToolsEnabled(true); + expect(globalThis.localStorage.getItem(TOOLS_FLAG_KEY)).toBe('true'); + expect(isToolsEnabled()).toBe(true); + setToolsEnabled(false); + expect(globalThis.localStorage.getItem(TOOLS_FLAG_KEY)).toBeNull(); + }); +}); + +describe('key namespacing (regression: review finding 2)', () => { + it('keeps two tools in one repo distinct when both declare only a scope', () => { + // The spec calls the declared list "scope inside that namespace", so + // scope-only keys are legal — and without a namespace they collide, and + // `dor tool docs` reports the `api` pane. + const docs = namespacedToolKey('docs', ['/repo']); + const api = namespacedToolKey('api', ['/repo']); + expect(toolKeysEqual(docs, api)).toBe(false); + expect(toolKeysEqual(docs, docs)).toBe(true); + }); + + it('stops an announcement from claiming another tool’s key', () => { + // A trusted tool rendering hostile bytes emits OSC 367 with storybook's + // key. Namespaced under the name the host resolved at spawn, it cannot + // match storybook's, so a later `dor tool storybook` will not adopt — and + // Ctrl+C — the announcing pane. + const storybook = namespacedToolKey('storybook', ['storybook', '/repo']); + const spoofed = namespacedToolKey('notes', ['storybook', '/repo']); + expect(toolKeysEqual(storybook, spoofed)).toBe(false); + }); + + it('gives an identityless tool no key, so a re-key cannot mint one', () => { + expect(namespacedToolKey(null, ['storybook', '/repo'])).toBeNull(); + expect(namespacedToolKey('storybook', null)).toBeNull(); + }); +}); + +describe('tool persistence (regression: review findings 4 and 11)', () => { + it('strips the derived browser state, so a restart never frames a dead URL', () => { + const meta = toolLeafMeta('storybook', { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolKey: ['storybook', 'storybook', '/repo'], + toolRender: 'ab-screencast', + toolPort: 'auto', + toolPortConflict: [6006, 6007], + url: 'http://localhost:6006/', + renderMode: 'ab-screencast', + session: 'dormouse.w.tool.p1', + wsPort: 51234, + showTerminal: true, + }); + expect(persistableLeafMeta(meta).params).toEqual({ + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolKey: ['storybook', 'storybook', '/repo'], + toolRender: 'ab-screencast', + toolPort: 'auto', + }); + }); + + it('leaves a browser Surface’s params alone', () => { + const meta = { component: 'browser', tabComponent: 'surface', title: 'B', params: { url: 'https://x', renderMode: 'iframe' } }; + expect(persistableLeafMeta(meta).params).toEqual({ url: 'https://x', renderMode: 'iframe' }); + }); +}); + +describe('the pending-approval shape (regression: PR #493 review)', () => { + // The producer in `use-dor-control.ts` and this reader disagreed about `cwd`, + // so `toolPendingFromParams` returned null in production, `toolFace` never + // reached `pending-approval`, and the untrusted pane mounted a live shell + // instead of the prompt. The producer's literal is now typed `ToolPending`, + // so a future divergence is a compile error rather than a silent one — these + // pin the runtime half. + const pending = { + name: 'storybook', + run: 'pnpm storybook', + path: '/repo/dormouse.yml', + projectRoot: '/repo', + minimized: false, + upstreamUrl: null, + }; + + it('accepts exactly what the producer writes', () => { + expect(toolPendingFromParams({ surfaceType: 'tool', toolPending: pending })).toMatchObject({ + name: 'storybook', + projectRoot: '/repo', + }); + expect(toolFace({ surfaceType: 'tool', toolPending: pending })).toBe('pending-approval'); + }); + + it('rejects a shape missing any required field, rather than half-reading it', () => { + for (const field of ['name', 'run', 'path', 'projectRoot', 'minimized'] as const) { + const { [field]: _dropped, ...rest } = pending; + expect(toolPendingFromParams({ surfaceType: 'tool', toolPending: rest }), field).toBeNull(); + } + }); + + it('allows a null upstream, which is how a repo with no remote arrives', () => { + expect(toolPendingFromParams({ surfaceType: 'tool', toolPending: pending })).not.toBeNull(); + }); +}); + +describe('a pending tool is not persisted (regression: PR #493 review)', () => { + it('persists as a plain terminal, so a restart cannot spawn a shell in an unapproved repo', () => { + const meta = toolLeafMeta('storybook', { + surfaceType: 'tool', + command: 'pnpm storybook', + cwd: '/repo', + toolPending: { name: 'storybook', run: 'pnpm storybook', path: '/p', projectRoot: '/repo', minimized: false, upstreamUrl: null }, + }); + const persisted = persistableLeafMeta(meta); + expect(persisted.component).toBe('terminal'); + expect(persisted.params).toBeUndefined(); + }); +}); diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index fa816732f..fd03549e3 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -11,6 +11,7 @@ import type { } from 'dor/commands/types'; import { hasBrowser, hasTerminal } from 'dor/commands/types'; import { MAX_AWAIT_TIMEOUT_MS } from '../../lib/alert-manager'; +import { TOOLS_FLAG_KEY, isToolsEnabled } from '../../lib/feature-flags'; import type { OpenPort } from '../../lib/platform/types'; import { buildShellCommandForKind, shellCommandKind } from 'dor/commands/shell-quote'; import { @@ -22,13 +23,21 @@ import { } from '../../lib/terminal-registry'; import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; import { hostPathDisplay } from './browser-url'; -import { agentBrowserSessionFromParams, isAgentBrowserParams } from './browser-surface'; +import { + agentBrowserSessionFromParams, + isAgentBrowserParams, + namespacedToolKey, + toolKeysEqual, + toolPendingFromParams, + type ToolPending, +} from './browser-surface'; // One-way import: connect-port no longer depends on this module (its eager-surface // and refresh seams are injected as plain functions). import { connectPortToDefaultBrowser } from './connect-port'; import { listenerUrlsByPort } from './port-url'; -import { dorDirectionForEdge, type LathWallEngine } from './lath-wall-engine'; +import { dorDirectionForEdge, toolLeafMeta, type LathWallEngine } from './lath-wall-engine'; import type { WallNav } from './keyboard/types'; +import type { LeafMeta } from '../../lib/lath/persistence'; import type { DooredItem } from './wall-types'; type DorControlParams = { @@ -55,6 +64,8 @@ type DorControlParams = { window?: string; scrollback?: unknown; wsPort?: unknown; + name?: unknown; + fresh?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -240,6 +251,28 @@ const RESTART_POLL_INTERVAL_MS = 100; const RESTART_INTERRUPT_TIMEOUT_MS = 15_000; const RESTART_START_TIMEOUT_MS = 15_000; +/** + * Serializes `surface.tool` requests. A plain promise chain rather than a real + * mutex: the critical section is "check for a key match, then create", and the + * only contender is another tool request, so ordering them is enough. Module + * scope because each `dor` invocation arrives as its own control request. + */ +let toolSpawnChain: Promise<void> = Promise.resolve(); +function acquireToolSpawnLock(): Promise<() => void> { + let release!: () => void; + const held = new Promise<void>((resolve) => { release = resolve; }); + const waited = toolSpawnChain.then(() => release); + // A handler that throws must not wedge every later one. + toolSpawnChain = toolSpawnChain.then(() => held).catch(() => {}); + return waited; +} + +/** The rendered command a tool Surface is running, for the reuse note. */ +function toolCommandFromParams(params: unknown): string { + const value = (params as { command?: unknown } | null | undefined)?.command; + return typeof value === 'string' ? value : ''; +} + /** Resolve true once `predicate` holds for the surface's live state, false on timeout. */ function waitForTerminalState( id: string, @@ -376,6 +409,13 @@ export function useDorControl({ cwd?: string; requireIntegration?: boolean; focusNeutral?: boolean; + /** Leaf metadata for the new Surface; defaults to a plain terminal. `dor + * tool` passes a tool leaf, which is a shell-hosted PTY exactly like a + * terminal but renders both capabilities. */ + leafMeta?: LeafMeta; + /** Create the leaf but stage no shell and spawn no PTY — a pane awaiting + * approval (docs/specs/dor-tool.md -> Trust rule 3). */ + deferTerminal?: boolean; }) => ParseResult<{ id: string; ref: string; minimized: boolean }>; createContentSurface: (args: { minimized: boolean; @@ -698,6 +738,292 @@ export function useDorControl({ return; } + if (detail.method === SURFACE_CONTROL_METHODS.tool) { + // Serialize every tool request behind the last one. Each `dor` + // invocation is its own socket connection, so two handlers otherwise + // interleave across the host lookup, both clear the key check, and both + // create — two panes with one key, two servers on one port. `finally` + // still runs on every `return` in the body below. + const releaseToolLock = await acquireToolSpawnLock(); + try { + // Off by default. With the flag off nothing is ever designated a tool, + // so the serving trigger has nothing to watch and no pane can transform. + if (!isToolsEnabled()) { + detail.respond({ + ok: false, + error: `Dor Tools are off. Enable them by setting localStorage '${TOOLS_FLAG_KEY}' to 'true'.`, + }); + return; + } + const cwd = stringParam(params.cwd)?.trim(); + if (!cwd) { + detail.respond({ ok: false, error: 'cwd is required' }); + return; + } + const toolName = stringParam(params.name)?.trim(); + let command: string; + let key: string[] | null = null; + let warnings: string[] = []; + let render: 'iframe' | 'ab-screencast' = 'iframe'; + // `dor tool -- <command>` has nowhere to declare a strategy, so it + // autobinds. Safe by construction now that `auto` refuses two ports + // rather than tie-breaking; a declared tool opts in with one line. + let port: 'announced' | 'auto' = 'auto'; + const toolShell = getDefaultShellOpts()?.shell; + + if (toolName) { + // The registry, the closed substitution set, and the trust gate all + // live behind this one host call (`dor/commands/types` -> + // ToolSurfaceRequest). + const toolControl = getPlatform().toolControl; + if (!toolControl) { + detail.respond({ ok: false, error: 'this host cannot read a dormouse.yml; use `dor tool -- <command>`' }); + return; + } + const lookup = await toolControl({ op: 'lookup', name: toolName, cwd }); + switch (lookup.status) { + case 'trust-recorded': + // Only a `trust` op can produce this; a lookup never does. + detail.respond({ ok: false, error: 'unexpected tool host response' }); + return; + case 'ok': + command = lookup.run; + // Namespaced under the host-resolved tool name, so two tools in + // one repo with scope-only keys stay distinct and a runtime + // re-key cannot name another tool's key. + key = namespacedToolKey(lookup.name, lookup.key); + render = lookup.render; + port = lookup.port; + warnings = lookup.warnings; + break; + case 'no-file': + detail.respond({ ok: false, error: `no dormouse.yml found in '${cwd}' or any parent directory` }); + return; + case 'unknown-tool': + detail.respond({ + ok: false, + error: lookup.names.length > 0 + ? `no tool '${toolName}' in ${lookup.path} (has: ${lookup.names.join(', ')})` + : `no tool '${toolName}' in ${lookup.path}`, + }); + return; + case 'untrusted': { + // Approval can only lead to a command gated on OSC 633. Reject + // a shell known never to emit it before offering a prompt that + // would otherwise approve, spawn, then silently drop the command. + if (toolShell && shellCommandKind(toolShell, PLATFORM_STRING) === 'cmd') { + detail.respond({ ok: false, error: missingIntegrationError(toolShell) }); + return; + } + // The pane appears now and asks; the command spawns only on + // approval (docs/specs/dor-tool.md -> Trust). Nothing from the + // repo has executed to reach this point — the file was read and + // parsed, which is inert, and is what lets the prompt name the + // command it is asking about. + // + // A second launch of the same tool reuses the pending pane + // rather than stacking prompts: dedupe cannot key on + // `prespawn_dedupe` yet (the untrusted lookup withholds it), so + // it keys on what the prompt is about. + const matchesPending = (candidate: unknown) => { + const waiting = toolPendingFromParams(candidate); + return waiting?.name === lookup.name && waiting.projectRoot === lookup.projectRoot; + }; + const already = findSurfaceByParams(matchesPending); + if (already) { + revealSurface(already.id); + detail.respond({ + ok: true, + result: { + status: 'pending', + surfaceId: already.id, + surfaceRef: surfaceRefForId(already.id), + command: lookup.run, + cwd, + minimized: findSurfaceByParams(matchesPending)?.minimized ?? false, + key: null, + }, + }); + return; + } + const pendingTarget = resolveSplitTarget(); + if (!pendingTarget) return; + // Deliberately not minimized, whatever was asked: a pane the + // user cannot see is a pane they cannot approve. The request is + // carried and applied once they do. + const pendingMeta: ToolPending = { + name: lookup.name, + run: lookup.run, + path: lookup.path, + projectRoot: lookup.projectRoot, + minimized: booleanParam(params.minimized), + upstreamUrl: lookup.upstreamUrl, + }; + const pending = createSplitSurface({ + direction: autoDorDirection(pendingTarget.target), + minimized: false, + reference: pendingTarget.target, + cwd, + focusNeutral: true, + // No shell until a human approves: `createSplitSurface` would + // otherwise stage shell opts and, on some paths, spawn the PTY + // outright (docs/specs/dor-tool.md -> Trust rule 3). + deferTerminal: true, + leafMeta: toolLeafMeta(lookup.name, { + surfaceType: 'tool', + command: lookup.run, + cwd, + toolName: lookup.name, + toolPending: pendingMeta, + }), + }); + if (!pending.ok) { + detail.respond({ ok: false, error: pending.message }); + return; + } + // A minimized reference creates its sibling as a Door even + // when `minimized` is false. Pending approval must stay visible, + // so immediately reattach that exceptional creation path. + if (pending.value.minimized) revealSurface(pending.value.id); + detail.respond({ + ok: true, + result: { + status: 'pending', + surfaceId: pending.value.id, + surfaceRef: pending.value.ref, + command: lookup.run, + cwd, + minimized: findSurfaceByParams(matchesPending)?.minimized ?? false, + key: null, + }, + }); + return; + } + default: + detail.respond({ ok: false, error: lookup.message }); + return; + } + } else { + const argv = stringArrayParam(params.command); + command = dorCommandString(argv) ?? ''; + if (!command) { + detail.respond({ ok: false, error: 'command cannot be empty' }); + return; + } + } + + // Spawn-time dedupe, and only for a tool that was given an identity + // (docs/specs/dor-tool.md -> Identity and dedupe). + if (key && !booleanParam(params.fresh)) { + const matchesToolKey = (candidate: unknown) => + toolKeysEqual((candidate as { toolKey?: unknown } | null | undefined)?.toolKey, key); + const match = findSurfaceByParams(matchesToolKey); + if (match) { + const matchedCommand = toolCommandFromParams(lath.getMeta(match.id)?.params) || command; + // A dedicated Surface whose command exited is unambiguously free, + // so re-run in place rather than splitting — where `dor ensure`, + // aimed at arbitrary shells, would stop matching. + const idle = getTerminalPaneState(match.id).currentCommand === null; + if (idle) { + // The tool's own cwd, not the caller's: `surfaceRunsCommand` + // compares against the matched Surface's `cwdAtStart`, so waiting + // on the caller's would never resolve when `dor tool` is run from + // a subdirectory — the command restarts and we report failure. + const matchedCwd = getTerminalPaneState(match.id).cwd?.path ?? cwd; + const restarted = await restartSurfaceInPlace(match.id, matchedCommand, matchedCwd); + if (!restarted.ok) { + detail.respond({ + ok: false, + error: `surface '${surfaceRefForId(match.id)}' ${restarted.message}`, + }); + return; + } + } + // Reveal, reattaching a Door first: a match that only printed a + // handle would leave a minimized tool minimized, which is exactly + // the "appears to do nothing" the invariant is written against. + revealSurface(match.id); + const survivor = findSurfaceByParams(matchesToolKey); + detail.respond({ + ok: true, + result: { + status: idle ? 'adopted' : 'existing', + surfaceId: match.id, + surfaceRef: surfaceRefForId(match.id), + command: matchedCommand, + cwd, + minimized: survivor?.minimized ?? false, + key, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }); + return; + } + } + + // A tool is a shell-hosted PTY with the command typed into it, exactly + // as `dor ensure` spawns one — but with no command+cwd matching, and a + // leaf that renders both capabilities. + if (toolShell && shellCommandKind(toolShell, PLATFORM_STRING) === 'cmd') { + detail.respond({ ok: false, error: missingIntegrationError(toolShell) }); + return; + } + const toolTarget = resolveSplitTarget(); + if (!toolTarget) return; + const created = createSplitSurface({ + command, + direction: autoDorDirection(toolTarget.target), + minimized: booleanParam(params.minimized), + reference: toolTarget.target, + cwd, + requireIntegration: true, + // Focus-neutral like `dor ensure`: a tool spawned by a script or an + // agent must not steal the caller's selection. + focusNeutral: true, + leafMeta: toolLeafMeta(toolName ?? command, { + surfaceType: 'tool', + command, + cwd, + toolRender: render, + toolPort: port, + ...(key ? { toolKey: key } : {}), + ...(toolName ? { toolName } : {}), + }), + }); + if (!created.ok) { + detail.respond({ ok: false, error: created.message }); + return; + } + const toolIntegrated = await waitForTerminalState( + created.value.id, + () => isPaneOscDriven(created.value.id), + INTEGRATION_DETECT_TIMEOUT_MS, + ); + if (!toolIntegrated) { + killPaneImmediately(created.value.id); + detail.respond({ ok: false, error: missingIntegrationError(toolShell) }); + return; + } + detail.respond({ + ok: true, + result: { + status: 'created', + surfaceId: created.value.id, + surfaceRef: created.value.ref, + command, + cwd, + minimized: created.value.minimized, + key, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }); + return; + + } finally { + releaseToolLock(); + } + } + if (detail.method === SURFACE_CONTROL_METHODS.ensure) { const command = dorCommandString(stringArrayParam(params.command)); if (!command) { @@ -1070,7 +1396,7 @@ export function useDorControl({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, buildDorSurfaceList, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, killPaneImmediately, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); + }, [buildDorSurfaces, buildDorSurfaceList, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceByParams, findSurfaceIdRunningCommand, killPaneImmediately, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); return { connectPort }; } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 7a3767959..a63b82050 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -9,6 +9,7 @@ import { UNNAMED_PANEL_TITLE, } from '../../lib/terminal-registry'; import { surfaceKindFromParams } from './browser-surface'; +import { persistableLeafMeta } from './lath-wall-engine'; import type { LathWallEngine } from './lath-wall-engine'; import type { DooredItem, WallSelectionKind } from './wall-types'; import type { PersistedDoor, PersistedSurfaceRefs } from '../../lib/session-types'; @@ -42,22 +43,36 @@ export function useSessionPersistence({ const trackerRef = useRef(createSessionDirtyTracker()); const doSave = useCallback((): Promise<void> => { - const panes = lath.listPanes().map((p) => ({ - id: p.id, - title: p.title ?? UNNAMED_PANEL_TITLE, - surfaceType: surfaceKindFromParams(p.params), - })); + const panes = lath.listPanes().map((p) => { + // Apply the same projection used by the saved Lath layout. In particular, + // a still-pending approval becomes a plain terminal and a running tool + // loses only its derived browser state. + const meta = lath.getMeta(p.id); + const persistable = meta ? persistableLeafMeta(meta) : undefined; + return { + id: p.id, + title: persistable?.title ?? p.title ?? UNNAMED_PANEL_TITLE, + surfaceType: surfaceKindFromParams(persistable?.params), + params: persistable?.params, + }; + }); // The runtime Door is id + token; its metadata is materialized HERE, from the // store that owned it all along, so a Surface persists where it navigated to // rather than where it was minimized and a restart cold-loads it there. const doors: PersistedDoor[] = (doorsRef.current ?? []).map((door) => { + // A Doored leaf is excluded from the tree snapshot and persisted as its + // own row, so it never passes through `serializeLayout` — run the same + // projection here, or a minimized tool round-trips its dead `url` and a + // daemon session that died with the previous process + // (docs/specs/dor-tool.md -> Persistence and hosts). const meta = lath.getMeta(door.id); + const persistable = meta ? persistableLeafMeta(meta) : undefined; return { id: door.id, - title: meta?.title?.trim() || UNNAMED_PANEL_TITLE, - component: meta?.component, - tabComponent: meta?.tabComponent, - params: meta?.params, + title: persistable?.title?.trim() || UNNAMED_PANEL_TITLE, + component: persistable?.component, + tabComponent: persistable?.tabComponent, + params: persistable?.params, token: door.token, }; }); diff --git a/lib/src/components/wall/use-tool-serving.test.tsx b/lib/src/components/wall/use-tool-serving.test.tsx new file mode 100644 index 000000000..3ac280819 --- /dev/null +++ b/lib/src/components/wall/use-tool-serving.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment jsdom +/** + * The serving decision (`docs/specs/dor-tool.md` -> Serving). This logic had no + * test at all before autobind, which is how "framing the lowest-numbered port" + * survived as an unstated rule. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakePtyAdapter, setPlatform } from '../../lib/platform'; +import { recordToolAnnounce, resetToolAnnounces } from '../../lib/tool-announce-store'; +import { useToolServing } from './use-tool-serving'; +import type { LathWallEngine } from './lath-wall-engine'; +import type { OpenPort } from '../../lib/platform/types'; + +const controllerMocks = vi.hoisted(() => ({ + disposeAgentBrowserSurfaceController: vi.fn(), +})); + +vi.mock('./agent-browser-surface-controller', () => controllerMocks); + +const POLL_MS = 1500; + +function tcp(port: number): OpenPort { + return { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port, pid: 1 }; +} + +/** Minimal Lath stand-in: one tool leaf whose params the hook reads and writes. */ +function fakeLath(params: Record<string, unknown>) { + const state = { params: { ...params } }; + const updateParams = vi.fn((_id: string, patch: Record<string, unknown>) => { + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) delete state.params[key]; + else state.params[key] = value; + } + }); + const lath = { + listPanes: () => [{ id: 'tool-1', params: state.params }], + getMeta: (id: string) => (id === 'tool-1' ? { params: state.params } : undefined), + store: { updateParams }, + } as unknown as LathWallEngine; + return { lath, state, updateParams }; +} + +let container: HTMLDivElement; +let root: Root; +let currentCommand: string | null = 'pnpm storybook'; + +vi.mock('../../lib/terminal-registry', () => ({ + getTerminalPaneState: () => ({ currentCommand }), +})); + +beforeEach(() => { + vi.useFakeTimers(); + resetToolAnnounces(); + currentCommand = 'pnpm storybook'; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** Mount the hook with a scripted sequence of scan results, one per tick. */ +async function run(params: Record<string, unknown>, scans: OpenPort[][]) { + const { lath, state, updateParams } = fakeLath(params); + let call = 0; + const platform = new FakePtyAdapter() as FakePtyAdapter & { getOpenPorts: () => Promise<OpenPort[]> }; + platform.getOpenPorts = vi.fn(async () => scans[Math.min(call++, scans.length - 1)] ?? []); + setPlatform(platform); + + const doorsRef = { current: [] }; + function Probe() { + useToolServing({ lath, doorsRef }); + return null; + } + await act(async () => { root.render(<Probe />); }); + // One tick per scripted scan, past the initial immediate tick. + for (let i = 1; i < scans.length; i += 1) { + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + } + return { state, updateParams, platform }; +} + +describe('port: announced', () => { + const announced = { surfaceType: 'tool', command: 'x', toolPort: 'announced' }; + + it('frames nothing without an announcement, however many ports bind', async () => { + const { state } = await run(announced, [[tcp(6006)], [tcp(6006)], [tcp(6006)]]); + expect(state.params.url).toBeUndefined(); + expect(state.params.toolPortConflict).toBeUndefined(); + }); + + it('frames the announced port', async () => { + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const { state } = await run(announced, [[tcp(6006)]]); + expect(state.params.url).toBe('http://localhost:6006/'); + }); + + it('does not undo URL-bar navigation while the announcement is unchanged', async () => { + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const { state, platform } = await run(announced, [[tcp(6006)]]); + state.params.url = 'https://example.com/docs'; + + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + + expect(state.params.url).toBe('https://example.com/docs'); + expect(platform.getOpenPorts).toHaveBeenCalledTimes(1); + }); + + it('re-points a live browser when the announced port changes', async () => { + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const { state, platform } = await run(announced, [[tcp(6006)]]); + expect(state.params.url).toBe('http://localhost:6006/'); + + recordToolAnnounce('tool-1', { port: 6007, name: null, key: null, dehydrate: false, persist: null }); + platform.getOpenPorts = vi.fn(async () => [tcp(6007)]); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + + expect(state.params.url).toBe('http://localhost:6007/'); + }); + + it('frames nothing when the announced port never binds', async () => { + recordToolAnnounce('tool-1', { port: 9999, name: null, key: null, dehydrate: false, persist: null }); + const { state } = await run(announced, [[tcp(6006)], [tcp(6006)]]); + expect(state.params.url).toBeUndefined(); + }); + + it('lets an announcement override even in auto mode', async () => { + recordToolAnnounce('tool-1', { port: 1420, name: null, key: null, dehydrate: false, persist: null }); + const { state } = await run({ ...announced, toolPort: 'auto' }, [[tcp(1420), tcp(1422)]]); + expect(state.params.url).toBe('http://localhost:1420/'); + expect(state.params.toolPortConflict).toBeUndefined(); + }); +}); + +describe('port: auto (autobind)', () => { + const auto = { surfaceType: 'tool', command: 'x', toolPort: 'auto' }; + + it('waits for the port set to settle before framing', async () => { + const { state } = await run(auto, [[tcp(6006)]]); + // First sighting only — not committed yet. + expect(state.params.url).toBeUndefined(); + }); + + it('frames a sole port once the set is unchanged', async () => { + const { state } = await run(auto, [[tcp(6006)], [tcp(6006)]]); + expect(state.params.url).toBe('http://localhost:6006/'); + expect(state.params.renderMode).toBe('iframe'); + }); + + it('refuses two ports rather than tie-breaking', async () => { + const { state } = await run(auto, [[tcp(6006), tcp(6007)], [tcp(6006), tcp(6007)]]); + expect(state.params.url).toBeUndefined(); + expect(state.params.toolPortConflict).toEqual([6006, 6007]); + }); + + it('does not frame the bridge when vite binds a tick later', async () => { + // The standalone harness: the dev bridge (1422) binds before vite (1420). + // Committing on first sighting would frame the JSON bridge permanently, + // since a framed leaf is never scanned again. This is the regression that + // motivates the settle window. + const { state } = await run(auto, [[tcp(1422)], [tcp(1420), tcp(1422)], [tcp(1420), tcp(1422)]]); + expect(state.params.url).toBeUndefined(); + expect(state.params.toolPortConflict).toEqual([1420, 1422]); + }); + + it('retires the conflict when the command exits, so a re-run re-decides', async () => { + const { state, updateParams } = await run(auto, [[tcp(6006), tcp(6007)], [tcp(6006), tcp(6007)]]); + expect(state.params.toolPortConflict).toEqual([6006, 6007]); + currentCommand = null; + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.toolPortConflict).toBeUndefined(); + expect(updateParams).toHaveBeenCalledWith('tool-1', expect.objectContaining({ toolPortConflict: undefined })); + }); +}); + +describe('an announcement overrides a committed conflict', () => { + const auto = { surfaceType: 'tool', command: 'x', toolPort: 'auto' }; + + it('frames the announced port after autobind has already refused', async () => { + // The spec says the announcement always wins. A tool that names its port + // *after* the set settled would otherwise be stuck on the conflict face for + // the life of the command — told to announce a port it had just announced. + const { lath, state, updateParams } = fakeLath(auto); + let call = 0; + const scans = [[tcp(1420), tcp(1422)], [tcp(1420), tcp(1422)], [tcp(1420), tcp(1422)]]; + const platform = new FakePtyAdapter() as FakePtyAdapter & { getOpenPorts: () => Promise<OpenPort[]> }; + platform.getOpenPorts = vi.fn(async () => scans[Math.min(call++, scans.length - 1)]); + setPlatform(platform); + + const doorsRef = { current: [] }; + function Probe() { + useToolServing({ lath, doorsRef }); + return null; + } + await act(async () => { root.render(<Probe />); }); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.toolPortConflict).toEqual([1420, 1422]); + + // The tool announces late. + recordToolAnnounce('tool-1', { port: 1420, name: null, key: null, dehydrate: false, persist: null }); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + + expect(state.params.url).toBe('http://localhost:1420/'); + // The stale verdict must be cleared too: `toolFace` tests the conflict + // before the url, so leaving it would keep the conflict card forward. + expect(state.params.toolPortConflict).toBeUndefined(); + expect(updateParams).toHaveBeenCalledWith('tool-1', expect.objectContaining({ toolPortConflict: undefined })); + }); +}); + +describe('the settle memory resets on any exit (regression: PR #493 review)', () => { + const auto = { surfaceType: 'tool', command: 'x', toolPort: 'auto' }; + + it('does not commit the first port seen after a run that died mid-settle', async () => { + // Run 1 sees only the bridge and dies before committing anything. Keeping + // that port list would make run 2's first tick compare equal and frame the + // bridge — the exact regression the settle window exists to prevent. + const { lath, state } = fakeLath(auto); + let call = 0; + const scans = [[tcp(1422)], [tcp(1422)], [tcp(1422)]]; + const platform = new FakePtyAdapter() as FakePtyAdapter & { getOpenPorts: () => Promise<OpenPort[]> }; + platform.getOpenPorts = vi.fn(async () => scans[Math.min(call++, scans.length - 1)]); + setPlatform(platform); + + const doorsRef = { current: [] }; + function Probe() { + useToolServing({ lath, doorsRef }); + return null; + } + await act(async () => { root.render(<Probe />); }); // tick 1: [1422] recorded + currentCommand = null; // the command dies + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + currentCommand = 'x'; // re-run + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + + // First tick of run 2 is a first sighting again, so nothing is framed yet. + expect(state.params.url).toBeUndefined(); + }); +}); + +describe('agent-browser retirement on command exit', () => { + it('closes the daemon session, disposes the controller, and clears its params', async () => { + currentCommand = null; + const params = { + surfaceType: 'tool', + command: 'pnpm storybook', + url: 'http://localhost:6006/', + renderMode: 'ab-screencast', + session: 'dormouse.1.tool-1', + wsPort: 43123, + syncEngaged: true, + binaryPath: '/opt/agent-browser', + }; + const { state, platform } = await run(params, [[]]); + const close = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + platform.agentBrowserCommand = close; + + // The first tick ran during mount before the close stub was installed; put + // the browser state back, then let the next poll exercise retirement. + Object.assign(state.params, params); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + + expect(close).toHaveBeenCalledWith('dormouse.1.tool-1', ['close'], '/opt/agent-browser'); + expect(controllerMocks.disposeAgentBrowserSurfaceController).toHaveBeenCalledWith('tool-1'); + expect(state.params).not.toHaveProperty('url'); + expect(state.params).not.toHaveProperty('session'); + expect(state.params).not.toHaveProperty('wsPort'); + expect(state.params).not.toHaveProperty('renderMode'); + expect(state.params).not.toHaveProperty('syncEngaged'); + }); +}); diff --git a/lib/src/components/wall/use-tool-serving.ts b/lib/src/components/wall/use-tool-serving.ts new file mode 100644 index 000000000..e01769f2f --- /dev/null +++ b/lib/src/components/wall/use-tool-serving.ts @@ -0,0 +1,264 @@ +/** + * The serving trigger: a tool Surface grows a browser in place once its command + * binds a port (`docs/specs/dor-tool.md` -> Serving). + * + * Only tool-designated Sessions are scanned. An ordinary terminal that opens a + * port never transforms — that is the Dev-Server Chip's job, and panes must not + * flip under the user (`docs/specs/dor-tool.md` -> Security). + */ +import { useEffect, useRef } from 'react'; +import { getPlatform } from '../../lib/platform'; +import { getTerminalPaneState } from '../../lib/terminal-registry'; +import { + browserUrlFromParams, + isToolParams, + namespacedToolKey, + toolKeysEqual, + toolPortConflictFromParams, +} from './browser-surface'; +import { attachAgentBrowserSession } from './connect-port'; +import { listenerUrlsByPort } from './port-url'; +import { getToolAnnounce } from '../../lib/tool-announce-store'; +import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { markAgentBrowserSessionClosed } from './agent-browser-sessions'; +import { disposeAgentBrowserSurfaceController } from './agent-browser-surface-controller'; +import type { LathWallEngine } from './lath-wall-engine'; +import type { DooredItem } from './wall-types'; + +// A serving command usually binds within a second or two of starting, but a +// cold `pnpm` boot can take much longer, so this keeps polling for as long as +// the command runs. The scan shells out per Surface (lsof / PowerShell), so the +// cadence is deliberately slow and only tools without a URL are scanned. +const POLL_MS = 1500; + +type ToolLeaf = { id: string; params: Record<string, unknown> | undefined }; + +/** The registered name a tool was spawned under; null for `dor tool -- <cmd>`. */ +function toolNameFromParams(params: Record<string, unknown> | undefined): string | null { + const name = params?.toolName; + return typeof name === 'string' ? name : null; +} + +function toolLeaves(lath: LathWallEngine, doors: DooredItem[]): ToolLeaf[] { + const leaves: ToolLeaf[] = []; + for (const pane of lath.listPanes()) { + if (isToolParams(pane.params)) leaves.push({ id: pane.id, params: pane.params }); + } + for (const door of doors) { + const params = lath.getMeta(door.id)?.params; + if (isToolParams(params)) leaves.push({ id: door.id, params }); + } + return leaves; +} + +export function useToolServing({ + lath, + doorsRef, +}: { + lath: LathWallEngine; + doorsRef: React.MutableRefObject<DooredItem[]>; +}): void { + // Ports seen on the previous tick, per leaf — the settle check's memory. + // A ref, not state: it drives no render, and a leaf's entry is dropped when + // its command exits so a re-run settles again from scratch. + const seenPorts = useRef<Map<string, number[]>>(new Map()); + // The announced port last applied to each leaf. A changed announcement may + // re-point a live browser, but the same announcement must not keep undoing + // URL-bar navigation just because params.url no longer names that port. + const appliedAnnouncedPorts = useRef<Map<string, number>>(new Map()); + + useEffect(() => { + const platform = getPlatform(); + if (!platform.getOpenPorts) return; + let cancelled = false; + + const tick = async () => { + const leaves = toolLeaves(lath, doorsRef.current); + // A killed tool never reaches the exit branch below, so prune by absence. + const live = new Set(leaves.map((leaf) => leaf.id)); + for (const id of seenPorts.current.keys()) { + if (!live.has(id)) seenPorts.current.delete(id); + } + for (const id of appliedAnnouncedPorts.current.keys()) { + if (!live.has(id)) appliedAnnouncedPorts.current.delete(id); + } + + for (const leaf of leaves) { + if (cancelled) return; + const announce = getToolAnnounce(leaf.id); + + // A runtime re-key re-labels this Surface and nothing else — it never + // dedupes (docs/specs/dor-tool.md -> Identity and dedupe). The + // namespace that keeps process output from claiming another tool's key + // is `namespacedToolKey`'s job; see its doc comment. + const announcedKey = namespacedToolKey(toolNameFromParams(leaf.params), announce?.key ?? null); + if (announcedKey && !toolKeysEqual(leaf.params?.toolKey, announcedKey)) { + lath.store.updateParams(leaf.id, { toolKey: announcedKey }); + } + + const hasUrl = browserUrlFromParams(leaf.params) !== null; + const hasConflict = toolPortConflictFromParams(leaf.params) !== null; + const running = getTerminalPaneState(leaf.id).currentCommand !== null; + + // Command exit retires the browser and the pane flips back to a prompt + // above the tool's dying words. Re-running revives it on the same + // Surface, because the params, not the id, changed. A conflict is + // derived the same way and retires with it, so a re-run gets a fresh + // verdict rather than the last run's. + // Drop the settle memory on *any* exit, not only one that committed: a + // command that died mid-settle would otherwise leave its port list + // behind, and the next run's first tick would compare equal to it and + // commit immediately — framing whichever port bound earliest, which is + // the regression the settle window exists to prevent. + if (!running) { + seenPorts.current.delete(leaf.id); + appliedAnnouncedPorts.current.delete(leaf.id); + } + + if ((hasUrl || hasConflict) && !running) { + const session = typeof leaf.params?.session === 'string' ? leaf.params.session : null; + if (session) { + const binaryPath = typeof leaf.params?.binaryPath === 'string' ? leaf.params.binaryPath : undefined; + // Mark before close so a popped-out/stream-loss callback cannot + // auto-relaunch a browser the command exit is retiring. + markAgentBrowserSessionClosed(session); + void platform.agentBrowserCommand?.(session, ['close'], binaryPath).catch(() => {}); + } + // The browser panel remains mounted behind the terminal half, so its + // controller must be disposed explicitly rather than waiting for an + // unmount that will not happen. + disposeAgentBrowserSurfaceController(leaf.id); + lath.store.updateParams(leaf.id, { + url: undefined, + showTerminal: undefined, + toolPortConflict: undefined, + session: undefined, + wsPort: undefined, + renderMode: undefined, + syncEngaged: undefined, + }); + continue; + } + // A conflict is a verdict about *guessing*, not a final state: the + // announcement always wins, so a tool that names its port after autobind + // has already refused must still be framed. Without the second clause + // the pane would show the conflict for the life of the command, telling + // the user to announce a port it had just announced. + // An announcement outranks whatever autobind decided, framed or + // refused. Only a *changed* announced port re-points a live browser: + // treating a mismatch with params.url as a change would undo URL-bar + // navigation every poll after the user left the announced origin. + const announcedPort = announce?.port ?? null; + if (announcedPort === null) appliedAnnouncedPorts.current.delete(leaf.id); + const announcedPortChanged = announcedPort !== null + && appliedAnnouncedPorts.current.get(leaf.id) !== announcedPort; + if (!running) continue; + if ((hasUrl || hasConflict) && !announcedPortChanged) continue; + + let ports; + try { + ports = await platform.getOpenPorts!(leaf.id); + } catch { + continue; // A scan that fails is a scan that finds nothing yet. + } + if (cancelled) return; + const entries = listenerUrlsByPort(ports); + let entry; + + if (announce?.port != null) { + // The announcement disambiguates; the scan supplies the number, so an + // announced port that nothing bound frames nothing. + entry = entries.find((candidate) => candidate.port === announce.port); + if (!entry) continue; + appliedAnnouncedPorts.current.set(leaf.id, announce.port); + } else if (leaf.params?.toolPort !== 'auto') { + // `announced`: never guess. No announcement, no browser. + continue; + } else { + // Autobind. Do not commit on first sighting: ports appear one at a + // time during boot, so framing the first one seen would frame + // whichever bound earliest — for the standalone harness that is the + // dev bridge, not vite. Wait for the set to stop changing, which + // costs one tick and never has to retract a framed browser. + const found = entries.map((candidate) => candidate.port); + const previous = seenPorts.current.get(leaf.id); + seenPorts.current.set(leaf.id, found); + if (found.length === 0) continue; + if (!previous || previous.length !== found.length + || previous.some((port, index) => port !== found[index])) { + continue; // Still settling; re-check next tick. + } + if (found.length > 1) { + // Two or more is an error, never a tie-break: the rest of Dormouse + // declines to guess among several ports and this used to be the + // outlier. Shown where the browser would have gone. + lath.store.updateParams(leaf.id, { toolPortConflict: found }); + continue; + } + entry = entries[0]; + } + + // Frame it, under whichever renderer the tool declared. Show the + // destination immediately even for `ab-screencast`: the panel's + // session-less branch renders `Connecting to browser session…` while + // the daemon boots, and cannot race it (see docs/specs/dor-browser.md + // -> Instant create). `toolFace` tests the conflict before the url, so + // a stale verdict would keep the conflict forward over the browser. + const agentDrivable = leaf.params?.toolRender === 'ab-screencast'; + lath.store.updateParams(leaf.id, { + url: entry.url, + renderMode: agentDrivable ? 'ab-screencast' : 'iframe', + toolPortConflict: undefined, + }); + if (!agentDrivable) continue; + + // An agent-drivable tool needs a real browser behind it. Bind the + // session to the tool's *own* Surface rather than creating a second + // one: a tool's browser is a param of its own leaf, which is what keeps + // its id stable while its capabilities come and go. + const session = sessionForKey(`tool.${leaf.id}`); + await attachAgentBrowserSession({ + url: entry.url, + platform, + session, + surfaceId: leaf.id, + refreshSurface: (id, patch) => { + if (!cancelled) lath.store.updateParams(id, patch); + }, + }); + if (cancelled) return; + // The Surface can be killed while the daemon boots. Param writes no-op + // on a dead leaf, but the daemon would keep running with nothing bound + // to it and no teardown path — `closeAgentBrowserSession` reads a + // `session` param this leaf no longer has. Close it here instead + // (docs/specs/dor-tool.md -> Lifecycle: kill reaps the browser's + // resources). + if (!lath.getMeta(leaf.id)) { + void platform.agentBrowserCommand?.(session, ['close']).catch(() => {}); + } + } + }; + + // `getOpenPorts` shells out (lsof / PowerShell) and an agent-browser launch + // is seconds, either of which can outrun the interval. Without this guard a + // second tick re-enters a leaf whose `url` is not written yet and issues a + // duplicate `agent-browser open`. + let ticking = false; + const runTick = async () => { + if (ticking) return; + ticking = true; + try { + await tick(); + } finally { + ticking = false; + } + }; + + void runTick(); + const timer = setInterval(() => void runTick(), POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [lath, doorsRef]); +} diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index 686f324f4..6f2e8bb4c 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -57,6 +57,12 @@ export interface WallActions { * session and connect the pane (`connect-port.ts`). Fire-and-forget — failures * are logged, and the pane itself shows loading state. */ onConnectPort: (id: string, url: string) => void; + /** Flip which half of a `tool` Surface is forward — the header's leading chip + * (docs/specs/dor-tool.md). Visibility only: both halves stay mounted. */ + onToggleToolTerminal?: (id: string) => void; + /** Resolve a pending tool's approval: grant and start it, or close its pane + * (docs/specs/dor-tool.md -> Trust). */ + onResolveToolApproval?: (id: string, choice: 'upstream' | 'folder' | 'decline') => void; } export const WallActionsContext = createContext<WallActions>({ @@ -76,6 +82,8 @@ export const WallActionsContext = createContext<WallActions>({ onOpenBrowserPane: () => {}, resolveSurfaceRef: (id: string) => id, onConnectPort: () => {}, + onToggleToolTerminal: () => {}, + onResolveToolApproval: () => {}, }); /** Engine-directed writes from a pane/header (title + params). The read side is diff --git a/lib/src/host/git-remote-url.test.ts b/lib/src/host/git-remote-url.test.ts new file mode 100644 index 000000000..93893ad25 --- /dev/null +++ b/lib/src/host/git-remote-url.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalRemoteUrl } from './git-remote-url'; + +describe('canonicalRemoteUrl', () => { + it('collapses the spellings of one remote onto one key', () => { + // The whole point: a worktree cloned over ssh and one cloned over https are + // the same repo and must share a grant. + const expected = 'https://github.com/diffplug/dormouse'; + for (const spelling of [ + 'https://github.com/diffplug/dormouse', + 'https://github.com/diffplug/dormouse.git', + 'https://github.com/diffplug/dormouse/', + 'git@github.com:diffplug/dormouse.git', + 'git@github.com:diffplug/dormouse', + 'ssh://git@github.com/diffplug/dormouse.git', + 'git://github.com/diffplug/dormouse.git', + 'git+https://github.com/diffplug/dormouse.git', + 'https://GitHub.com/diffplug/dormouse', + ]) { + expect(canonicalRemoteUrl(spelling), spelling).toBe(expected); + } + }); + + it('drops userinfo, which is a credential and not identity', () => { + expect(canonicalRemoteUrl('https://ntwigg@github.com/diffplug/dormouse')) + .toBe('https://github.com/diffplug/dormouse'); + expect(canonicalRemoteUrl('https://user:token@github.com/diffplug/dormouse')) + .toBe('https://github.com/diffplug/dormouse'); + }); + + it('keeps different hosts apart, including lookalikes', () => { + // A github-hardcoded normalizer passes these through untouched and they end + // up compared against whatever the caller expected. + const real = canonicalRemoteUrl('git@github.com:diffplug/dormouse.git'); + for (const impostor of [ + 'git@github.com.evil.com:diffplug/dormouse.git', + 'git@evil.com:diffplug/dormouse.git', + 'https://github.com.evil.com/diffplug/dormouse', + 'https://evil.com/diffplug/dormouse', + ]) { + expect(canonicalRemoteUrl(impostor), impostor).not.toBe(real); + } + }); + + it('keeps different repos on one host apart', () => { + expect(canonicalRemoteUrl('git@github.com:diffplug/dormouse.git')) + .not.toBe(canonicalRemoteUrl('git@github.com:someone/dormouse.git')); + }); + + it('strips only a trailing .git, not an interior one', () => { + expect(canonicalRemoteUrl('https://host/o/.github')).toBe('https://host/o/.github'); + expect(canonicalRemoteUrl('https://host/o/r.git.git')).toBe('https://host/o/r.git'); + }); + + it('normalizes a default port but keeps a non-default one', () => { + expect(canonicalRemoteUrl('https://host:443/o/r')).toBe('https://host/o/r'); + expect(canonicalRemoteUrl('ssh://git@host:2222/o/r')).toBe('https://host:2222/o/r'); + }); + + it('declines anything it does not understand rather than guessing', () => { + for (const raw of [ + '', + ' ', + 'not a url', + '/srv/repos/bare.git', // a local path — folder trust's job + 'file:///srv/repos/bare.git', // ditto, explicitly + '../sibling-worktree', + 'https://github.com', // host only, no repo + 'https://github.com/', + 'ftp://host/o/r', // not a scheme git addresses a host with + ]) { + expect(canonicalRemoteUrl(raw), JSON.stringify(raw)).toBeNull(); + } + }); + + it('declines the ambiguous host:/path form rather than picking a reading', () => { + // git reads this as scp; a URL parser reads `/path` as a port. Declining + // costs a folder grant; guessing wrong would mint a key for the wrong host. + expect(canonicalRemoteUrl('host:/srv/repo.git')).toBeNull(); + }); +}); + +describe('default ports (regression: PR #493 review)', () => { + it('collapses an explicitly-spelled default port onto the same key', () => { + // Without this, a worktree whose origin is spelled the long way re-prompts, + // defeating "every worktree and clone of one repo shares a grant". + const expected = canonicalRemoteUrl('git@github.com:diffplug/dormouse.git'); + expect(canonicalRemoteUrl('ssh://git@github.com:22/diffplug/dormouse.git')).toBe(expected); + expect(canonicalRemoteUrl('git://github.com:9418/diffplug/dormouse.git')).toBe(expected); + expect(canonicalRemoteUrl('https://github.com:443/diffplug/dormouse')).toBe(expected); + }); + + it('still keeps a genuinely non-default port distinct', () => { + expect(canonicalRemoteUrl('ssh://git@host:2222/o/r')).toBe('https://host:2222/o/r'); + expect(canonicalRemoteUrl('ssh://git@host:2222/o/r')).not.toBe(canonicalRemoteUrl('ssh://git@host/o/r')); + }); +}); diff --git a/lib/src/host/git-remote-url.ts b/lib/src/host/git-remote-url.ts new file mode 100644 index 000000000..39eb3788a --- /dev/null +++ b/lib/src/host/git-remote-url.ts @@ -0,0 +1,71 @@ +/** + * Canonicalize a git remote URL into a trust key + * (`docs/specs/dor-tool.md` -> Trust). + * + * This string is compared against a stored grant, so it is a security key and + * not a display helper. Two rules follow from that: + * + * - **Anything unparseable returns `null`**, never a best guess. A caller that + * gets `null` offers only the folder grant, which fails closed. + * - **Nothing is host-specific.** A github-only normalizer passes + * `git@evil.com:x/y` through untouched, and a `.git`-suffix rule applied by + * blind string replacement mangles a repo legitimately named `x.git.git`. + */ + +/** scp-like syntax: `[user@]host:path`, which is not a URL and `new URL` will + * not parse. The path must not start with `/` — `host:/path` is ambiguous with + * a port and git treats it as scp too, but we decline rather than guess. */ +const SCP_LIKE = /^(?:([^@/]+)@)?([A-Za-z0-9._-]+):(?!\/)(.+)$/; + +/** Schemes git speaks that address a network host. `file://` and a bare local + * path are deliberately absent: a local clone's "upstream" is a directory on + * this machine, which is what folder trust is for. */ +const REMOTE_SCHEMES = new Set(['https:', 'http:', 'ssh:', 'git:']); + +/** + * Reduce a remote URL to a stable comparison key, or `null` when it is not a + * network remote this code understands. + * + * `git@github.com:diffplug/dormouse.git` and + * `https://github.com/diffplug/dormouse` both become + * `https://github.com/diffplug/dormouse`. + */ +export function canonicalRemoteUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + // `git+https://…` — npm-style, and git itself accepts it in some configs. + const unprefixed = trimmed.replace(/^git\+/, ''); + + const scp = SCP_LIKE.exec(unprefixed); + const normalized = scp ? `ssh://${scp[1] ? `${scp[1]}@` : ''}${scp[2]}/${scp[3]}` : unprefixed; + + let url: URL; + try { + url = new URL(normalized); + } catch { + return null; + } + if (!REMOTE_SCHEMES.has(url.protocol)) return null; + // A URL with no host (`https:///x`) would collapse every remote onto one key. + if (!url.hostname) return null; + + // Userinfo is a credential, not identity: `git@github.com/x/y` and + // `https://github.com/x/y` are the same remote and must share a grant. + // Query and fragment are meaningless on a git remote and are dropped so they + // cannot be used to mint distinct keys for one destination. + const host = url.hostname.toLowerCase(); + // Each scheme's own default, not just the web ones: `new URL` already strips + // 80/443 for http(s), so without ssh's 22 and git's 9418 the long spelling + // `ssh://git@host:22/o/r` would key differently from `git@host:o/r` and split + // one repo's grant in two. + const defaultPorts: Record<string, string> = { 'https:': '443', 'http:': '80', 'ssh:': '22', 'git:': '9418' }; + const port = url.port && url.port !== defaultPorts[url.protocol] ? `:${url.port}` : ''; + + // One trailing `.git`, and only as a suffix of the final segment — not a + // global replace, which would rewrite a path component named `.github`. + const path = url.pathname.replace(/\/+$/, '').replace(/\.git$/, ''); + if (!path || path === '/') return null; + + return `https://${host}${port}${path}`; +} diff --git a/lib/src/host/git-upstream.test.ts b/lib/src/host/git-upstream.test.ts new file mode 100644 index 000000000..61a57ddb8 --- /dev/null +++ b/lib/src/host/git-upstream.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const spawnAndCapture = vi.fn(); +vi.mock('dor-lib-common', () => ({ spawnAndCapture: (...args: unknown[]) => spawnAndCapture(...args) })); + +const { resolveUpstreamUrl } = await import('./git-upstream'); + +const ok = (stdout: string) => ({ ok: true, exitCode: 0, stdout, stderr: '' }); +const failed = (exitCode = 128) => ({ ok: true, exitCode, stdout: '', stderr: 'fatal' }); +const enoent = () => ({ ok: false, error: { code: 'ENOENT', message: 'git not found' } }); + +/** Script the two calls in order: upstream lookup, then remote get-url. */ +function script(...results: unknown[]) { + spawnAndCapture.mockReset(); + for (const result of results) spawnAndCapture.mockResolvedValueOnce(result); + spawnAndCapture.mockResolvedValue(failed()); +} + +beforeEach(() => spawnAndCapture.mockReset()); + +describe('resolveUpstreamUrl', () => { + it('uses the branch upstream’s remote', async () => { + script(ok('origin/main'), ok('git@github.com:diffplug/dormouse.git')); + expect(await resolveUpstreamUrl('/repo')).toBe('https://github.com/diffplug/dormouse'); + expect(spawnAndCapture).toHaveBeenNthCalledWith(1, 'git', + ['-C', '/repo', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); + expect(spawnAndCapture).toHaveBeenNthCalledWith(2, 'git', ['-C', '/repo', 'remote', 'get-url', 'origin']); + }); + + it('prefers a fork over the repo you trusted', async () => { + // The case the branch lookup exists for: a PR branch tracking a + // contributor's fork must not inherit the upstream repo's grant. + script(ok('newbie/pr-500'), ok('https://github.com/newbie/dormouse.git')); + expect(await resolveUpstreamUrl('/repo')).toBe('https://github.com/newbie/dormouse'); + expect(spawnAndCapture).toHaveBeenNthCalledWith(2, 'git', ['-C', '/repo', 'remote', 'get-url', 'newbie']); + }); + + it('keeps a branch name containing slashes out of the remote name', async () => { + script(ok('origin/feature/nested'), ok('https://github.com/o/r')); + await expect(resolveUpstreamUrl('/repo')).resolves.toBe('https://github.com/o/r'); + expect(spawnAndCapture).toHaveBeenNthCalledWith(2, 'git', ['-C', '/repo', 'remote', 'get-url', 'origin']); + }); + + it('falls back to origin with no upstream set', async () => { + script(failed(), ok('https://github.com/diffplug/dormouse')); + expect(await resolveUpstreamUrl('/repo')).toBe('https://github.com/diffplug/dormouse'); + expect(spawnAndCapture).toHaveBeenNthCalledWith(2, 'git', ['-C', '/repo', 'remote', 'get-url', 'origin']); + }); + + it('falls back to origin on a detached HEAD', async () => { + script(ok('HEAD'), ok('https://github.com/diffplug/dormouse')); + expect(await resolveUpstreamUrl('/repo')).toBe('https://github.com/diffplug/dormouse'); + }); + + it('is null when the remote has no URL', async () => { + script(ok('origin/main'), failed()); + expect(await resolveUpstreamUrl('/repo')).toBeNull(); + }); + + it('is null outside a git repo', async () => { + script(failed(), failed()); + expect(await resolveUpstreamUrl('/tmp/plain')).toBeNull(); + }); + + it('is null when git is not installed', async () => { + script(enoent(), enoent()); + expect(await resolveUpstreamUrl('/repo')).toBeNull(); + }); + + it('is null when the remote URL is a local path', async () => { + // A local clone's "upstream" is a directory on this machine; folder trust + // is the right tool for that, not a shared remote key. + script(ok('origin/main'), ok('/srv/repos/bare.git')); + expect(await resolveUpstreamUrl('/repo')).toBeNull(); + }); + + it('never runs a shell and never interpolates the directory', async () => { + script(ok('origin/main'), ok('https://github.com/o/r')); + await resolveUpstreamUrl('/repo with spaces/;rm -rf /'); + for (const [binary, args] of spawnAndCapture.mock.calls) { + expect(binary).toBe('git'); + expect(args[0]).toBe('-C'); + expect(args[1]).toBe('/repo with spaces/;rm -rf /'); + } + }); +}); diff --git a/lib/src/host/git-upstream.ts b/lib/src/host/git-upstream.ts new file mode 100644 index 000000000..3d3a38afa --- /dev/null +++ b/lib/src/host/git-upstream.ts @@ -0,0 +1,49 @@ +/** + * Resolve a project directory's upstream remote URL, for the tool trust key + * (`docs/specs/dor-tool.md` -> Trust, which records the unverifiability of a + * `.git/config`-sourced answer as an accepted risk). + * + * Every failure — no git, not a repo, no upstream, no remote, unparseable URL — + * returns `null`, which leaves the caller offering only a folder grant. Failing + * closed costs one extra approval; guessing would mint a key for the wrong repo. + */ +import { spawnAndCapture } from 'dor-lib-common'; +import { canonicalRemoteUrl } from './git-remote-url'; + +/** The directory travels in argv, not a `cwd` option (`docs/specs/dor-cli.md` + * -> the `spawnAndCapture` rules). `dir` is the host-resolved project root, + * never a raw string off the wire. */ +async function git(dir: string, args: string[]): Promise<string | null> { + const result = await spawnAndCapture('git', ['-C', dir, ...args]); + if (!result.ok || result.exitCode !== 0) return null; + const out = result.stdout.trim(); + return out || null; +} + +/** + * The remote name the current branch tracks (`origin` from `origin/main`), or + * null on a detached HEAD or a branch with no upstream. + */ +async function trackedRemote(dir: string): Promise<string | null> { + const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); + if (!upstream) return null; + // `origin/main` -> `origin`. A remote name cannot contain `/`, so the first + // segment is the remote and everything after is the branch, which may itself + // contain slashes (`origin/feature/x`). + const slash = upstream.indexOf('/'); + return slash > 0 ? upstream.slice(0, slash) : null; +} + +/** + * The canonical upstream URL for `dir`, or null. + * + * Prefers the branch's own upstream over `origin` so a PR branch tracking a + * contributor's fork resolves to the fork rather than to the repo you trusted. + * That is a useful heuristic, not a boundary: a cross-repo PR fetched into + * `origin` with a pull refspec still resolves to `origin`. + */ +export async function resolveUpstreamUrl(dir: string): Promise<string | null> { + const remote = (await trackedRemote(dir)) ?? 'origin'; + const url = await git(dir, ['remote', 'get-url', remote]); + return url ? canonicalRemoteUrl(url) : null; +} diff --git a/lib/src/host/tool-host.test.ts b/lib/src/host/tool-host.test.ts new file mode 100644 index 000000000..c76ce5691 --- /dev/null +++ b/lib/src/host/tool-host.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createToolHost } from './tool-host'; + +const YML = ` +tools: + storybook: + run: pnpm storybook + prespawn_dedupe: [storybook, $PROJECT_ROOT] + scratch: + run: echo hi + noisy: + run: echo noisy + colour: blue +`; + +let repo = ''; +let stateDir = ''; + +beforeEach(async () => { + repo = await mkdtemp(join(tmpdir(), 'dor-tool-host-')); + stateDir = join(repo, '.state'); + await writeFile(join(repo, 'dormouse.yml'), YML); +}); +afterEach(async () => { + await rm(repo, { recursive: true, force: true }); +}); + +describe('createToolHost', () => { + it('asks for trust before resolving anything runnable', async () => { + const host = createToolHost({ stateDir }); + expect(await host.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'untrusted', + run: 'pnpm storybook', + projectRoot: repo, + }); + }); + + it('renders the key host-side once trusted, so the webview never sees a template', async () => { + const host = createToolHost({ stateDir }); + await host.handle({ op: 'trust', kind: 'folder', projectRoot: repo }); + expect(await host.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'ok', + run: 'pnpm storybook', + key: ['storybook', repo], + }); + }); + + it('resolves an upstream grant host-side', async () => { + const host = createToolHost({ stateDir }); + // This fixture is not a git checkout, so an upstream choice must fall back + // to a folder grant. + await host.handle({ + op: 'trust', + kind: 'upstream', + projectRoot: repo, + }); + + expect(await host.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'ok', + }); + }); + + it('reports a null key for an entry that declared none', async () => { + const host = createToolHost({ stateDir }); + await host.handle({ op: 'trust', kind: 'folder', projectRoot: repo }); + const result = await host.handle({ op: 'lookup', name: 'scratch', cwd: repo }); + expect(result).toMatchObject({ status: 'ok', key: null }); + }); + + it('carries lint warnings through to the caller', async () => { + const host = createToolHost({ stateDir }); + await host.handle({ op: 'trust', kind: 'folder', projectRoot: repo }); + const result = await host.handle({ op: 'lookup', name: 'noisy', cwd: repo }); + expect(result).toMatchObject({ status: 'ok' }); + if (result.status !== 'ok') return; + expect(result.warnings).toEqual([expect.stringContaining("unknown field 'colour'")]); + }); + + + it('persists trust to the state directory, surviving a host restart', async () => { + await createToolHost({ stateDir }).handle({ op: 'trust', kind: 'folder', projectRoot: repo }); + expect(await createToolHost({ stateDir }).handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'ok', + }); + }); + + it('forgets trust between runs when the host has no state directory', async () => { + const first = createToolHost(); + await first.handle({ op: 'trust', kind: 'folder', projectRoot: repo }); + expect(await first.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ status: 'ok' }); + expect(await createToolHost().handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'untrusted', + }); + }); + + it('reports an unknown tool with the names it knows', async () => { + const result = await createToolHost({ stateDir }).handle({ op: 'lookup', name: 'nope', cwd: repo }); + expect(result).toMatchObject({ status: 'unknown-tool', names: ['noisy', 'scratch', 'storybook'] }); + }); + + it('reports no-file above any dormouse.yml', async () => { + const empty = await mkdtemp(join(tmpdir(), 'dor-tool-empty-')); + try { + expect(await createToolHost({ stateDir }).handle({ op: 'lookup', name: 'x', cwd: empty })).toEqual({ + status: 'no-file', + }); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }); + + it('returns a parse error rather than throwing across the wire', async () => { + await writeFile(join(repo, 'dormouse.yml'), 'tools:\n t:\n run: x\n prespawn_dedupe: [$NOPE]\n'); + const result = await createToolHost({ stateDir }).handle({ op: 'lookup', name: 't', cwd: repo }); + expect(result).toMatchObject({ status: 'error' }); + }); +}); diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts new file mode 100644 index 000000000..8289defe4 --- /dev/null +++ b/lib/src/host/tool-host.ts @@ -0,0 +1,78 @@ +/** + * The Node-side entry both hosts install for Dor Tools + * (`docs/specs/dor-tool.md`). Bundled into the standalone sidecar as + * `tool-host.cjs` and imported directly by the VS Code extension host. + * + * Two operations, one method: resolve a tool name against the nearest + * `dormouse.yml`, and record a trust decision a human made in Dormouse's own + * chrome. Everything crossing back to the webview is plain JSON — the + * standalone path goes through Rust. + */ +import type { ToolControlResult, ToolHostRequest } from '../lib/platform/tool-types'; +import { resolveUpstreamUrl } from './git-upstream'; +import { resolveDedupeKey } from './tool-registry'; +import { + FileToolTrustStore, + MemoryToolTrustStore, + folderGrantKey, + lookupTool, + upstreamGrantKey, + type ToolTrustStore, +} from './tool-trust'; + +export interface ToolHost { + handle(request: ToolHostRequest): Promise<ToolControlResult>; +} + +/** + * `stateDir` is where the trust record lives. Without one the decision is + * in-memory and dies with the host: a host with no durable state re-asks each + * run, which is annoying but never wrong, where inventing a location could put + * a security decision somewhere the user cannot find to revoke it. + */ +export function createToolHost(options: { stateDir?: string } = {}): ToolHost { + const trust: ToolTrustStore = options.stateDir + ? new FileToolTrustStore(options.stateDir) + : new MemoryToolTrustStore(); + + return { + async handle(request) { + if (request.op === 'trust') { + // The key is derived here, not taken from the request: the webview says + // *which kind* the human picked, and the host owns the mapping from a + // project to its keys. An `upstream` pick with no URL falls back to the + // folder rather than minting a key on an empty string. + const upstream = request.kind === 'upstream' + ? await resolveUpstreamUrl(request.projectRoot) + : null; + await trust.grant( + upstream ? upstreamGrantKey(upstream) : folderGrantKey(request.projectRoot), + upstream ? 'upstream' : 'folder', + ); + return { status: 'trust-recorded' }; + } + + const lookup = await lookupTool(request.name, request.cwd, trust); + if (lookup.status !== 'ok') { + // Every non-ok arm is already wire-shaped. + return lookup; + } + const { entry } = lookup; + try { + return { + status: 'ok', + projectRoot: lookup.projectRoot, + path: lookup.path, + name: entry.name, + run: entry.run, + render: entry.render, + port: entry.port, + key: resolveDedupeKey(entry, { projectRoot: lookup.projectRoot, cwd: request.cwd }), + warnings: [...lookup.file.warnings], + }; + } catch (error) { + return { status: 'error', message: error instanceof Error ? error.message : String(error) }; + } + }, + }; +} diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts new file mode 100644 index 000000000..2154d5de3 --- /dev/null +++ b/lib/src/host/tool-registry.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + ToolFileError, + parseToolFile, + resolveDedupeKey, +} from './tool-registry'; + +const REPO = { path: '/repo/dormouse.yml', dir: '/repo', scope: 'repo' as const }; +const USER = { path: '/home/me/.config/dormouse/tools.yml', dir: '/home/me/.config/dormouse', scope: 'user' as const }; + +function parse(text: string, opts = REPO) { + return parseToolFile(text, opts); +} + +describe('parseToolFile', () => { + it('reads an entry with a key template', () => { + const file = parse(` +tools: + storybook: + run: pnpm storybook + prespawn_dedupe: [storybook, $PROJECT_ROOT] +`); + expect(file.warnings).toEqual([]); + expect(file.tools.get('storybook')).toEqual({ + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe', + port: 'announced', + dedupeTemplate: ['storybook', '$PROJECT_ROOT'], + }); + }); + + it('reads an ab-screencast renderer, the one that makes a tool agent-drivable', () => { + const file = parse('tools:\n harness:\n run: pnpm dev\n render: ab-screencast\n'); + expect(file.tools.get('harness')?.render).toBe('ab-screencast'); + }); + + it('defaults port selection to announced, so nothing guesses unless asked', () => { + expect(parse('tools:\n t:\n run: x\n').tools.get('t')?.port).toBe('announced'); + }); + + it('reads autobind', () => { + expect(parse('tools:\n t:\n run: x\n port: auto\n').tools.get('t')?.port).toBe('auto'); + }); + + it('rejects an unknown port mode', () => { + expect(() => parse('tools:\n t:\n run: x\n port: 6006\n')).toThrow(/'port' must be one of/); + expect(() => parse('tools:\n t:\n run: x\n port: first\n')).toThrow(/'port' must be one of/); + }); + + it('rejects an unknown renderer', () => { + expect(() => parse('tools:\n t:\n run: x\n render: canvas\n')).toThrow(/'render' must be one of/); + }); + + it('treats an absent prespawn_dedupe as no identity at all', () => { + const file = parse('tools:\n once:\n run: echo hi\n'); + expect(file.tools.get('once')?.dedupeTemplate).toBeNull(); + }); + + it('accepts a bare scalar as a one-element key', () => { + const file = parse('tools:\n clock:\n run: tock\n prespawn_dedupe: clock\n', USER); + expect(file.tools.get('clock')?.dedupeTemplate).toEqual(['clock']); + }); + + it('treats an empty file and a file with no tools as empty, not broken', () => { + expect(parse('').tools.size).toBe(0); + expect(parse('# just a comment\n').tools.size).toBe(0); + expect(parse('other: 1\n').tools.size).toBe(0); + }); + + it('rejects an unknown substitution rather than keeping it as a literal', () => { + expect(() => parse('tools:\n t:\n run: x\n prespawn_dedupe: [t, $PROJECTROOT]\n')).toThrow( + /unknown substitution '\$PROJECTROOT'/, + ); + }); + + it('rejects $PROJECT_ROOT in a user-global file', () => { + expect(() => parse('tools:\n t:\n run: x\n prespawn_dedupe: [t, $PROJECT_ROOT]\n', USER)).toThrow( + /only defined for a repo-local/, + ); + }); + + it('rejects an unknown reserved prespawn_* field', () => { + expect(() => parse('tools:\n t:\n run: x\n prespawn_port: true\n')).toThrow( + /unknown reserved field 'prespawn_port'/, + ); + }); + + it('warns but keeps going for an unknown non-reserved field', () => { + const file = parse('tools:\n t:\n run: x\n colour: blue\n'); + expect(file.tools.has('t')).toBe(true); + expect(file.warnings).toEqual([expect.stringContaining("ignoring unknown field 'colour'")]); + }); + + it('warns on a repo-local key with no project scope', () => { + const file = parse('tools:\n t:\n run: x\n prespawn_dedupe: [t]\n'); + expect(file.warnings).toEqual([expect.stringContaining('no $PROJECT_ROOT')]); + expect(file.tools.get('t')?.dedupeTemplate).toEqual(['t']); + }); + + it('does not warn about project scope for a user-global key', () => { + expect(parse('tools:\n t:\n run: x\n prespawn_dedupe: [t]\n', USER).warnings).toEqual([]); + }); + + it('requires a non-empty run', () => { + expect(() => parse('tools:\n t:\n prespawn_dedupe: [t]\n')).toThrow(/'run' is required/); + expect(() => parse('tools:\n t:\n run: " "\n')).toThrow(/'run' is required/); + }); + + it('rejects structurally wrong documents with the file path in the message', () => { + expect(() => parse('- a\n- b\n')).toThrow(/\/repo\/dormouse\.yml: expected a mapping/); + expect(() => parse('tools: 3\n')).toThrow(/'tools' must be a mapping/); + expect(() => parse('tools:\n t: 3\n')).toThrow(/entry must be a mapping/); + expect(() => parse('tools:\n t:\n run: x\n prespawn_dedupe: []\n')).toThrow(/cannot be empty/); + expect(() => parse('tools:\n t:\n run: x\n prespawn_dedupe: [{a: 1}]\n')).toThrow(/must be strings/); + }); + + it('reports malformed YAML as a ToolFileError naming the file', () => { + expect(() => parse('tools:\n - [\n')).toThrow(ToolFileError); + expect(() => parse('tools:\n - [\n')).toThrow(/\/repo\/dormouse\.yml:/); + }); +}); + +describe('resolveDedupeKey', () => { + const entry = (dedupeTemplate: string[] | null) => + ({ name: 't', run: 'x', render: 'iframe' as const, port: 'announced' as const, dedupeTemplate }); + + it('is null when the entry declared no template', () => { + expect(resolveDedupeKey(entry(null), { projectRoot: '/repo', cwd: '/repo/lib' })).toBeNull(); + }); + + it('substitutes the project root and the caller cwd', () => { + expect( + resolveDedupeKey(entry(['t', '$PROJECT_ROOT', '$CWD']), { projectRoot: '/repo', cwd: '/repo/lib' }), + ).toEqual(['t', '/repo', '/repo/lib']); + }); + + it('substitutes inside a larger string', () => { + expect(resolveDedupeKey(entry(['tool@$PROJECT_ROOT']), { projectRoot: '/repo', cwd: '/x' })).toEqual([ + 'tool@/repo', + ]); + }); + + it('keeps two worktrees distinct — the case the list shape exists for', () => { + const template = ['storybook', '$PROJECT_ROOT']; + const a = resolveDedupeKey(entry(template), { projectRoot: '/repo', cwd: '/repo' }); + const b = resolveDedupeKey(entry(template), { projectRoot: '/repo.phase-b', cwd: '/repo.phase-b' }); + expect(a).not.toEqual(b); + }); + + it('throws rather than emitting a literal $PROJECT_ROOT when none is defined', () => { + expect(() => resolveDedupeKey(entry(['t', '$PROJECT_ROOT']), { projectRoot: null, cwd: '/x' })).toThrow( + /\$PROJECT_ROOT is not defined/, + ); + }); +}); + +describe("this repo's own dormouse.yml", () => { + // Pins the file shipped at the repo root against the parser, so a typo in a + // substitution or a stray field fails here rather than at `dor tool` time. + const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..'); + const file = parseToolFile(readFileSync(join(repoRoot, 'dormouse.yml'), 'utf-8'), { + path: 'dormouse.yml', + dir: repoRoot, + scope: 'repo', + }); + + it('parses with no warnings', () => { + expect(file.warnings).toEqual([]); + }); + + it('declares the two shipped tools', () => { + expect([...file.tools.keys()].sort()).toEqual(['standalone-harness', 'storybook']); + expect(file.tools.get('storybook')?.run).toBe('pnpm storybook'); + expect(file.tools.get('standalone-harness')?.run).toBe('pnpm dev:standalone:ab'); + // The harness is the agent-drivable one; storybook only needs framing. + expect(file.tools.get('standalone-harness')?.render).toBe('ab-screencast'); + expect(file.tools.get('storybook')?.render).toBe('iframe'); + // storybook autobinds (it never announces); the harness announces, because + // its dev bridge binds before vite. + expect(file.tools.get('storybook')?.port).toBe('auto'); + expect(file.tools.get('standalone-harness')?.port).toBe('announced'); + }); + + it('scopes every key to the checkout, so parallel worktrees stay distinct', () => { + for (const entry of file.tools.values()) { + const a = resolveDedupeKey(entry, { projectRoot: '/w/one', cwd: '/w/one' }); + const b = resolveDedupeKey(entry, { projectRoot: '/w/two', cwd: '/w/two' }); + expect(a).not.toBeNull(); + expect(a).not.toEqual(b); + } + }); +}); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts new file mode 100644 index 000000000..cbf0b7c07 --- /dev/null +++ b/lib/src/host/tool-registry.ts @@ -0,0 +1,212 @@ +/** + * `dormouse.yml` parsing and dedupe-key resolution for Dor Tools + * (`docs/specs/dor-tool.md` -> Declaring tools, Identity and dedupe). + * + * Everything here is pure given a file's text; discovery and trust live in + * `tool-trust.ts`. Node-side so the YAML dependency stays out of the webview + * bundle. + */ +import { parse as parseYaml } from 'yaml'; + +/** Where a tool file came from. `$PROJECT_ROOT` exists only for `repo`. */ +export type ToolScope = 'repo' | 'user'; + +/** Where a tool's browser renders once it serves. `iframe` frames the page; + * `ab-screencast` drives a real browser, which is what makes a tool + * agent-drivable via `dor ab --surface` (`docs/specs/dor-tool.md`). The repo + * declares it rather than the tool: which renderer suits a tool is a Dormouse- + * side judgement, not something the tool knows about itself. */ +export type ToolRender = 'iframe' | 'ab-screencast'; +const TOOL_RENDERS: readonly ToolRender[] = ['iframe', 'ab-screencast']; + +/** How Dormouse learns which port to frame absent an announcement: `announced` + * frames nothing without OSC 367, `auto` autobinds a single bound port and + * refuses two (`docs/specs/dor-tool.md` -> Serving; the decision itself is + * `use-tool-serving.ts`). */ +export type ToolPortMode = 'announced' | 'auto'; +const TOOL_PORT_MODES: readonly ToolPortMode[] = ['announced', 'auto']; + +export interface ToolEntry { + readonly name: string; + /** Command typed into the spawned shell, exactly as `dor ensure` types one. */ + readonly run: string; + /** Renderer for its browser; `iframe` when unstated. */ + readonly render: ToolRender; + /** Port-selection strategy; `announced` when unstated. */ + readonly port: ToolPortMode; + /** + * `prespawn_dedupe` before substitution; `null` when the entry declared none. + * A null template means no key, which means no dedupe at all — never a key + * derived from the command or cwd (`docs/specs/dor-tool.md`). + */ + readonly dedupeTemplate: readonly string[] | null; +} + +export interface ToolFile { + readonly scope: ToolScope; + /** Absolute directory holding the file. `$PROJECT_ROOT` for a repo scope. */ + readonly dir: string; + readonly tools: ReadonlyMap<string, ToolEntry>; + /** Non-fatal lint output, already prefixed with the file path. */ + readonly warnings: readonly string[]; +} + +export class ToolFileError extends Error {} + +/** Substitutions a `prespawn_dedupe` element may use. Closed set: an + * unrecognized `$NAME` is a parse error, never a literal, because a typo kept + * as a constant string dedupes across every worktree on the machine. */ +const SUBSTITUTIONS = ['$PROJECT_ROOT', '$CWD'] as const; +export type Substitution = (typeof SUBSTITUTIONS)[number]; + +// `$` followed by an identifier. Matches the whole token so an unknown one can +// be named in the error rather than silently surviving as text. +const SUBSTITUTION_TOKEN = /\$[A-Za-z_][A-Za-z0-9_]*/g; + +// The reserved namespace. An unknown member is an error rather than an ignored +// field: silently dropping a dedupe directive the author wrote is the +// destructive failure (two tools, one port), where failing to parse is loud. +const KNOWN_PRESPAWN_FIELDS = new Set(['prespawn_dedupe']); +const KNOWN_ENTRY_FIELDS = new Set(['run', 'render', 'port', 'prespawn_dedupe']); + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Coerce one `prespawn_dedupe` value to its element list. A bare scalar is a + * one-element key, unambiguous because the field has exactly one value shape + * (the reason `prespawn_*` spends a field name per addition). */ +function readDedupeTemplate(value: unknown, where: string): string[] { + const elements = Array.isArray(value) ? value : [value]; + if (elements.length === 0) { + throw new ToolFileError(`${where}: prespawn_dedupe cannot be empty`); + } + return elements.map((element) => { + if (typeof element === 'string') return element; + if (typeof element === 'number' || typeof element === 'boolean') return String(element); + throw new ToolFileError(`${where}: prespawn_dedupe elements must be strings`); + }); +} + +/** Reject unknown `$NAME` tokens, and `$PROJECT_ROOT` outside a repo scope. */ +function validateSubstitutions(template: readonly string[], scope: ToolScope, where: string): void { + for (const element of template) { + for (const token of element.match(SUBSTITUTION_TOKEN) ?? []) { + if (!(SUBSTITUTIONS as readonly string[]).includes(token)) { + throw new ToolFileError( + `${where}: unknown substitution '${token}' (known: ${SUBSTITUTIONS.join(', ')})`, + ); + } + if (token === '$PROJECT_ROOT' && scope !== 'repo') { + throw new ToolFileError(`${where}: $PROJECT_ROOT is only defined for a repo-local dormouse.yml`); + } + } + } +} + +/** + * Parse a tool file. `dir` is the absolute directory holding it and becomes + * `$PROJECT_ROOT` for a repo scope. Throws `ToolFileError` with a + * `<path>: <problem>` message for anything malformed; lint-level problems come + * back as `warnings`. + */ +export function parseToolFile( + text: string, + opts: { path: string; dir: string; scope: ToolScope }, +): ToolFile { + const { path, dir, scope } = opts; + let doc: unknown; + try { + doc = parseYaml(text); + } catch (error) { + throw new ToolFileError(`${path}: ${error instanceof Error ? error.message : String(error)}`); + } + // An empty file is a valid file with no tools, not a broken one. + if (doc === null || doc === undefined) { + return { scope, dir, tools: new Map(), warnings: [] }; + } + if (!isRecord(doc)) throw new ToolFileError(`${path}: expected a mapping at the top level`); + + const toolsNode = doc.tools; + if (toolsNode === undefined) return { scope, dir, tools: new Map(), warnings: [] }; + if (!isRecord(toolsNode)) throw new ToolFileError(`${path}: 'tools' must be a mapping of name to entry`); + + const tools = new Map<string, ToolEntry>(); + const warnings: string[] = []; + + for (const [name, rawEntry] of Object.entries(toolsNode)) { + const where = `${path}: tools.${name}`; + if (!isRecord(rawEntry)) throw new ToolFileError(`${where}: entry must be a mapping`); + + for (const field of Object.keys(rawEntry)) { + if (KNOWN_ENTRY_FIELDS.has(field)) continue; + if (field.startsWith('prespawn_') && !KNOWN_PRESPAWN_FIELDS.has(field)) { + throw new ToolFileError(`${where}: unknown reserved field '${field}'`); + } + warnings.push(`${where}: ignoring unknown field '${field}'`); + } + + const run = rawEntry.run; + if (typeof run !== 'string' || run.trim() === '') { + throw new ToolFileError(`${where}: 'run' is required and must be a non-empty string`); + } + + let dedupeTemplate: string[] | null = null; + if (rawEntry.prespawn_dedupe !== undefined && rawEntry.prespawn_dedupe !== null) { + dedupeTemplate = readDedupeTemplate(rawEntry.prespawn_dedupe, where); + validateSubstitutions(dedupeTemplate, scope, where); + // A repo-local key with no project scope dedupes across every checkout + // that declares the name, so a second worktree's tool would reveal the + // first instead of starting. Warn, not error: a repo-declared + // machine-wide singleton is unusual but legitimate. + if (scope === 'repo' && !dedupeTemplate.some((el) => el.includes('$PROJECT_ROOT'))) { + warnings.push( + `${where}: prespawn_dedupe has no $PROJECT_ROOT, so it dedupes across every checkout of this repo`, + ); + } + } + + const rawRender = rawEntry.render; + if (rawRender !== undefined && !(TOOL_RENDERS as readonly unknown[]).includes(rawRender)) { + throw new ToolFileError(`${where}: 'render' must be one of ${TOOL_RENDERS.join(', ')}`); + } + const render = (rawRender as ToolRender | undefined) ?? 'iframe'; + + const rawPort = rawEntry.port; + if (rawPort !== undefined && !(TOOL_PORT_MODES as readonly unknown[]).includes(rawPort)) { + throw new ToolFileError(`${where}: 'port' must be one of ${TOOL_PORT_MODES.join(', ')}`); + } + const port = (rawPort as ToolPortMode | undefined) ?? 'announced'; + + tools.set(name, { name, run: run.trim(), render, port, dedupeTemplate }); + } + + return { scope, dir, tools, warnings }; +} + +/** + * Render an entry's key for one invocation. Returns `null` when the entry + * declared no template — a tool has an identity if and only if it was given + * one, so a null key means a fresh Surface every time. + */ +export function resolveDedupeKey( + entry: ToolEntry, + context: { projectRoot: string | null; cwd: string }, +): string[] | null { + if (!entry.dedupeTemplate) return null; + return entry.dedupeTemplate.map((element) => + element.replace(SUBSTITUTION_TOKEN, (token) => { + if (token === '$CWD') return context.cwd; + if (token === '$PROJECT_ROOT') { + // Unreachable via parseToolFile, which rejects $PROJECT_ROOT outside a + // repo scope; guard anyway so a caller assembling entries by hand + // cannot produce a key with a literal '$PROJECT_ROOT' in it. + if (context.projectRoot === null) { + throw new ToolFileError(`tool '${entry.name}': $PROJECT_ROOT is not defined here`); + } + return context.projectRoot; + } + return token; + }), + ); +} diff --git a/lib/src/host/tool-trust.test.ts b/lib/src/host/tool-trust.test.ts new file mode 100644 index 000000000..39aca2d29 --- /dev/null +++ b/lib/src/host/tool-trust.test.ts @@ -0,0 +1,295 @@ +import { lstat, mkdtemp, mkdir, rm, symlink, unlink, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + FileToolTrustStore, + MemoryToolTrustStore, + findToolFile, + folderGrantKey, + lookupTool, + upstreamGrantKey, +} from './tool-trust'; + +/** No git in these fixtures; the folder grant is the only key unless stated. */ +const noUpstream = async () => null; + +const YML = ` +tools: + storybook: + run: pnpm storybook + prespawn_dedupe: [storybook, $PROJECT_ROOT] + once: + run: echo hi +`; + +let root = ''; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'dor-tool-trust-')); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('findToolFile', () => { + it('walks up from a nested cwd to the nearest dormouse.yml', async () => { + await writeFile(join(root, 'dormouse.yml'), YML); + const nested = join(root, 'lib', 'src'); + await mkdir(nested, { recursive: true }); + const found = await findToolFile(nested); + expect(found?.dir).toBe(root); + expect(found?.text).toContain('storybook'); + }); + + it('is null when no file exists up to the filesystem root', async () => { + expect(await findToolFile(root)).toBeNull(); + }); + + it('stops at the nearest file rather than the outermost', async () => { + await writeFile(join(root, 'dormouse.yml'), YML); + const inner = join(root, 'inner'); + await mkdir(inner, { recursive: true }); + await writeFile(join(inner, 'dormouse.yml'), 'tools:\n t:\n run: x\n'); + expect((await findToolFile(inner))?.dir).toBe(inner); + }); +}); + +describe('FileToolTrustStore', () => { + it('is untrusted until a grant is recorded, then remembers it across instances', async () => { + const stateDir = join(root, 'state'); + const key = folderGrantKey('/repo'); + expect(await new FileToolTrustStore(stateDir).isTrusted([key])).toBe(false); + await new FileToolTrustStore(stateDir).grant(key, 'folder'); + expect(await new FileToolTrustStore(stateDir).isTrusted([key])).toBe(true); + }); + + it('shares one upstream grant across every checkout — the point of the change', async () => { + const store = new FileToolTrustStore(join(root, 'state')); + const upstream = upstreamGrantKey('https://github.com/diffplug/dormouse'); + await store.grant(upstream, 'upstream'); + // A second worktree resolves the same upstream and a different folder. + expect(await store.isTrusted([folderGrantKey('/w/two'), upstream])).toBe(true); + // ...while an unrelated repo with no upstream grant does not. + expect(await store.isTrusted([folderGrantKey('/w/other')])).toBe(false); + }); + + it('keys folder grants on the resolved path', async () => { + const store = new FileToolTrustStore(join(root, 'state')); + await store.grant(folderGrantKey('/repo/../repo'), 'folder'); + expect(await store.isTrusted([folderGrantKey('/repo')])).toBe(true); + }); + + it('keeps upstream and folder keys from colliding', async () => { + const store = new FileToolTrustStore(join(root, 'state')); + await store.grant(folderGrantKey('/repo'), 'folder'); + expect(await store.isTrusted([upstreamGrantKey('/repo')])).toBe(false); + }); + + it('serializes concurrent grants and merges each against the committed file', async () => { + const stateDir = join(root, 'state'); + const first = new FileToolTrustStore(stateDir); + const second = new FileToolTrustStore(stateDir); + const folder = folderGrantKey('/repo/one'); + const upstream = upstreamGrantKey('https://github.com/diffplug/dormouse'); + + await Promise.all([ + first.grant(folder, 'folder'), + second.grant(upstream, 'upstream'), + ]); + + const reader = new FileToolTrustStore(stateDir); + expect(await reader.isTrusted([folder])).toBe(true); + expect(await reader.isTrusted([upstream])).toBe(true); + // Long-lived instances also re-read the shared file instead of retaining a + // cache that cannot observe another window's grant. + expect(await first.isTrusted([upstream])).toBe(true); + }); + + it('reclaims an aged lock even when its pid has been recycled', async () => { + const stateDir = join(root, 'state'); + const lockDir = join(stateDir, 'tool-trust.json.lock'); + const lockPath = join(lockDir, 'ticket-orphaned.json'); + await mkdir(lockDir, { recursive: true }); + await writeFile(lockPath, JSON.stringify({ pid: process.pid, token: 'orphaned', ticket: 1 })); + const stale = new Date(Date.now() - 31_000); + await utimes(lockPath, stale, stale); + + const store = new FileToolTrustStore(stateDir); + const grant = store.grant(folderGrantKey('/repo'), 'folder'); + let timeout: ReturnType<typeof setTimeout> | undefined; + const reclaimed = await Promise.race([ + grant.then(() => { + if (timeout) clearTimeout(timeout); + return true; + }), + new Promise<false>((resolve) => { + timeout = setTimeout(() => resolve(false), 250); + }), + ]); + // Keep a mutation that refuses to age out a live pid from leaving a retry + // loop behind after the assertion has proved the regression. + if (!reclaimed) await unlink(lockPath).catch(() => {}); + await grant; + + expect(reclaimed).toBe(true); + expect(await store.isTrusted([folderGrantKey('/repo')])).toBe(true); + }); + + it('migrates a leftover single-file lock from the previous format', async () => { + const stateDir = join(root, 'state'); + const lockPath = join(stateDir, 'tool-trust.json.lock'); + await mkdir(stateDir, { recursive: true }); + await writeFile(lockPath, JSON.stringify({ pid: process.pid, token: 'legacy' })); + + const store = new FileToolTrustStore(stateDir); + await store.grant(folderGrantKey('/repo'), 'folder'); + + expect((await lstat(lockPath)).isDirectory()).toBe(true); + expect(await store.isTrusted([folderGrantKey('/repo')])).toBe(true); + }); + + it('merges concurrent grants after reclaiming one aged lock', async () => { + const stateDir = join(root, 'state'); + const lockDir = join(stateDir, 'tool-trust.json.lock'); + const lockPath = join(lockDir, 'ticket-orphaned.json'); + await mkdir(lockDir, { recursive: true }); + await writeFile(lockPath, JSON.stringify({ pid: process.pid, token: 'orphaned', ticket: 1 })); + const stale = new Date(Date.now() - 31_000); + await utimes(lockPath, stale, stale); + + const keys = Array.from({ length: 8 }, (_, index) => folderGrantKey(`/repo/${index}`)); + await Promise.all(keys.map((key) => new FileToolTrustStore(stateDir).grant(key, 'folder'))); + + const reader = new FileToolTrustStore(stateDir); + for (const key of keys) expect(await reader.isTrusted([key])).toBe(true); + }); + + it('starts empty on a corrupt file rather than failing every tool', async () => { + const stateDir = join(root, 'state'); + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, 'tool-trust.json'), '{not json'); + expect(await new FileToolTrustStore(stateDir).isTrusted([folderGrantKey('/repo')])).toBe(false); + }); + + it('migrates the pre-versioned shape, keeping grants and dropping denials', async () => { + // v0 was `{ roots: Record<absPath, 'trusted' | 'denied'> }`. A stored denial + // must not survive as anything: the state no longer exists, and nothing can + // revoke or even list it. + const stateDir = join(root, 'state'); + await mkdir(stateDir, { recursive: true }); + await writeFile( + join(stateDir, 'tool-trust.json'), + JSON.stringify({ roots: { '/old/yes': 'trusted', '/old/no': 'denied' } }), + ); + const store = new FileToolTrustStore(stateDir); + expect(await store.isTrusted([folderGrantKey('/old/yes')])).toBe(true); + expect(await store.isTrusted([folderGrantKey('/old/no')])).toBe(false); + }); +}); + +describe('lookupTool', () => { + const write = (text = YML) => writeFile(join(root, 'dormouse.yml'), text); + + it('reports no-file when there is nothing to read', async () => { + expect(await lookupTool('storybook', root, new MemoryToolTrustStore(), undefined, noUpstream)) + .toEqual({ status: 'no-file' }); + }); + + it('asks for trust before running anything, naming the command', async () => { + await write(); + expect(await lookupTool('storybook', root, new MemoryToolTrustStore(), undefined, noUpstream)) + .toMatchObject({ + status: 'untrusted', + projectRoot: root, + name: 'storybook', + run: 'pnpm storybook', + upstreamUrl: null, + }); + }); + + it('offers the upstream when git resolves one', async () => { + await write(); + const upstream = async () => 'https://github.com/diffplug/dormouse'; + expect(await lookupTool('storybook', root, new MemoryToolTrustStore(), undefined, upstream)) + .toMatchObject({ status: 'untrusted', upstreamUrl: 'https://github.com/diffplug/dormouse' }); + }); + + it('runs when the upstream is granted, even in a folder never seen before', async () => { + await write(); + const trust = new MemoryToolTrustStore(); + await trust.grant(upstreamGrantKey('https://github.com/diffplug/dormouse'), 'upstream'); + const upstream = async () => 'https://github.com/diffplug/dormouse'; + expect((await lookupTool('storybook', root, trust, undefined, upstream)).status).toBe('ok'); + }); + + it('resolves once the folder is granted', async () => { + await write(); + const trust = new MemoryToolTrustStore(); + await trust.grant(folderGrantKey(root), 'folder'); + const result = await lookupTool('storybook', root, trust, undefined, noUpstream); + expect(result.status).toBe('ok'); + if (result.status !== 'ok') return; + expect(result.entry.run).toBe('pnpm storybook'); + expect(result.projectRoot).toBe(root); + }); + + + it('reports an unknown tool with the names it does know, before any trust check', async () => { + await write(); + expect(await lookupTool('nope', root, new MemoryToolTrustStore(), undefined, noUpstream)).toMatchObject({ + status: 'unknown-tool', + names: ['once', 'storybook'], + }); + }); + + it('surfaces a parse error as an error rather than throwing', async () => { + await write('tools:\n t:\n run: x\n prespawn_dedupe: [$NOPE]\n'); + const result = await lookupTool('t', root, new MemoryToolTrustStore(), undefined, noUpstream); + expect(result).toMatchObject({ status: 'error' }); + if (result.status !== 'error') return; + expect(result.message).toMatch(/unknown substitution '\$NOPE'/); + }); +}); + +describe('the pre-approval read (regression: review finding 13, PR #493 review)', () => { + it('refuses via fstat, before the file contents are read', async () => { + // Read before the trust check, so its size is chosen by a repo nobody has + // approved yet; parsing a huge one would OOM the host and take every PTY. + await writeFile(join(root, 'dormouse.yml'), `# ${'x'.repeat(300_000)}\n`); + const result = await lookupTool('storybook', root, new MemoryToolTrustStore()); + expect(result).toMatchObject({ status: 'error' }); + if (result.status !== 'error') return; + // Naming the check that fired is the assertion: a status alone is produced + // by the post-read fallback too, so it would stay green with the fstat + // removed — the exact regression this block exists for. + expect(result.message).toMatch(/larger than \d+ bytes$/); + }); + + it('still reads a normal file', async () => { + await writeFile(join(root, 'dormouse.yml'), YML); + expect((await lookupTool('storybook', root, new MemoryToolTrustStore(), undefined, noUpstream)).status).toBe('untrusted'); + }); + + it('refuses a symlink instead of following it before trust', async () => { + const target = join(root, 'repo-controlled-target.yml'); + await writeFile(target, YML); + await symlink(target, join(root, 'dormouse.yml')); + + const result = await lookupTool('storybook', root, new MemoryToolTrustStore(), undefined, noUpstream); + expect(result).toMatchObject({ status: 'error' }); + if (result.status !== 'error') return; + expect(result.message).toMatch(/must be a regular file, not a symbolic link$/); + }); + + it('measures bytes, not UTF-16 code units', async () => { + // Injected reader, so `stat` never runs and `Buffer.byteLength` is the only + // check standing. 100k four-byte characters: well under the cap by + // `.length`, well over it by bytes. Counting code units would let it through. + const oversized = `# ${'\u{1F600}'.repeat(100_000)}\n`; + const result = await lookupTool('storybook', root, new MemoryToolTrustStore(), async () => oversized, noUpstream); + expect(result).toMatchObject({ status: 'error' }); + if (result.status !== 'error') return; + expect(result.message).toMatch(/after reading$/); + }); +}); diff --git a/lib/src/host/tool-trust.ts b/lib/src/host/tool-trust.ts new file mode 100644 index 000000000..d7e6819a2 --- /dev/null +++ b/lib/src/host/tool-trust.ts @@ -0,0 +1,492 @@ +/** + * Tool-file discovery and the repo-trust record + * (`docs/specs/dor-tool.md` -> Trust). + * + * `dormouse.yml` is repo-controlled and its entries execute, so it is inert + * until the project is granted — by its upstream remote URL, or by its folder. + * + * Granting is *not* implemented here: only a gesture in Dormouse's own chrome + * may grant trust (`ToolApproval.tsx`). This module records the decision a + * gesture produced and answers "is it trusted yet?". + */ +import { constants } from 'node:fs'; +import { chmod, lstat, mkdir, open, readFile, readdir, rename, unlink, utimes, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { ToolFileError, parseToolFile, type ToolEntry, type ToolFile } from './tool-registry'; +import { resolveUpstreamUrl } from './git-upstream'; + +export const TOOL_FILE_NAME = 'dormouse.yml'; +/** + * Cap on a `dormouse.yml`. This read happens before the trust check — + * deliberately, so the approval dialog can name the command — so both the file + * type and the bytes read are controlled by a repo nobody has approved yet. A + * real tool file is a few hundred bytes. + */ +const TOOL_FILE_MAX_BYTES = 256 * 1024; + +/** Refuse stable symlinks on every host, then fstat and cap one descriptor. + * POSIX also opens no-follow, closing the lstat/open replacement race there. */ +async function readToolFile(path: string): Promise<string> { + const entry = await lstat(path); + if (entry.isSymbolicLink()) { + throw new ToolFileError(`${path}: tool file must be a regular file, not a symbolic link`); + } + + let file; + try { + const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0; + file = await open(path, constants.O_RDONLY | noFollow); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ELOOP' || code === 'EMLINK') { + throw new ToolFileError(`${path}: tool file must be a regular file, not a symbolic link`); + } + throw error; + } + try { + const info = await file.stat(); + if (!info.isFile()) { + throw new ToolFileError(`${path}: tool file must be a regular file`); + } + if (info.size > TOOL_FILE_MAX_BYTES) { + throw new ToolFileError(`${path}: tool file is larger than ${TOOL_FILE_MAX_BYTES} bytes`); + } + + // The file may grow after fstat. Read at most cap + 1 so that race is + // detected without ever allowing an unbounded allocation or readFile. + const bytes = Buffer.allocUnsafe(TOOL_FILE_MAX_BYTES + 1); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > TOOL_FILE_MAX_BYTES) { + throw new ToolFileError(`${path}: tool file is larger than ${TOOL_FILE_MAX_BYTES} bytes`); + } + return bytes.subarray(0, offset).toString('utf-8'); + } finally { + await file.close(); + } +} +const TRUST_FILE_NAME = 'tool-trust.json'; +const TRUST_LOCK_RETRY_MS = 20; +const TRUST_LOCK_STALE_MS = 30_000; + +interface TrustLockParticipant { + readonly contents: string; + readonly mtimeMs: number; +} + +/** + * What a grant covers. `upstream` is the canonical remote URL the project's + * branch tracks, so every worktree and clone of one repo shares it; `folder` is + * a single project root, for a repo with no resolvable remote or one the user + * wants scoped to this checkout only. + */ +export type TrustGrantKind = 'upstream' | 'folder'; + +/** A grant key: kind-prefixed so one map holds both without collisions. */ +export function upstreamGrantKey(canonicalUrl: string): string { + return `upstream:${canonicalUrl}`; +} +export function folderGrantKey(root: string): string { + return `folder:${resolve(root)}`; +} + +interface TrustGrant { + readonly kind: TrustGrantKind; + /** ISO timestamp. Not read by anything yet; see the schema note below. */ + readonly grantedAt: string; +} + +/** + * There is no `denied`. A refusal closes the tool's pane and writes nothing, so + * a reflexive decline cannot permanently disable tools for every checkout of a + * repo — which would be unrecoverable, since nothing can revoke or even list a + * decision (`docs/specs/dor-tool.md` -> Trust). + * + * The entry is an object rather than a bare `true` on purpose: + * `docs/specs/remote-security-model.md` designed revocation into its ACL record + * from the start and still shipped without callers, but the *field* was there. + * A flat boolean map has nowhere to put one, so adding revocation later would be + * a schema change on a security file. + */ +interface TrustFile { + readonly version: 1; + readonly grants: Record<string, TrustGrant>; +} + +function emptyTrust(): TrustFile { + return { version: 1, grants: {} }; +} + +/** + * Read a stored file, migrating the pre-versioned shape. + * + * v0 was `{ roots: Record<absPath, 'trusted' | 'denied'> }`. Its trusted entries + * become folder grants; its denials are dropped, because the state no longer + * exists and a stored denial would otherwise be permanent and invisible. + */ +function parseTrustFile(parsed: unknown): TrustFile { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return emptyTrust(); + const record = parsed as { version?: unknown; grants?: unknown; roots?: unknown }; + + if (record.version === 1 && record.grants && typeof record.grants === 'object' && !Array.isArray(record.grants)) { + const grants: Record<string, TrustGrant> = {}; + for (const [key, value] of Object.entries(record.grants as Record<string, unknown>)) { + const grant = value as { kind?: unknown; grantedAt?: unknown }; + if (grant?.kind !== 'upstream' && grant?.kind !== 'folder') continue; + grants[key] = { kind: grant.kind, grantedAt: typeof grant.grantedAt === 'string' ? grant.grantedAt : '' }; + } + return { version: 1, grants }; + } + + if (record.roots && typeof record.roots === 'object' && !Array.isArray(record.roots)) { + const grants: Record<string, TrustGrant> = {}; + for (const [root, decision] of Object.entries(record.roots as Record<string, unknown>)) { + if (decision !== 'trusted') continue; + grants[folderGrantKey(root)] = { kind: 'folder', grantedAt: '' }; + } + return { version: 1, grants }; + } + + return emptyTrust(); +} + +/** Records grants. One small JSON file, written temp-then-rename so a crash + * mid-write cannot leave a truncated file that reads as "nothing is trusted". */ +export class FileToolTrustStore { + readonly #dir: string; + readonly #path: string; + readonly #lockPath: string; + + constructor(stateDir: string) { + this.#dir = stateDir; + this.#path = join(stateDir, TRUST_FILE_NAME); + this.#lockPath = `${this.#path}.lock`; + } + + /** Whether any of these keys has been granted. Callers pass every key that + * would cover this project — the upstream and the folder — so one lookup + * answers "may this run?". */ + async isTrusted(keys: readonly string[]): Promise<boolean> { + const { grants } = await this.#read(); + return keys.some((key) => grants[key] !== undefined); + } + + /** Record a grant a human made in Dormouse's chrome. */ + async grant(key: string, kind: TrustGrantKind): Promise<void> { + const release = await this.#acquireCommitLock(); + try { + // Read only after acquiring the cross-process lock. Every host sharing + // this global directory therefore merges against the latest committed + // file rather than a snapshot captured before another grant. + const current = await this.#read(); + const next: TrustFile = { + version: 1, + grants: { ...current.grants, [key]: { kind, grantedAt: new Date().toISOString() } }, + }; + await this.#write(next); + } finally { + await release(); + } + } + + async #read(): Promise<TrustFile> { + try { + return parseTrustFile(JSON.parse(await readFile(this.#path, 'utf-8'))); + } catch { + // A missing file is the common case (nothing trusted yet). A corrupt one + // starts empty rather than throwing: failing closed here means every tool + // stops working, and the cost of starting empty is one more approval. + return emptyTrust(); + } + } + + async #ensureDir(): Promise<void> { + await mkdir(this.#dir, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await chmod(this.#dir, 0o700).catch(() => {}); + } + + async #ensureLockDirectory(): Promise<void> { + for (;;) { + try { + await mkdir(this.#lockPath, { recursive: true, mode: 0o700 }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + + // The lock was one file before it became a directory of participants. + // `recursive` tolerates an existing directory but not that leftover file, + // so remove the obsolete shape before trying the directory create again. + try { + await unlink(this.#lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + // Another new host may have won the migration between our failed mkdir + // and unlink. In that case the desired directory already exists. + const entry = await lstat(this.#lockPath).catch(() => null); + if (entry?.isDirectory()) return; + throw error; + } + } + } + + async #acquireCommitLock(): Promise<() => Promise<void>> { + await this.#ensureDir(); + await this.#ensureLockDirectory(); + const token = randomUUID(); + const choosingPath = join(this.#lockPath, `choosing-${token}.json`); + const ticketPath = join(this.#lockPath, `ticket-${token}.json`); + const owner = { pid: process.pid, token }; + await writeFile(choosingPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); + let ticket: number; + try { + ticket = 1 + await this.#highestPublishedTicket(); + await writeFile(ticketPath, JSON.stringify({ ...owner, ticket }), { flag: 'wx', mode: 0o600 }); + } finally { + await unlink(choosingPath).catch(() => {}); + } + + // A live participant refreshes its unique file, so a genuinely long grant + // keeps its lease while a crash whose pid is later recycled still ages out. + // Unique participant paths make recovery race-free: no waiter ever unlinks + // the pathname a newer owner would reuse. + const heartbeat = setInterval(() => { + const now = new Date(); + void utimes(ticketPath, now, now).catch(() => {}); + }, TRUST_LOCK_STALE_MS / 3); + heartbeat.unref?.(); + + try { + for (;;) { + if (!await this.#hasEarlierParticipant(token, ticket)) break; + await new Promise((resolve) => setTimeout(resolve, TRUST_LOCK_RETRY_MS)); + } + return async () => { + clearInterval(heartbeat); + await unlink(ticketPath).catch(() => {}); + }; + } catch (error) { + clearInterval(heartbeat); + await unlink(ticketPath).catch(() => {}); + throw error; + } + } + + async #highestPublishedTicket(): Promise<number> { + let highest = 0; + for (const name of await readdir(this.#lockPath)) { + if (!name.startsWith('ticket-')) continue; + const participant = await this.#readLockParticipant(join(this.#lockPath, name)); + if (!participant || await this.#reapIfStale(join(this.#lockPath, name), participant)) continue; + try { + const value = JSON.parse(participant.contents) as { ticket?: unknown }; + if (typeof value.ticket === 'number' && Number.isSafeInteger(value.ticket) && value.ticket > highest) { + highest = value.ticket; + } + } catch { + // A fresh malformed participant is handled as a blocker in the wait + // loop; it cannot safely contribute a ticket number here. + } + } + return highest; + } + + async #hasEarlierParticipant(token: string, ticket: number): Promise<boolean> { + for (const name of await readdir(this.#lockPath)) { + const isChoosing = name.startsWith('choosing-'); + const isTicket = name.startsWith('ticket-'); + if (!isChoosing && !isTicket) continue; + const path = join(this.#lockPath, name); + const participant = await this.#readLockParticipant(path); + if (!participant || await this.#reapIfStale(path, participant)) continue; + let value: { token?: unknown; ticket?: unknown }; + try { + value = JSON.parse(participant.contents) as typeof value; + } catch { + return true; + } + if (value.token === token) continue; + if (typeof value.token !== 'string') return true; + // Lamport's choosing marker closes the race where two processes inspect + // the same maximum before either publishes its ticket. + if (isChoosing) return true; + if (typeof value.ticket !== 'number' || !Number.isSafeInteger(value.ticket)) return true; + if (value.ticket < ticket || (value.ticket === ticket && value.token < token)) return true; + } + return false; + } + + async #readLockParticipant(path: string): Promise<TrustLockParticipant | null> { + let participant; + try { + participant = await open(path, constants.O_RDONLY); + const info = await participant.stat(); + return { contents: await participant.readFile('utf-8'), mtimeMs: info.mtimeMs }; + } catch { + return null; + } finally { + await participant?.close().catch(() => {}); + } + } + + async #reapIfStale(path: string, participant: TrustLockParticipant): Promise<boolean> { + let owner: { pid?: unknown } = {}; + try { + owner = JSON.parse(participant.contents) as typeof owner; + } catch { + // A publisher exposes an empty file only while its choosing marker is + // present. Keep any fresh malformed record until its lease expires. + } + if (typeof owner.pid === 'number' && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + await unlink(path).catch(() => {}); + return true; + } + } + } + if (Date.now() - participant.mtimeMs <= TRUST_LOCK_STALE_MS) return false; + await unlink(path).catch(() => {}); + return true; + } + + async #write(state: TrustFile): Promise<void> { + await this.#ensureDir(); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); + await rename(tmp, this.#path); + } +} + +/** An in-memory store, for hosts with no state directory and for tests. */ +export class MemoryToolTrustStore { + readonly #grants = new Map<string, TrustGrant>(); + + async isTrusted(keys: readonly string[]): Promise<boolean> { + return keys.some((key) => this.#grants.has(key)); + } + + async grant(key: string, kind: TrustGrantKind): Promise<void> { + this.#grants.set(key, { kind, grantedAt: new Date().toISOString() }); + } +} + +export type ToolTrustStore = FileToolTrustStore | MemoryToolTrustStore; + +/** + * Walk up from `startDir` for the nearest `dormouse.yml`. Its directory is + * `$PROJECT_ROOT` — free, since the host knows where it found the file, and + * more robust than shelling out to git (it works in a non-git directory). + */ +export async function findToolFile( + startDir: string, + readTextFile: (path: string) => Promise<string> = readToolFile, +): Promise<{ path: string; dir: string; text: string } | null> { + let dir = resolve(startDir); + // Bounded by the filesystem root; `dirname('/') === '/'` is the terminator. + for (;;) { + const path = join(dir, TOOL_FILE_NAME); + try { + const text = await readTextFile(path); + // Backstop for an injected reader that caps nothing; the default reader + // refuses at `stat` first. Distinct wording so a test can name which + // check fired. `byteLength`, not `.length` — the cap is bytes, and + // multi-byte characters would slip past a UTF-16 count. + if (Buffer.byteLength(text, 'utf-8') > TOOL_FILE_MAX_BYTES) { + throw new ToolFileError( + `${path}: tool file content exceeds ${TOOL_FILE_MAX_BYTES} bytes after reading`, + ); + } + return { path, dir, text }; + } catch (error) { + if (error instanceof ToolFileError) throw error; + // Not here (or unreadable) — keep walking. + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export type ToolLookup = + | { status: 'no-file' } + | { status: 'unknown-tool'; projectRoot: string; path: string; names: string[] } + | { + status: 'untrusted'; + projectRoot: string; + path: string; + name: string; + run: string; + /** Canonical upstream URL, or null when there is no resolvable remote — + * the approval UI then offers only the folder grant. */ + upstreamUrl: string | null; + } + | { status: 'error'; message: string } + | { status: 'ok'; projectRoot: string; path: string; file: ToolFile; entry: ToolEntry }; + +/** + * Find, parse, and trust-check the entry named `name` for a caller in `cwd`. + * + * Parsing precedes the trust check on purpose: parsing is inert, and the + * approval dialog has to name the command it is approving. Nothing from the + * file executes on this path. + */ +export async function lookupTool( + name: string, + cwd: string, + trust: ToolTrustStore, + readTextFile?: (path: string) => Promise<string>, + resolveUpstream: (dir: string) => Promise<string | null> = resolveUpstreamUrl, +): Promise<ToolLookup> { + let found; + try { + found = await findToolFile(cwd, readTextFile); + } catch (error) { + // An oversized file: report it rather than letting it reach the parser. + if (error instanceof ToolFileError) return { status: 'error', message: error.message }; + throw error; + } + if (!found) return { status: 'no-file' }; + + let file: ToolFile; + try { + file = parseToolFile(found.text, { path: found.path, dir: found.dir, scope: 'repo' }); + } catch (error) { + if (error instanceof ToolFileError) return { status: 'error', message: error.message }; + throw error; + } + + const entry = file.tools.get(name); + if (!entry) { + return { + status: 'unknown-tool', + projectRoot: found.dir, + path: found.path, + names: [...file.tools.keys()].sort(), + }; + } + + // Either grant covers this project: the upstream every worktree shares, or + // this folder alone. Resolved before the check so the approval UI can offer + // both, and so a hit on either short-circuits identically. + const upstreamUrl = await resolveUpstream(found.dir); + const keys = [folderGrantKey(found.dir), ...(upstreamUrl ? [upstreamGrantKey(upstreamUrl)] : [])]; + if (await trust.isTrusted(keys)) { + return { status: 'ok', projectRoot: found.dir, path: found.path, file, entry }; + } + return { + status: 'untrusted', + projectRoot: found.dir, + path: found.path, + name: entry.name, + run: entry.run, + upstreamUrl, + }; +} diff --git a/lib/src/lib/feature-flags.ts b/lib/src/lib/feature-flags.ts index 639c1c5c7..aa72ad5b1 100644 --- a/lib/src/lib/feature-flags.ts +++ b/lib/src/lib/feature-flags.ts @@ -21,6 +21,15 @@ function readBoolFlag(key: string): boolean { } } +function writeBoolFlag(key: string, enabled: boolean): void { + try { + if (enabled) globalThis.localStorage?.setItem(key, 'true'); + else globalThis.localStorage?.removeItem(key); + } catch { + // No localStorage: nothing to persist. + } +} + /** Whether the Workspace/Window container is enabled. Off by default (dormant). */ export function isWorkspacesEnabled(): boolean { return readBoolFlag(WORKSPACES_FLAG_KEY); @@ -28,12 +37,22 @@ export function isWorkspacesEnabled(): boolean { /** Toggle the workspaces flag (used by dev tooling / the stage-3 Storybook UI). */ export function setWorkspacesEnabled(enabled: boolean): void { - try { - if (enabled) globalThis.localStorage?.setItem(WORKSPACES_FLAG_KEY, 'true'); - else globalThis.localStorage?.removeItem(WORKSPACES_FLAG_KEY); - } catch { - // No localStorage: nothing to persist. - } + writeBoolFlag(WORKSPACES_FLAG_KEY, enabled); +} + +export const TOOLS_FLAG_KEY = 'dormouse.flags.tools'; + +/** Whether Dor Tools are enabled (`docs/specs/dor-tool.md`). Off by default: + * with the flag off, `dor tool` reports that tools are disabled and no + * Session is ever designated, so the serving trigger has nothing to watch and + * no pane can transform. */ +export function isToolsEnabled(): boolean { + return readBoolFlag(TOOLS_FLAG_KEY); +} + +/** Toggle the tools flag (dev tooling / Storybook). */ +export function setToolsEnabled(enabled: boolean): void { + writeBoolFlag(TOOLS_FLAG_KEY, enabled); } export const AB_DEBUG_LOGS_FLAG_KEY = 'dormouse.flags.abDebugLogs'; diff --git a/lib/src/lib/osc-sanitize.ts b/lib/src/lib/osc-sanitize.ts new file mode 100644 index 000000000..eaf8530d7 --- /dev/null +++ b/lib/src/lib/osc-sanitize.ts @@ -0,0 +1,22 @@ +/** + * The shared sanitizer for untrusted OSC payload text — OSC 9/99/777 + * notifications and the OSC 367 tool announcement, all arbitrary process output + * that reaches UI (`docs/specs/alert.md` -> notification protocols). + */ + +/** Clamp by code point, so a truncation cannot split a surrogate pair. */ +export function truncateText(input: string, limit: number): string { + if (input.length <= limit) return input; + return Array.from(input).slice(0, limit).join(''); +} + +/** Collapse control characters and runs of whitespace, trim, then clamp. + * Returns null when nothing survives. */ +export function sanitizeText(input: string, limit: number): string | null { + const collapsed = input + .replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!collapsed) return null; + return truncateText(collapsed, limit); +} diff --git a/lib/src/lib/platform/tool-types.ts b/lib/src/lib/platform/tool-types.ts new file mode 100644 index 000000000..19e007e97 --- /dev/null +++ b/lib/src/lib/platform/tool-types.ts @@ -0,0 +1,43 @@ +/** + * The `toolControl` wire shapes (`docs/specs/dor-tool.md`). + * + * Their own module, like `iframe-proxy-types.ts`: the webview, both adapters, + * and the Node host all reference them, and the Node side must not drag + * `lib/src/host` (and its `yaml` dependency) into a browser bundle. + */ + +export type ToolHostRequest = + | { op: 'lookup'; name: string; cwd: string } + | { op: 'trust'; kind: 'upstream' | 'folder'; projectRoot: string }; + +/** Result of resolving a tool name. `ok` carries the rendered dedupe key: the + * host owns `$PROJECT_ROOT`, so the webview never sees a template. */ +export type ToolLookupResult = + | { status: 'no-file' } + | { status: 'unknown-tool'; projectRoot: string; path: string; names: string[] } + | { + status: 'untrusted'; + projectRoot: string; + path: string; + name: string; + run: string; + /** Canonical upstream URL, or null when there is no resolvable remote. */ + upstreamUrl: string | null; + } + | { status: 'error'; message: string } + | { + status: 'ok'; + projectRoot: string; + path: string; + name: string; + run: string; + /** Renderer for the tool's browser once it serves; 'iframe' by default. */ + render: 'iframe' | 'ab-screencast'; + /** How to pick the port to frame absent an announcement; 'announced' by + * default, meaning nothing is framed without OSC 367. */ + port: 'announced' | 'auto'; + key: string[] | null; + warnings: string[]; + }; + +export type ToolControlResult = ToolLookupResult | { status: 'trust-recorded' }; diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 720bbd197..624d9dd51 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -5,6 +5,9 @@ import type { ShellEntry } from '../shell-defaults'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; +import type { ToolControlResult, ToolHostRequest } from './tool-types'; + +export type { ToolControlResult, ToolHostRequest, ToolLookupResult } from './tool-types'; export interface PtyInfo { id: string; @@ -276,6 +279,14 @@ export interface PlatformAdapter { // host), where the panel falls back to a raw, uninstrumented `<iframe>`. createIframeProxyUrl?(targetUrl: string): Promise<IframeProxyResult>; + // Dor Tools (see docs/specs/dor-tool.md). Two operations behind one method: + // resolve a tool name against the nearest dormouse.yml, and record a trust + // decision a human made in Dormouse's own chrome. Both need a filesystem, so + // this is absent on hosts with none (the web demo), where `dor tool <name>` + // reports that the host cannot read a tool file. `dor tool -- <command>` + // needs none of it and works everywhere. + toolControl?(request: ToolHostRequest): Promise<ToolControlResult>; + // Render-swap support (docs/specs/dor-browser.md → "Display Modal And Render Swaps"; // docs/specs/dor-browser.md → "Pop-Out"). All optional // so hosts degrade: the modal hides whatever isn't backed by a capability. diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 50c3887bf..47799e653 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,4 +1,4 @@ -import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo, RemoteHostLink } from './types'; +import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo, RemoteHostLink, ToolControlResult, ToolHostRequest } from './types'; import { OPEN_PORT_TIMEOUT_MS } from './types'; import { createRemoteHostLinkClient } from '../../host/remote/link-client'; import type { AwaitHandle, AwaitOptions, AwaitOutcome } from '../alert-manager'; @@ -393,6 +393,17 @@ export class VSCodeAdapter implements PlatformAdapter { return result ?? { ok: false, error: 'agent-browser pop-in timed out' }; } + async toolControl(request: ToolHostRequest): Promise<ToolControlResult> { + // The extension host owns the filesystem (vscode-ext/src/tool-host.ts). A + // timeout reports an error rather than hanging `dor tool`, which blocks on it. + const result = await this.requestResponse<ToolControlResult>( + 'tool:control', 'tool:result', { request }, + (msg) => msg.result, + 5000, + ); + return result ?? { status: 'error', message: 'tool request timed out' }; + } + async createIframeProxyUrl(url: string): Promise<IframeProxyResult> { // The extension host stands up the loopback proxy and serves the bytes (see // iframe-proxy-host.ts). On timeout, report unreachable so the panel shows a diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index 66acfe232..199020189 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -186,6 +186,29 @@ describe('restoreSession', () => { expect(result?.paneIds).toEqual(['pane-term', 'pane-web']); }); + it('respawns a restored tool command with integration gating', () => { + const saved: PersistedSession = { + version: 3, + panes: [{ + id: 'pane-tool', + title: 'storybook', + cwd: '/repo', + untouched: true, + surfaceType: 'tool', + command: 'pnpm storybook', + tool: { name: 'storybook', render: 'iframe', port: 'announced' }, + }], + }; + + restoreSession(createPlatform(saved, { 'pane-tool': 'claude --resume should-not-win' })); + + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith('pane-tool', expect.objectContaining({ + command: 'pnpm storybook', + requireIntegration: true, + resumeCommand: null, + })); + }); + it('passes the native lathLayout through untouched', () => { const lathLayout = { version: 1 as const, diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index b464a98ac..74981a4e9 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -48,7 +48,11 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul shell: shellOpts?.shell, args: shellOpts?.args, untouched: pane.untouched, - resumeCommand: recoveryCommands[pane.id] ?? null, + // A tool command is durable, approved Session state and wins over the + // host's unrelated single-use agent recovery channel. + ...(pane.surfaceType === 'tool' + ? { command: pane.command ?? null, requireIntegration: true, resumeCommand: null } + : { resumeCommand: recoveryCommands[pane.id] ?? null }), }); } diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 12d28d7e5..7038d86ba 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -228,6 +228,39 @@ describe('saveSession', () => { expect(platform.getCwd).not.toHaveBeenCalledWith('door-web'); }); + it('persists a tool command and stable metadata for cold respawn', async () => { + const platform = createPlatform(null); + + await saveSession(platform, [{ + id: 'pane-tool', + title: 'storybook', + surfaceType: 'tool', + params: { + surfaceType: 'tool', + command: 'pnpm storybook', + toolName: 'storybook', + toolRender: 'ab-screencast', + toolPort: 'auto', + toolKey: ['storybook', '/repo'], + // Derived state must stay in the Lath projection, never this row. + url: 'http://localhost:6006/', + session: 'dormouse.1.tool', + }, + }]); + + const saved = vi.mocked(platform.saveState).mock.calls[0]![0] as PersistedSession; + expect(saved.panes.find((pane) => pane.id === 'pane-tool')).toMatchObject({ + surfaceType: 'tool', + command: 'pnpm storybook', + tool: { + name: 'storybook', + render: 'ab-screencast', + port: 'auto', + key: ['storybook', '/repo'], + }, + }); + }); + it('persists neither a transcript nor a recovery command', async () => { // Both are absent by construction now: `PlatformAdapter` has no scrollback // reader, and the recovery command is host-owned and rides the boot payload diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 5b45cc951..ba350744a 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,5 +1,5 @@ import type { PlatformAdapter } from './platform/types'; -import { browserPersistedPane, readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; +import { browserPersistedPane, readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType, type PersistedToolMetadata } from './session-types'; import { getActivity, getLivePersistedAlertState, getTerminalPaneState, isUntouched, resolveTerminalSessionId } from './terminal-registry'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; @@ -15,7 +15,7 @@ function getPreviousPaneMap(platform: PlatformAdapter): Map<string, PersistedPan // the unconditional flushes + store-level compare only bound the staleness. export async function saveSession( platform: PlatformAdapter, - panes: Array<{ id: string; title: string; surfaceType?: PersistedSurfaceType }>, + panes: Array<{ id: string; title: string; surfaceType?: PersistedSurfaceType; params?: Record<string, unknown> }>, doors: PersistedDoor[] = [], // The native Lath persisted layout (docs/specs/tiling-engine.md → "Persistence"). // The only layout Dormouse writes. @@ -32,16 +32,26 @@ export async function saveSession( // only for `saveState` to drop the result. if (platform.persistsSession === false) return; const previousPanes = getPreviousPaneMap(platform); - const allPanes = new Map<string, { id: string; title: string; surfaceType: PersistedSurfaceType }>(); + const allPanes = new Map<string, { id: string; title: string; surfaceType: PersistedSurfaceType; params?: Record<string, unknown> }>(); for (const pane of panes) { - allPanes.set(pane.id, { id: pane.id, title: persistedVisiblePaneTitle(pane.title), surfaceType: pane.surfaceType ?? 'terminal' }); + allPanes.set(pane.id, { + id: pane.id, + title: persistedVisiblePaneTitle(pane.title), + surfaceType: pane.surfaceType ?? 'terminal', + params: pane.params, + }); } const persistedDoors = doors.map((door) => ({ ...door, title: persistedDoorTitle(door.id, door.title, door.component), })); for (const item of persistedDoors) { - allPanes.set(item.id, { id: item.id, title: item.title, surfaceType: item.component === 'browser' ? 'browser' : 'terminal' }); + // A Door's component is the leaf's kind: a minimized tool must persist as + // 'tool', or its row round-trips as a plain terminal. + const doorSurfaceType = item.component === 'browser' || item.component === 'tool' + ? item.component + : 'terminal'; + allPanes.set(item.id, { id: item.id, title: item.title, surfaceType: doorSurfaceType, params: item.params }); } const persisted: PersistedPane[] = await Promise.all( @@ -57,13 +67,23 @@ export async function saveSession( const liveAlert = getLivePersistedAlertState(pane.id); const sessionId = resolveTerminalSessionId(pane.id); const cwd = await platform.getCwd(sessionId); - return { + const terminalPane: PersistedPane = { id: pane.id, title: pane.title, cwd: cwd ?? previousPane?.cwd ?? null, untouched: isUntouched(pane.id), alert: liveAlert ?? previousPane?.alert ?? null, }; + if (pane.surfaceType !== 'tool') return terminalPane; + + const command = toolCommandFromParams(pane.params) ?? previousPane?.command; + const tool = toolMetadataFromParams(pane.params) ?? previousPane?.tool; + return { + ...terminalPane, + surfaceType: 'tool', + ...(command ? { command } : {}), + ...(tool ? { tool } : {}), + }; }), ); const session: PersistedSession = { @@ -77,6 +97,22 @@ export async function saveSession( platform.saveState(session); } +function toolCommandFromParams(params: Record<string, unknown> | undefined): string | null { + const command = params?.command; + return typeof command === 'string' && command.trim() ? command : null; +} + +function toolMetadataFromParams(params: Record<string, unknown> | undefined): PersistedToolMetadata | null { + if (!params) return null; + const name = typeof params.toolName === 'string' && params.toolName ? params.toolName : undefined; + const render = params.toolRender === 'ab-screencast' ? 'ab-screencast' : 'iframe'; + const port = params.toolPort === 'auto' ? 'auto' : 'announced'; + const key = Array.isArray(params.toolKey) && params.toolKey.every((part) => typeof part === 'string') + ? params.toolKey as string[] + : undefined; + return { ...(name ? { name } : {}), render, port, ...(key ? { key } : {}) }; +} + function persistedVisiblePaneTitle(title: string): string { const trimmed = title.trim(); return trimmed || UNNAMED_PANEL_TITLE; diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 12bd148ee..04b3c0b4d 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -9,7 +9,17 @@ export interface PersistedAlertState { } /** Absent means terminal; browser panes rebuild from the persisted layout. */ -export type PersistedSurfaceType = 'terminal' | 'browser'; +export type PersistedSurfaceType = 'terminal' | 'browser' | 'tool'; + +/** Stable declaration/runtime identity needed to rebuild a tool after its PTY + * is respawned. Derived browser state (URL/session/port conflict) never enters + * this projection. */ +export interface PersistedToolMetadata { + name?: string; + render: 'iframe' | 'ab-screencast'; + port: 'announced' | 'auto'; + key?: string[]; +} /** Durable pane structure, never scrollback. Single-use recovery commands travel * out of band through `PlatformAdapter.getRecoveryCommands`. */ @@ -20,6 +30,11 @@ export interface PersistedPane { untouched: boolean; alert?: PersistedAlertState | null; surfaceType?: PersistedSurfaceType; + /** Tool-only command, re-run on cold restore. This is separate from the + * host-owned, single-use agent recovery command. */ + command?: string; + /** Tool-only stable metadata; browser state is re-derived after respawn. */ + tool?: PersistedToolMetadata; } /** Shared browser-pane projection for renderer saves and VS Code host refresh. */ @@ -131,11 +146,23 @@ function isPersistedPaneShape(value: unknown): boolean { // them and stay readable, new ones never do, and `normalizeSessionV3` strips // both either way. (value.untouched === undefined || typeof value.untouched === 'boolean') && - (value.surfaceType === undefined || value.surfaceType === 'terminal' || value.surfaceType === 'browser') && + (value.surfaceType === undefined || value.surfaceType === 'terminal' || value.surfaceType === 'browser' || value.surfaceType === 'tool') && + (value.command === undefined || (value.surfaceType === 'tool' && typeof value.command === 'string')) && + (value.tool === undefined || (value.surfaceType === 'tool' && isPersistedToolMetadataShape(value.tool))) && (value.alert === undefined || isPersistedAlertShape(value.alert)) ); } +function isPersistedToolMetadataShape(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + (value.name === undefined || typeof value.name === 'string') && + (value.render === 'iframe' || value.render === 'ab-screencast') && + (value.port === 'announced' || value.port === 'auto') && + (value.key === undefined || (Array.isArray(value.key) && value.key.every((part) => typeof part === 'string'))) + ); +} + function isPersistedDoor(value: unknown): value is PersistedDoor { if (!isRecord(value)) return false; return ( diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 1cbea2998..07182c025 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,3 +1,4 @@ +import { clearToolAnnounce } from './tool-announce-store'; import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; @@ -502,7 +503,16 @@ export function resumeTerminal( // agent the host interrupted on its way down, which this pane re-runs itself. export function restoreTerminal( id: string, - opts: { cwd?: string | null; title?: string | null; cwdWarning?: string | null; shell?: string; args?: string[]; untouched?: boolean; resumeCommand?: string | null }, + opts: { + cwd?: string | null; + title?: string | null; + shell?: string; + args?: string[]; + untouched?: boolean; + resumeCommand?: string | null; + command?: string | null; + requireIntegration?: boolean; + }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -518,10 +528,6 @@ export function restoreTerminal( setTerminalUserTitle(id, trimmedTitle); } - if (opts.cwdWarning) { - entry.terminal.write(`\r\n\x1b[33m${opts.cwdWarning}\x1b[0m\r\n`); - } - const dims = entry.fit.proposeDimensions(); getPlatform().spawnPty(id, { cols: dims?.cols || 80, @@ -534,17 +540,19 @@ export function restoreTerminal( // Revalidated rather than trusted: the snapshot may have been written by an // older detector, and this string is about to be executed. - const resume = opts.resumeCommand ? normalizeResumeCommand(opts.resumeCommand) : null; - if (resume) { + const restoredCommand = opts.command?.trim() ? opts.command : null; + const resume = !restoredCommand && opts.resumeCommand ? normalizeResumeCommand(opts.resumeCommand) : null; + const command = restoredCommand ?? resume; + if (command) { // A passive notice, not a dialog: the pane has no transcript, so without it // an agent simply appears. It also states the discontinuity the resume hides // — the interrupted turn did not continue. - entry.terminal.write(`${DIM}⟲ resuming agent session: ${resume}${RESET}\r\n`); + if (resume) entry.terminal.write(`${DIM}⟲ resuming agent session: ${resume}${RESET}\r\n`); // Seeded before the write because this bypasses xterm's keystroke fallback, // and typed only once the fresh shell reaches a prompt — spawn-then-type is // exactly the window shell startup swallows keystrokes in. - seedLaunchedCommand(id, resume, opts.cwd ?? undefined); - typeCommandWhenPromptReady(id, resume, false); + seedLaunchedCommand(id, command, opts.cwd ?? undefined); + typeCommandWhenPromptReady(id, command, opts.requireIntegration === true); } return entry; @@ -586,6 +594,10 @@ export function disposeSession(id: string): void { registry.delete(id); removeTerminalPaneState(id); removeMouseSelectionState(id); + // A port hint must not outlive its Session: a recycled pane id would inherit + // the previous tenant's announced port and then frame nothing forever + // (docs/specs/dor-tool.md -> Serving). + clearToolAnnounce(id); notifyActivityListeners(); } diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 40f56edcd..f0bcd5b29 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -1,5 +1,8 @@ import type { ActivityNotification, ProtocolProgressUpdate } from './alert-manager'; import { parseColor } from './css-color'; +import { sanitizeText, truncateText } from './osc-sanitize'; +import { recordToolAnnounce } from './tool-announce-store'; +import { parseToolAnnounce, type ToolAnnounce } from './tool-announce'; import { cwdFromOsc1337, cwdFromOsc633, @@ -13,6 +16,7 @@ import { export type TerminalProtocolEvent = | { kind: 'notification'; notification: ActivityNotification } + | { kind: 'toolAnnounce'; announce: ToolAnnounce } | { kind: 'progress'; progress: ProtocolProgressUpdate } | { kind: 'response'; data: string } | { kind: 'semantic'; event: TerminalSemanticEvent }; @@ -128,6 +132,12 @@ export class TerminalProtocolParser { if (content === '2' || content.startsWith('2;')) return parseOscTitle(content, 'osc2'); if (content === '99' || content.startsWith('99;')) return this.parseOsc99(content); if (content === '777' || content.startsWith('777;')) return this.parseOsc777(content); + // OSC 367 is stripped whether or not it parses: a malformed announcement + // must not print itself into the user's scrollback. + if (content === '367' || content.startsWith('367;')) { + const announce = content.startsWith('367;') ? parseToolAnnounce(content.slice('367;'.length)) : null; + return announce ? [{ kind: 'toolAnnounce', announce }] : []; + } const colorResponse = this.parseColorQuery(content); if (colorResponse) return colorResponse; if (isKnownUnsupportedIterm2Osc(content)) return []; @@ -255,6 +265,9 @@ export function applyTerminalProtocolEvents( sink.notifyFromProtocol(id, event.notification); } else if (event.kind === 'progress') { sink.updateProtocolProgress(id, event.progress); + } else if (event.kind === 'toolAnnounce') { + // Recording is not acting — see `tool-announce-store.ts`. + recordToolAnnounce(id, event.announce); } } } @@ -554,24 +567,10 @@ function decodeBase64(input: string): string | null { } } -function sanitizeText(input: string, limit: number): string | null { - const collapsed = input - .replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!collapsed) return null; - return truncateText(collapsed, limit); -} - function appendLimited(existing: string, next: string, limit: number): string { return truncateText(`${existing}${next}`, limit); } -function truncateText(input: string, limit: number): string { - if (input.length <= limit) return input; - return Array.from(input).slice(0, limit).join(''); -} - const DEVICE_ATTRIBUTE_PENDING_SUFFIXES = ['\x1b[>', '\x1b[', '\x1b', '\x9b>', '\x9b']; function stripDeviceAttributeQueries( diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 47221a568..0bb0cc648 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -415,6 +415,20 @@ describe('terminal-registry alert behavior', () => { expect(received).toEqual(['claude --resume 4f2c9b1e-6a03\r']); }); + it('auto-runs a restored tool command once shell integration is ready', async () => { + const id = 'restored-tool-command'; + const received: string[] = []; + fakePlatform.setInputHandler(id, (data) => received.push(data)); + + restoreTerminal(id, { command: 'pnpm storybook', requireIntegration: true }); + expect(getTerminalPaneState(id).currentCommand?.rawCommandLine).toBe('pnpm storybook'); + expect(received).toEqual([]); + + applyTerminalSemanticEvents(id, [{ type: 'promptStart' }]); + await vi.advanceTimersByTimeAsync(200); + expect(received).toEqual(['pnpm storybook\r']); + }); + it('announces the resume in the pane instead of replaying a transcript', () => { const id = 'noticed-resume-command'; const entry = restoreTerminal(id, { resumeCommand: 'codex resume 01JCX8ZK' }); diff --git a/lib/src/lib/tool-announce-store.ts b/lib/src/lib/tool-announce-store.ts new file mode 100644 index 000000000..7845a0fec --- /dev/null +++ b/lib/src/lib/tool-announce-store.ts @@ -0,0 +1,55 @@ +/** + * Per-Session record of the latest OSC 367 `serve` announcement + * (`docs/specs/dor-tool.md` -> Serving, OSC 367). + * + * A module store rather than adapter plumbing: every adapter already funnels + * PTY data through `applyTerminalProtocolEvents`, so recording here reaches the + * Wall without a new message on four transports. + * + * **Recording is not acting.** An announcement from an ordinary terminal lands + * here and does nothing — only a tool-designated Session reads it, and even + * then it only *selects among* the ports the scan found. Output alone never + * creates surfaces. + */ +import type { ToolAnnounce } from './tool-announce'; + +const announces = new Map<string, ToolAnnounce>(); +const listeners = new Set<() => void>(); +let snapshot: ReadonlyMap<string, ToolAnnounce> = new Map(); + +function publish(): void { + snapshot = new Map(announces); + for (const listener of listeners) listener(); +} + +/** Last-write-wins: the announcement is re-emittable, so a tool that changes + * its port or its name simply says so again. */ +export function recordToolAnnounce(id: string, announce: ToolAnnounce): void { + announces.set(id, announce); + publish(); +} + +/** Drop a Session's announcement when it dies, so a recycled pane id cannot + * inherit the previous tenant's port hint. */ +export function clearToolAnnounce(id: string): void { + if (announces.delete(id)) publish(); +} + +export function getToolAnnounce(id: string): ToolAnnounce | null { + return announces.get(id) ?? null; +} + +export function getToolAnnounceSnapshot(): ReadonlyMap<string, ToolAnnounce> { + return snapshot; +} + +export function subscribeToToolAnnounce(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Test seam. */ +export function resetToolAnnounces(): void { + announces.clear(); + publish(); +} diff --git a/lib/src/lib/tool-announce.test.ts b/lib/src/lib/tool-announce.test.ts new file mode 100644 index 000000000..6ec589f6c --- /dev/null +++ b/lib/src/lib/tool-announce.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { parseToolAnnounce } from './tool-announce'; +import { TerminalProtocolParser } from './terminal-protocol'; +import { getToolAnnounce, resetToolAnnounces } from './tool-announce-store'; +import { applyTerminalProtocolEvents } from './terminal-protocol'; + +const serve = (payload: unknown) => `serve;${JSON.stringify(payload)}`; + +describe('parseToolAnnounce', () => { + it('reads a full serve payload', () => { + expect(parseToolAnnounce(serve({ port: 6006, name: 'Storybook', key: ['storybook', '/repo'], dehydrate: true, persist: 'never', v: 1 }))).toEqual({ + port: 6006, + name: 'Storybook', + key: ['storybook', '/repo'], + dehydrate: true, + persist: 'never', + }); + }); + + it('defaults the reserved fields when unstated', () => { + expect(parseToolAnnounce(serve({ port: 4242 }))).toEqual({ + port: 4242, + name: null, + key: null, + dehydrate: false, + persist: null, + }); + }); + + it('ignores every verb but serve — dehydrate is D2 and half-honoring it is worse than dropping it', () => { + expect(parseToolAnnounce('dehydrate;{"v":1}')).toBeNull(); + expect(parseToolAnnounce('progress;{"v":1}')).toBeNull(); + }); + + it('never throws on malformed output', () => { + expect(parseToolAnnounce('serve;not json')).toBeNull(); + expect(parseToolAnnounce('serve;[1,2]')).toBeNull(); + expect(parseToolAnnounce('serve;null')).toBeNull(); + expect(parseToolAnnounce('serve;')).toBeNull(); + expect(parseToolAnnounce('serve')).toBeNull(); + expect(parseToolAnnounce('')).toBeNull(); + }); + + it('rejects a payload past the size cap rather than parsing it', () => { + expect(parseToolAnnounce(serve({ port: 1, name: 'x'.repeat(8000) }))).toBeNull(); + }); + + it('rejects ports outside the valid range', () => { + for (const port of [0, -1, 65536, 1.5, '6006']) { + expect(parseToolAnnounce(serve({ port, name: 'n' }))?.port ?? null).toBeNull(); + } + }); + + it('sanitizes the name like every other OSC payload', () => { + expect(parseToolAnnounce(serve({ port: 1, name: 'Storybook\n\nhere' }))?.name).toBe('Story book here'); + }); + + it('clamps an over-long name instead of dropping the announcement', () => { + const announce = parseToolAnnounce(serve({ port: 1, name: 'a'.repeat(500) })); + expect(announce?.name).toHaveLength(200); + }); + + it('rejects a key that is not a list of strings, and caps its length', () => { + expect(parseToolAnnounce(serve({ key: 'storybook' }))).toBeNull(); + expect(parseToolAnnounce(serve({ key: [1, 2] }))).toBeNull(); + expect(parseToolAnnounce(serve({ key: [] }))).toBeNull(); + expect(parseToolAnnounce(serve({ key: Array(20).fill('x') }))).toBeNull(); + }); + + it('returns null when nothing actionable is stated', () => { + expect(parseToolAnnounce(serve({ v: 1 }))).toBeNull(); + expect(parseToolAnnounce(serve({ dehydrate: true }))).toBeNull(); + }); +}); + +describe('OSC 367 at the PTY boundary', () => { + const sink = { notifyFromProtocol: () => {}, updateProtocolProgress: () => {} }; + + function feed(id: string, data: string) { + const parser = new TerminalProtocolParser(); + const result = parser.process(data); + applyTerminalProtocolEvents(sink, id, result.events); + return result; + } + + it('strips the sequence from what the terminal renders', () => { + resetToolAnnounces(); + const result = feed('s1', `before\x1b]367;${serve({ port: 6006 })}\x1b\\after`); + expect(result.visibleData).toBe('beforeafter'); + }); + + it('strips a malformed announcement too, so it cannot print itself', () => { + resetToolAnnounces(); + expect(feed('s2', 'a\x1b]367;serve;garbage\x1b\\b').visibleData).toBe('ab'); + expect(getToolAnnounce('s2')).toBeNull(); + }); + + it('accepts BEL as the terminator, as the other OSC readers do', () => { + resetToolAnnounces(); + feed('s3', `\x1b]367;${serve({ port: 1234 })}\x07`); + expect(getToolAnnounce('s3')?.port).toBe(1234); + }); + + it('records last-write-wins, because the announcement is re-emittable', () => { + resetToolAnnounces(); + feed('s4', `\x1b]367;${serve({ port: 1 })}\x1b\\`); + feed('s4', `\x1b]367;${serve({ port: 2 })}\x1b\\`); + expect(getToolAnnounce('s4')?.port).toBe(2); + }); + + it('records an announcement from any Session — recording is not acting', () => { + // An ordinary terminal that prints this gets an entry here and nothing + // else: only a tool-designated Session ever reads it. + resetToolAnnounces(); + feed('plain-terminal', `\x1b]367;${serve({ port: 8080 })}\x1b\\`); + expect(getToolAnnounce('plain-terminal')?.port).toBe(8080); + }); +}); diff --git a/lib/src/lib/tool-announce.ts b/lib/src/lib/tool-announce.ts new file mode 100644 index 000000000..39866c101 --- /dev/null +++ b/lib/src/lib/tool-announce.ts @@ -0,0 +1,92 @@ +/** + * OSC 367 — the Dor Tool announcement (`docs/specs/dor-tool.md` -> OSC 367). + * `DOR` on a phone keypad; registered in `docs/specs/terminal-escapes.md`. + * + * **The announcement never mints a tool.** `port` selects among the ports the + * scan already sees; an announced port that nothing bound frames nothing. + * + * Verb-multiplexed like OSC 633, so the contract can grow without burning + * registry numbers. The payload is untrusted process output that reaches UI, so + * it is sanitized and size-capped like OSC 9/99/777 (`docs/specs/alert.md`). + */ + +import { sanitizeText } from './osc-sanitize'; + +/** Cap on the whole payload before parsing. A tool's announcement is a handful + * of fields; anything larger is a mistake or an attack, and JSON.parse on + * unbounded terminal output is not something to offer. */ +const PAYLOAD_LIMIT = 4096; +const NAME_LIMIT = 200; +const KEY_ELEMENT_LIMIT = 512; +const KEY_ELEMENTS_LIMIT = 8; + +export type ToolAnnounce = { + /** Which of the tool's ports to frame. Null when unstated. */ + port: number | null; + /** Title candidate, feeding the existing channel in terminal-state.md. */ + name: string | null; + /** Re-key request. Never dedupes — a runtime re-key only re-labels its own + * Surface, because a late collision between two Surfaces that both hold work + * cannot be resolved by killing either. */ + key: string[] | null; + /** Reserved for D2: the tool can produce a dehydrate payload on graceful stop. */ + dehydrate: boolean; + /** Reserved for D1/D2 restart policy. */ + persist: 'respawn' | 'never' | null; +}; + +function sanitize(value: unknown, limit: number): string | null { + return typeof value === 'string' ? sanitizeText(value, limit) : null; +} + +function readPort(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isInteger(value)) return null; + return value >= 1 && value <= 65535 ? value : null; +} + +function readKey(value: unknown): string[] | null { + if (!Array.isArray(value) || value.length === 0 || value.length > KEY_ELEMENTS_LIMIT) return null; + const elements: string[] = []; + for (const element of value) { + const cleaned = sanitize(element, KEY_ELEMENT_LIMIT); + if (cleaned === null) return null; + elements.push(cleaned); + } + return elements; +} + +/** + * Parse an OSC 367 payload. `content` is everything after `367;`, i.e. + * `<verb>;<json>`. Returns null for an unknown verb, a malformed payload, or a + * payload with nothing usable in it — never throws, because this runs on + * arbitrary process output. + */ +export function parseToolAnnounce(content: string): ToolAnnounce | null { + const separator = content.indexOf(';'); + if (separator === -1) return null; + const verb = content.slice(0, separator); + // `dehydrate` is D2's verb; parsed as unknown here rather than half-honored. + if (verb !== 'serve') return null; + const raw = content.slice(separator + 1); + if (raw.length === 0 || raw.length > PAYLOAD_LIMIT) return null; + + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch { + return null; + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null; + const record = payload as Record<string, unknown>; + + const announce: ToolAnnounce = { + port: readPort(record.port), + name: sanitize(record.name, NAME_LIMIT), + key: readKey(record.key), + dehydrate: record.dehydrate === true, + persist: record.persist === 'never' ? 'never' : record.persist === 'respawn' ? 'respawn' : null, + }; + // An announcement that says nothing actionable is not an announcement. + if (announce.port === null && announce.name === null && announce.key === null) return null; + return announce; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b11a0631..3a183bb44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -44,7 +44,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: specifier: ^10.4.0 version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) @@ -53,7 +53,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) dor: dependencies: @@ -128,19 +128,22 @@ importers: tailwind-variants: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': specifier: ^10.4.0 version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -149,7 +152,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) chromatic: specifier: ^17.0.0 version: 17.8.0 @@ -167,10 +170,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) server: dependencies: @@ -247,7 +250,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tauri-apps/cli': specifier: ^2.11.2 version: 2.11.4 @@ -259,7 +262,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -277,10 +280,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) standalone/sidecar: dependencies: @@ -299,7 +302,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -311,7 +314,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2(supports-color@7.2.0) @@ -332,10 +335,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) website: dependencies: @@ -363,10 +366,10 @@ importers: devDependencies: '@react-router/dev': specifier: ^8.0.0 - version: 8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -381,10 +384,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -4641,6 +4644,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yauzl-promise@4.0.0: resolution: {integrity: sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==} engines: {node: '>=16'} @@ -5304,11 +5312,11 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 @@ -5606,7 +5614,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.8 @@ -5632,7 +5640,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@6.0.3) - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -5856,10 +5864,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/icons': 2.1.0(react@19.2.8) '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 @@ -5875,25 +5883,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.2 rollup: 4.62.2 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -5910,11 +5918,11 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5924,7 +5932,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -5935,11 +5943,11 @@ snapshots: - supports-color - webpack - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5949,7 +5957,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6055,12 +6063,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@tauri-apps/api@2.11.1': {} @@ -6261,10 +6269,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -6283,13 +6291,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -8650,7 +8658,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -8662,11 +8670,12 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.0 - vitest@4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -8683,7 +8692,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -8766,6 +8775,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.0: {} + yauzl-promise@4.0.0: dependencies: '@node-rs/crc32': 1.10.7 diff --git a/scripts/spec-lint.mjs b/scripts/spec-lint.mjs index a564dec84..ec9db3479 100644 --- a/scripts/spec-lint.mjs +++ b/scripts/spec-lint.mjs @@ -36,7 +36,10 @@ * 10. Word-budget ratchet: every checked file stays under its budget in * scripts/spec-word-budgets.json. Growth past the budget fails; the fix * is to cut, or to raise the budget deliberately in the same PR. Budgets - * carry small headroom so routine edits don't trip it. + * carry small headroom so routine edits don't trip it. A budget may be a + * number (whole file) or {prose, indexLine} — the split form AGENTS.md + * uses, capping its conventions prose and each spec-index line + * separately so adding a spec never costs another spec's routing line. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, dirname, normalize } from 'node:path'; @@ -289,15 +292,60 @@ for (const spec of foldCheckedFiles) { // --- Check 10: word-budget ratchet ------------------------------------------ const BUDGETS_FILE = 'scripts/spec-word-budgets.json'; const budgets = JSON.parse(read(BUDGETS_FILE)); +const countWords = (text) => text.split(/\s+/).filter(Boolean).length; +const raiseHint = + `cut, or raise the budget in ${BUDGETS_FILE} deliberately in the same PR`; + +/** + * AGENTS.md's spec index — the `- **\`path\`** — …` bullets between "## Specs" + * and "## Design". Scoped to that slice on purpose: the Architecture section + * uses the same bullet shape for package paths. + */ +function specIndexLines(text) { + const slice = text.split('\n## Specs')[1]?.split('\n## Design')[0] ?? ''; + return slice.split('\n').filter((l) => /^- \*\*`/.test(l)); +} + +/** + * A split budget caps AGENTS.md's two halves independently, because they grow + * for unrelated reasons and the pooled form made them compete: the index grows + * only when a spec is added and every line of it is routing an agent uses, so + * charging a new spec's line against convention prose taxed the wrong half — + * and the cheapest way to pay was to make the line vaguer, not the file smaller. + */ +function checkSplitBudget(rel, text, budget) { + const index = specIndexLines(text); + const prose = countWords(text) - index.reduce((n, l) => n + countWords(l), 0); + if (prose > budget.prose) { + problems.push( + `${rel}: ${prose} words of prose (excluding the ${index.length}-line spec ` + + `index) exceeds its ${budget.prose}-word budget — ${raiseHint}`, + ); + } + for (const line of index) { + const words = countWords(line); + if (words > budget.indexLine) { + const name = /`([^`]+)`/.exec(line)?.[1] ?? line.slice(0, 40); + problems.push( + `${rel}: spec-index line for ${name} is ${words} words, over the ` + + `${budget.indexLine}-word per-line cap — ${raiseHint}`, + ); + } + } +} + for (const rel of allFiles) { - const words = read(rel).split(/\s+/).filter(Boolean).length; + const text = read(rel); const budget = budgets[rel]; if (budget === undefined) { - problems.push(`${BUDGETS_FILE}: no budget for ${rel} — add one (currently ${words} words)`); - } else if (words > budget) { problems.push( - `${rel}: ${words} words exceeds its ${budget}-word budget — cut, ` + - `or raise the budget in ${BUDGETS_FILE} deliberately in the same PR`, + `${BUDGETS_FILE}: no budget for ${rel} — add one (currently ${countWords(text)} words)`, + ); + } else if (typeof budget === 'object') { + checkSplitBudget(rel, text, budget); + } else if (countWords(text) > budget) { + problems.push( + `${rel}: ${countWords(text)} words exceeds its ${budget}-word budget — ${raiseHint}`, ); } } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 6d189eed0..f7b0102a4 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,8 @@ { - "AGENTS.md": 2750, + "AGENTS.md": { + "prose": 2050, + "indexLine": 55 + }, "SELF_HOST.md": 7100, "docs/specs/alert.md": 7350, "docs/specs/alert.rationale.md": 850, @@ -8,9 +11,10 @@ "docs/specs/deploy.md": 2450, "docs/specs/dor-browser.md": 4850, "docs/specs/dor-browser.rationale.md": 650, - "docs/specs/dor-cli.md": 5900, + "docs/specs/dor-cli.md": 6050, "docs/specs/dor-cli.rationale.md": 600, - "docs/specs/dor-tool.md": 2450, + "docs/specs/dor-tool.md": 3350, + "docs/specs/dor-tool.rationale.md": 1560, "docs/specs/glossary.md": 3325, "docs/specs/layout.md": 9150, "docs/specs/layout.rationale.md": 700, @@ -25,7 +29,7 @@ "docs/specs/shortcuts.md": 1550, "docs/specs/standalone.md": 5100, "docs/specs/standalone.rationale.md": 375, - "docs/specs/terminal-escapes.md": 3950, + "docs/specs/terminal-escapes.md": 4000, "docs/specs/terminal-escapes.rationale.md": 350, "docs/specs/terminal-state.md": 2725, "docs/specs/theme.md": 2350, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 2f5259969..a7c4809b8 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -3,6 +3,7 @@ // TypeScript source while the sidecar itself stays plain CJS. // - lib/src/host/iframe-proxy.ts → sidecar/iframe-proxy.cjs // - lib/src/host/agent-browser-host.ts → sidecar/agent-browser-host.cjs +// - lib/src/host/tool-host.ts → sidecar/tool-host.cjs // - lib/src/host/remote/sidecar-entry.ts → sidecar/remote-host.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; @@ -25,6 +26,7 @@ const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, + { entry: 'tool-host.ts', out: 'tool-host.cjs' }, { entry: 'remote/sidecar-entry.ts', out: 'remote-host.cjs', diff --git a/standalone/scripts/dev-agent-browser-announce.test.mjs b/standalone/scripts/dev-agent-browser-announce.test.mjs new file mode 100644 index 000000000..dc5ccd0e4 --- /dev/null +++ b/standalone/scripts/dev-agent-browser-announce.test.mjs @@ -0,0 +1,23 @@ +// The harness's OSC 367 announcement (docs/specs/dor-tool.md -> OSC 367). +// Pinned here rather than eyeballed: the sequence is invisible in a terminal, +// so a typo in the escape framing would fail silently — the harness would keep +// working and Dormouse would simply frame the wrong port, or none. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const source = readFileSync(fileURLToPath(new URL('./dev-agent-browser.mjs', import.meta.url)), 'utf-8'); + +test('the harness writes an OSC 367 serve naming its vite port', () => { + // ESC ] 367 ; serve ; <json> ESC \ — matched as source text, since the write + // happens only when the harness boots a real vite. + const emitted = source.match( + /process\.stdout\.write\(\s*`\\u001b\]367;serve;\$\{JSON\.stringify\((.*?)\)\}\\u001b\\\\`,?\s*\)/s, + ); + assert.ok(emitted, 'expected a `process.stdout.write` of an OSC 367 serve payload'); + + const payload = JSON.parse(JSON.stringify(eval(`(${emitted[1].replace('vitePort', '1420')})`))); + assert.equal(payload.port, 1420, 'must announce the vite port it chose'); + assert.equal(payload.v, 1, 'must carry the contract version'); +}); diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index b53260112..e2741ba9e 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -134,6 +134,8 @@ const invokeMap = { return result; }, agent_browser_stream_status: ({ session, binaryPath }) => requestSidecar('agentBrowser:streamStatus', { session, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), + tool_control: ({ request }) => + requestSidecar('tool:control', { request }, 'tool:result', (data) => data.result), agent_browser_open: ({ url, headed, binaryPath }) => requestSidecar('agentBrowser:open', { url, headed, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), agent_browser_pop_out: ({ session, url, rect, binaryPath }) => requestSidecar('agentBrowser:popOut', { session, url, rect, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), agent_browser_pop_in: ({ session, url, binaryPath }) => requestSidecar('agentBrowser:popIn', { session, url, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), @@ -333,6 +335,14 @@ log(`starting browser dev host on http://127.0.0.1:${hostPort}`); // harness never runs in CI, and the token dies with the process. log(`bridge token: ${bridgeToken}`); log(`try: curl -H 'content-type: application/json' -d '{"cmd":"pty_request_init"}' 'http://127.0.0.1:${hostPort}/__dormouse_dev_host/send?t=${bridgeToken}'`); +// Dor Tool announcement (docs/specs/dor-tool.md -> OSC 367). This harness binds +// several ports — this one, vite, and the sidecar's control socket — and no +// port scan can guess which to frame, so name it. Harmless outside Dormouse: a +// well-behaved terminal drops an unknown OSC. +process.stdout.write( + `\u001b]367;serve;${JSON.stringify({ port: vitePort, name: 'Dormouse dev', v: 1 })}\u001b\\`, +); + await startHostServer(); startSidecar(); startVite(); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 169006d46..52c054b0e 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -14,6 +14,7 @@ const { createDorControlServer } = require('./dor-control-server'); // Built from lib/src/host/iframe-proxy.ts (shared with the VS Code host) by // scripts/build-sidecar-proxy.mjs. See docs/specs/dor-browser.md. const { createIframeProxyUrl } = require('./iframe-proxy.cjs'); +const { createToolHost } = require('./tool-host.cjs'); // Same pattern: lib/src/host/agent-browser-host.ts is the single source of truth // for the agent-browser host capabilities, run here exactly as the VS Code // extension host runs it. See docs/specs/dor-browser.md → "Agent-Browser Host Capabilities". @@ -50,6 +51,10 @@ const remoteHost = createSidecarRemoteHost({ mgr, }); +// Dor Tools. Shares the app's state directory, so an approved repo stays +// approved across restarts (docs/specs/dor-tool.md -> Trust). +const toolHost = createToolHost({ stateDir: process.env.DORMOUSE_STATE_DIR }); + // The control token arrives from Rust in our own environment, and `pty-core` // merges `process.env` into every shell it spawns — so it has to come out of // there and go back only once the channel is actually listening. A lost bind @@ -133,6 +138,11 @@ function handleLine(line) { case 'sidecar:shutdown': shutdown(); break; case 'dor:controlResponse': dorControl?.respond(data); break; case 'remoteHost:command': remoteHost.handleCommand(data); break; + case 'tool:control': + respondAsync('tool:result', data.requestId, async () => ({ + result: await toolHost.handle(data.request), + })); + break; case 'iframe:createProxyUrl': // Log to stderr — stdout is the JSON-lines protocol channel. respondAsync('iframe:proxyUrl', data.requestId, async () => ({ diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 7b9d790d2..b6502121a 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -518,6 +518,24 @@ fn iframe_create_proxy_url( Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) } +// Resolves a `dor tool <name>` against the nearest dormouse.yml, or records a +// trust decision, in the sidecar (shared lib/src/host/tool-host.ts). Bridge +// only — the parsing, the closed substitution set, and the trust record all +// live in lib so the two hosts cannot drift. See docs/specs/dor-tool.md. +#[tauri::command(async)] +fn tool_control( + state: tauri::State<'_, SidecarState>, + request: JsonValue, +) -> Result<JsonValue, String> { + let response = request_from_sidecar_timeout( + &state, + "tool:control", + serde_json::json!({ "request": request }), + Duration::from_secs(5), + )?; + Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) +} + // ── agent-browser host (docs/specs/dor-browser.md → "Agent-Browser Host Capabilities"). // Thin forwarders to the Node sidecar, which runs the shared // lib/src/host/agent-browser-host.ts — the very same module the VS Code @@ -1618,6 +1636,7 @@ pub fn run() { pty_get_scrollback, pty_graceful_kill_all, iframe_create_proxy_url, + tool_control, pty_request_init, dor_control_response, remote_host_command, diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index dc893a694..31e688db4 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -12,6 +12,8 @@ import type { PlatformAdapter, PtyInfo, RemoteHostLink, + ToolControlResult, + ToolHostRequest, } from "dormouse-lib/lib/platform/types"; import { answerAskCommand, @@ -84,6 +86,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // drops `this` and makes the internal `this.host` access throw. The VS Code // adapter binds for the same reason; mirror it so any call style is safe. this.createIframeProxyUrl = this.createIframeProxyUrl.bind(this); + this.toolControl = this.toolControl.bind(this); this.agentBrowserCommand = this.agentBrowserCommand.bind(this); this.agentBrowserEdit = this.agentBrowserEdit.bind(this); this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this); @@ -160,6 +163,14 @@ export class BrowserSidecarAdapter implements PlatformAdapter { try { return await this.host.invoke("read_clipboard_text"); } catch { return null; } } + async toolControl(request: ToolHostRequest): Promise<ToolControlResult> { + try { + return await this.host.invoke("tool_control", { request }); + } catch (err) { + return { status: "error", message: errMessage(err) }; + } + } + async createIframeProxyUrl(targetUrl: string): Promise<IframeProxyResult> { try { return await this.host.invoke("iframe_create_proxy_url", { target: targetUrl }); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index caa1a5247..9576477d7 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -15,6 +15,8 @@ import type { PlatformAdapter, PtyInfo, RemoteHostLink, + ToolControlResult, + ToolHostRequest, } from "dormouse-lib/lib/platform/types"; import { answerAskCommand, @@ -301,6 +303,15 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + async toolControl(request: ToolHostRequest): Promise<ToolControlResult> { + // The sidecar owns the filesystem (shared lib/src/host/tool-host.ts). + try { + return await rawInvoke<ToolControlResult>("tool_control", { request }); + } catch (err) { + return { status: "error", message: errMessage(err) }; + } + } + async createIframeProxyUrl(targetUrl: string): Promise<IframeProxyResult> { // The sidecar stands up the loopback proxy and serves the bytes (shared // lib/src/host/iframe-proxy.ts). On failure, report unreachable so the panel diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 431a96bef..ccb05e5b6 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -6,6 +6,7 @@ import { attachRouter, flushAllSessions, getAlertStates } from './message-router import { closePoppedOutSessions } from './agent-browser-host'; import { serveWebview } from './webview-messaging'; import { log } from './log'; +import { initToolHost } from './tool-host'; import { captureAgentRecoveryCommands, mergeAlertStates, refreshSavedSessionStateFromPtys, takeRecoveryCommands } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; import { workspaceTitle } from './workspace-chrome'; @@ -81,6 +82,9 @@ export function activate(context: vscode.ExtensionContext) { // The remote Host runs here, in the extension host that owns the PTYs — in // whichever window wins the bind (remote-host.ts). context.subscriptions.push(initRemoteHost(context)); + // Dor Tools: the trust record lives in the extension's global storage, so an + // approved repo stays approved across windows and restarts. + initToolHost(context.globalStorageUri?.fsPath); log.init(); extensionContext = context; ptyManager.setExtensionPath(context.extensionPath); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index dc5336042..39d93214b 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -21,6 +21,8 @@ import type { WebviewMessage, ExtensionMessage } from './message-types'; import type { DorControlRequest } from './pty-manager'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; +import { toolControl } from './tool-host'; +import type { ToolHostRequest } from '../../lib/src/lib/platform/types'; import { ASK_BUDGET_MS } from '../../lib/src/host/remote/service-protocol'; import { configurePeerLink, remoteNotifyPeerChange } from './peer-link'; import { createProcessedPtyStreams } from './processed-pty-streams'; @@ -633,6 +635,15 @@ export function attachRouter( post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; + case 'tool:control': + toolControl(msg.request as ToolHostRequest).then( + (result) => post({ type: 'tool:result', requestId: msg.requestId, result } satisfies ExtensionMessage), + (err) => post({ + type: 'tool:result', requestId: msg.requestId, + result: { status: 'error', message: err?.message ?? String(err) }, + } satisfies ExtensionMessage), + ); + break; case 'iframe:createProxyUrl': createIframeProxyUrl(typeof msg.url === 'string' ? msg.url : '').then( (result) => post({ diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 2abb96cc7..cc19ef5e1 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -9,7 +9,13 @@ import type { AlertSettings } from '../../lib/src/lib/alert-settings'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { TerminalColors } from '../../lib/src/lib/terminal-protocol'; import type { DorControlCancelPayload, DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; -import type { AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; +import type { + AgentBrowserStreamStatusResult, + IframeProxyResult, + OpenPort, + ToolControlResult, + ToolHostRequest, +} from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; import type { RemoteHostCommand, RemoteHostResult } from '../../lib/src/host/remote/service-protocol'; @@ -35,6 +41,7 @@ export type WebviewMessage = | { type: 'agentBrowser:popOut'; session: string; url?: string; rect?: { x: number; y: number; width: number; height: number }; binaryPath?: string; requestId: string } | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; requestId: string } + | { type: 'tool:control'; request: ToolHostRequest; requestId: string } // Peer surfaces: the remote Host runs in the extension host, but the terminals // live in whichever webview opened them. See docs/specs/vscode.md → "Peer // surfaces". `op` is opaque to the router: the operation map lives in @@ -94,6 +101,7 @@ export type ExtensionMessage = | { type: 'agentBrowser:openResult'; requestId: string; ok: boolean; session?: string; wsPort?: number; binaryPath?: string; error?: string } | { type: 'agentBrowser:popResult'; requestId: string; ok: boolean; wsPort?: number; error?: string } | { type: 'iframe:proxyUrl'; requestId: string; result: IframeProxyResult } + | { type: 'tool:result'; requestId: string; result: ToolControlResult } | { type: 'peer:ask'; requestId: string; op: string; params: unknown } // Broadcast to every webview: `rhId` carries a per-adapter tag, so only the // one that asked finds a pending command to settle. diff --git a/vscode-ext/src/tool-host.ts b/vscode-ext/src/tool-host.ts new file mode 100644 index 000000000..2a9c6579a --- /dev/null +++ b/vscode-ext/src/tool-host.ts @@ -0,0 +1,23 @@ +/** + * VS Code extension-host binding for Dor Tools. + * + * The registry, the closed substitution set, and the trust record are + * host-agnostic and live in `lib/src/host/tool-host.ts` — the same module the + * Tauri sidecar bundles, so the two hosts cannot drift + * (`docs/specs/dor-tool.md`). This file only supplies the state directory. + */ +import { createToolHost } from '../../lib/src/host/tool-host'; +import type { ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; + +let host: ReturnType<typeof createToolHost> | null = null; + +/** `stateDir` is the extension's own global storage; without it, trust is + * in-memory and the user re-approves once per window. */ +export function initToolHost(stateDir: string | undefined): void { + host = createToolHost({ stateDir }); +} + +export function toolControl(request: ToolHostRequest): Promise<ToolControlResult> { + if (!host) return Promise.resolve({ status: 'error', message: 'tool host not initialized' }); + return host.handle(request); +} diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 547df11a2..6ce5a54e4 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -334,5 +334,12 @@ "license": "MIT", "author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)", "homepage": "https://github.com/websockets/ws" + }, + { + "name": "yaml", + "version": "2.9.0", + "license": "ISC", + "author": "Eemeli Aro <eemeli@gmail.com>", + "homepage": "https://eemeli.org/yaml/" } ]