diff --git a/.gitignore b/.gitignore index fbc49a524..04935a1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ standalone/sidecar/burrow.cjs # Kept beside it: a checkout that built before the Burrow rename still holds # the old bundle, and `bundle.resources` would ship it. 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 51f339e03..e24dc2a92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/theme.md`** — The two-layer CSS variable strategy, consumed-token resolver, terminal color contract, theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI on every Dormouse terminal's `PATH`: bundling and env contract, `spawnAndCapture` rules, control-socket plumbing, the Surface handle model, the command set. - **`docs/specs/dor-browser.md`** — The browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, the iframe proxy and CSP boundaries. -- **`docs/specs/dor-tool.md`** — Dor Tools (design-stage): the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs and OSC 367 contract. Only capability gating is built. +- **`docs/specs/dor-tool.md`** — Dor Tools: the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs and OSC 367 contract. Designation, trust, serving, and persistence are built behind the Tools flag. - **`docs/specs/vscode.md`** — VS Code host: webview hosting, webview ↔ Workspace mapping, persistence ordering, theme integration, CSP, the build/dogfood pipeline. - **`docs/specs/standalone.md`** — Tauri host: the Rust ↔ Node-sidecar bridge, boot sequence, AppBar, persistence, shutdown ordering, the build/dev workflow. - **`docs/specs/auto-update.md`** — Standalone auto-update: check → approved download → install-on-quit, the Baseboard notice, Windows sidecar teardown, per-platform quit behavior. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 8590e70af..29e173006 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -542,6 +542,12 @@ Source of truth: `dor/src/commands/skill.ts`, `scripts/generate-dor-skill.mjs`, Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `dispatchDorControlRequest` in `lib/src/lib/platform/dor-control-dispatch.ts`. +## Dor Tools + +**Must route `dor tool` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. + +Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. + ## Future - **Surface a dead control channel in the UI.** A lost bind leaves one diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index f61f63b4d..d88485e09 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -1,264 +1,212 @@ # Dor Tools -> Status: design — only the shared [capability gating](#capability-gating) is -> implemented; the `tool` Surface does not exist. Everything else is under -> [Future](#future). +> See `docs/specs/glossary.md` for Surface / Session / Pane / Door vocabulary. +> Owns tool designation, configuration, trust workflow, serving, and command lifecycle. Browser chrome belongs to `docs/specs/dor-browser.md`; notes and closure belong to `docs/specs/notepad.md`; helpers belong to `docs/specs/terminal-context.md`. +> Status: implemented behind `dormouse.flags.tools`, off by default. Unbuilt design is 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), -> whose staged "plugin/backend target axis" this subsumes. +## Files -**Pitch**: a console app that opens a web port, framed in a pane the human and -the agent both see and both drive. No SDK, no protocol: print one escape -sequence, read one env var. +- `dor/src/commands/tool.ts` — CLI entry and generated help. +- `lib/src/host/tool-host.ts` — shared host lookup and trust entry. +- `lib/src/components/wall/use-dor-control.ts` — launch, approval placement, dedupe, and response orchestration. +- `lib/src/components/wall/use-tool-serving.ts` — port discovery and browser lifetime. +- `lib/src/components/wall/ToolPanel.tsx` — terminal/browser composition. ## Capability gating -Nothing in the shipped gating is `tool`-specific: `docs/specs/glossary.md` → -Panes and Surfaces owns the capability model and its `hasTerminal` / -`hasBrowser` predicates, `docs/specs/dor-cli.md` → `dor list` the `--json` -`has_terminal` / `has_browser` row fields and their `has no terminal` / `has no -browser` failures. Still owed: the kind that has both — -[The tool capability set](#the-tool-capability-set). +**Must gate tool creation on `isToolsEnabled`.** The flag disables designation, not parsing of inert announcements or the capability predicates. Capability semantics belong to `docs/specs/glossary.md` → Panes and Surfaces; CLI reporting belongs to `docs/specs/dor-cli.md` → `dor list`. -Source of truth: `KIND_CAPABILITIES` in `dor/src/commands/types.ts`, with -`SURFACE_KINDS` **derived** from it so `--kind` parsing cannot drift; -`dor/src/commands/list.ts`; `requireTerminalSurface` / `requireBrowserSurface` -in `lib/src/components/wall/use-dor-control.ts`. +Source of truth: `isToolsEnabled` in `lib/src/lib/feature-flags.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`. + +## The tool capability set + +**Must designate the Surface as `tool` before its command starts serving.** A Tool has terminal and browser capabilities, including while booting, awaiting approval, showing a port conflict, or resting at a prompt after command exit. Browser operations still require the renderer/session they operate on. + +- **Must retain the Session id, public Surface ref, terminal and notes across serving and renderer changes.** These are changes within one Surface. +- **Must bypass browser `replaceSurface` for Tool renderer swaps**, mutating the Tool's params and releasing the retired browser resources. +- **Must run the terminal Activity model for a Tool**, including when its browser is visible. Watched-command defaults belong to `docs/specs/alert.md`. +- **Must mark input to either capability as touching the Tool.** Never apply the untouched-shell kill or shell-replacement shortcut to a Tool. +- **Must classify Tool params before browser params**, since a serving Tool carries `renderMode` too. + +Source of truth: `surfaceKindFromParams` / `isToolParams` in `lib/src/components/wall/browser-surface.ts`; `onSwapRenderMode` / `requestKill` in `lib/src/components/Wall.tsx`; `lib/src/components/wall/tool-surface.test.ts`. + +## Declaring tools + +**Must resolve a named Tool from the nearest ancestor `dormouse.yml`.** The host owns discovery, bounded reads, YAML parsing, and substitutions; the renderer receives the resolved result. Canonical field shapes are `ToolEntry` in `lib/src/host/tool-registry.ts`. + +| Field | Behavior | +| --- | --- | +| `run` | Required command, typed into the configured shell after integration readiness | +| `render` | `iframe` by default, or `ab-screencast` | +| `port` | `announced` by default, or `auto`; [Serving](#serving) owns selection | +| `prespawn_dedupe` | Optional scalar or list of literal key elements with substitutions | + +- **Must reject unknown `prespawn_*` fields and unknown substitutions**; unknown ordinary fields produce warnings. The substitution set is `$PROJECT_ROOT`, the declaring directory, and `$CWD`, the caller's resolved directory. (rationale) +- **Must preserve scalar `prespawn_dedupe` as a one-element literal list**, never interpret it as a command to execute. Reserve separate fields for future computed keys. (rationale) +- **Must warn when a repo-local key omits `$PROJECT_ROOT`**, while allowing intentional cross-checkout dedupe. +- Reserved: **Must reject `$PROJECT_ROOT` in the future user-global configuration**, which has no project root; see scope **dor-tools** under [Future](#future). + +Source of truth: `lookupTool` in `lib/src/host/tool-trust.ts`; `parseToolFile` / `resolveDedupeKey` in `lib/src/host/tool-registry.ts`; `lib/src/host/tool-registry.test.ts`. + +## Identity and dedupe + +**Must dedupe only when an explicit key exists and `--fresh` is absent.** Neither a command nor its CWD implicitly creates identity; anonymous `dor tool -- ` invocations create fresh Surfaces. (rationale) + +- **Must namespace keys by the host-resolved Tool name**; runtime output supplies scope elements, never another Tool's namespace. +- **Must serialize Tool launch requests in the renderer**, covering lookup, matching, creation, and startup. The current lock serializes all Tool requests, not only matching keys. +- **Must reveal a live matching Tool and report `existing` without sending input.** An idle match restarts its stored command in its own directory and reports `adopted`; a failed restart reports an error. +- **Must reuse and reveal a matching pending approval Surface**, preserving its approval state and reporting `pending`. +- **Must apply runtime re-keys only to the announcing Tool**, without merging Surfaces, transferring state, or killing either side of a collision. (rationale) + +Source of truth: `acquireToolSpawnLock` / the `surface.tool` handler in `lib/src/components/wall/use-dor-control.ts`; `namespacedToolKey` / `toolKeysEqual` in `lib/src/components/wall/browser-surface.ts`; `lib/src/components/Wall.test.tsx`. + +## Trust + +**Must obtain a recorded grant before executing a repo-local named Tool.** Anonymous command invocations carry the caller's explicit command and require no repo-config grant. The local authority boundary belongs to `docs/specs/security-local.md` → Dor Tool configuration. + +1. **Must derive grant keys host-side from the canonical upstream remote URL or project-root folder.** Either recorded key satisfies lookup; upstream trust spans clones and worktrees. (rationale) +2. **Must present unapproved named invocations in a visible pending Tool pane**, returning `pending` without spawning a PTY. Defer requested minimization until approval. Pending approval is never persisted as a runnable Tool. +3. **Must grant only through the approval controls in Dormouse chrome**, never through a `dor` verb or terminal output. The prompt names the proposed command; it is not itself executable terminal content. (rationale) +4. **Must re-resolve the named entry after the grant is written**, then stage the resolved command, renderer, port strategy, and key before exposing its terminal. A Surface closed during the host calls must not start later. +5. **Must close a declined approval through the ordinary close coordinator and record no denial.** Archive failure may retain the pane. (rationale) +6. **Must share grant updates safely across host processes**, merging against the latest file under the existing lock and atomic-write protocol. +7. **Never content-hash grants or re-prompt solely because the config changed.** (rationale) + +Reserved: **Must keep future implicit glob dispatch user-global and limited to user-global Tools**, and gate any future repo `prespawn_*` execution on the same approval; see scope **dor-tools** under [Future](#future). + +Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrustStore` / `lookupTool` in `lib/src/host/tool-trust.ts`; `resolveUpstreamUrl` in `lib/src/host/git-upstream.ts`; `ToolApproval` in `lib/src/components/wall/ToolApproval.tsx`; `resolveToolApproval` in `lib/src/components/Wall.tsx`. Tests: `lib/src/host/tool-trust.test.ts`, `lib/src/components/Wall.test.tsx`. + +## Serving + +**Must frame only a port returned by the Tool Session's process-tree scan while its designated command is current.** An OSC announcement selects a discovered port; it cannot supply an arbitrary listening service or designate an ordinary terminal as a Tool. Recheck the command run after asynchronous discovery and browser startup. + +| Policy | Selection | +| --- | --- | +| Announced port present | Match that exact port in the scan; absent match frames nothing | +| `port: announced`, no announced port | Frame nothing | +| `port: auto`, no announced port | Wait for one unchanged scan tick; one port frames, several show a conflict, zero keeps waiting | +| Anonymous command | Uses `auto` | + +- **Must poll unbound Tools every 1.5 seconds while their command runs.** Reset settle memory on command exit. (rationale) +- **Must let a changed announced port override a committed conflict or browser**, but only after a matching scan. An unchanged announcement never undoes URL-bar navigation. (rationale) +- **Must stop ordinary port scans once a browser or conflict is committed.** An unannounced additional port appearing after settle is not detected. +- **Must display the browser destination before awaiting agent-browser startup**, leaving the session-less renderer inert until the binding arrives. Close any browser session whose Tool disappeared or changed command during startup. +- **Must retain a runtime re-key within the Tool's namespace**, following [Identity and dedupe](#identity-and-dedupe). + +Reserved: **Must derive a Tool's URL again on cold restore**, compatible with future `prespawn_port` and `DORMOUSE_TOOL_PORT` in scope **dor-tools**; [Persistence and hosts](#persistence-and-hosts) owns the saved projection. + +Source of truth: `useToolServing` in `lib/src/components/wall/use-tool-serving.ts`; `attachAgentBrowserSession` in `lib/src/components/wall/tool-browser-session.ts`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`. Tests: `lib/src/components/wall/use-tool-serving.test.tsx`. + +## Lifecycle + +**Must create a shell-hosted PTY and type the command only after integration readiness.** An unsupported shell fails before launch; integration timeout or cancellation closes the temporary Surface through the notepad close coordinator, retaining it if closure fails. + +| Transition | Result | +| --- | --- | +| Spawn | Terminal visible; Tool identity already established | +| Serving | Browser becomes visible in the same Surface | +| Port conflict | Explanation occupies the browser half; terminal remains available | +| Command exit or different command | Browser resources retire and terminal becomes visible | +| Re-run stored command | Same Surface may serve again | +| Kill | Notes archive and helper guards settle before PTY/browser teardown | + +**Must show the full terminal before serving and after command exit.** A serving Tool shows its browser, and Terminal Context reveals the same primary terminal (`docs/specs/terminal-context.md` → Tool context). Keep the browser mounted behind context, and keep the hidden terminal sized with `visibility` and `inert`, never `display: none`. Pending approval mounts neither capability. + +Notepad follows `docs/specs/notepad.md` → Notepad UI. Tool context follows `docs/specs/terminal-context.md` → Tool context. + +Source of truth: `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`; `ToolPaneHeader` in `lib/src/components/wall/ToolPaneHeader.tsx`; `toolLeafMeta` / `shouldParkOnMinimize` in `lib/src/components/wall/lath-wall-engine.ts`; `closeSurface` in `lib/src/components/Wall.tsx`. Tests: `lib/src/components/wall/ToolPanel.test.tsx`, `lib/src/components/Wall.test.tsx`. + +## CLI + +**Must split focus-neutrally for a new Tool and return its Surface handle.** Calling-pane take-over is staged under [Future](#future). A matching Tool follows [Identity and dedupe](#identity-and-dedupe). + +**Must retain `dor tool` as a Surface-producing command on every supported host**, never route it to a native editor. Generated help owns syntax and response types own shape. + +Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshots/help/tool.md`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. + +## OSC 367 + +**Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` is the only implemented verb. The escape registry is `docs/specs/terminal-escapes.md`. + +- **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` and `parseToolAnnounce` own the field shapes and validation limits. +- **Must forward parsed announcements from the host to the owning renderer**, which records the latest announcement per Session. Standalone uses `terminal:protocolEvents`; VS Code uses `terminal:toolAnnounce` scoped to the owning webview. The fake adapter applies locally. +- **Must reconstruct announcements from raw replay without emitting replies**, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert. +- Reserved: **Must retain `name`, `dehydrate`, and `persist` as inert parsed fields**, serving the announced-name and D1/D2 items under [Future](#future). Neither `persist: never` nor a `dehydrate` verb changes current persistence. +- Reserved: **Never assign a third OSC 367 verb**; `dehydrate` belongs to D2 under [Future](#future), while existing title/progress protocols keep those roles. + +Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`. + +## Security + +The Tool-specific local boundaries are `docs/specs/security-local.md` → Dor Tool configuration. Browser content follows `docs/specs/security-local.md` → Browser panes. Serving authority follows [Serving](#serving); approval workflow follows [Trust](#trust). + +## Persistence and hosts + +**Must persist the command and stable Tool metadata with `surfaceType: 'tool'`**, retaining the ordinary CWD field. Never persist a derived URL, browser session binding, conflict, or pending approval as runnable Tool state. Live notes follow `docs/specs/notepad.md` → Live resume. + +**Must cold-restore an approved Tool by starting its saved command through integration-gated shell readiness**, then rediscover its port. Agent-resume commands do not override the saved Tool command. Pending approvals restore as ordinary terminals and execute nothing. **Must rebuild visible Tool metadata from its pane row when layout geometry is unusable**, rather than starting the command in a plain terminal with no serving behavior. + +**Must provide Tool host operations in standalone and VS Code.** Remote terminal transport remains protocol-v1; remote browser presentation is staged in `docs/specs/remote-api.md`. + +Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `saveSession` in `lib/src/lib/session-save.ts`; `restoreSession` in `lib/src/lib/session-restore.ts`; `restoreTerminal` in `lib/src/lib/terminal-lifecycle.ts`; `toolControl` in `lib/src/lib/platform/types.ts`. Tests: `lib/src/lib/session-save.test.ts`, `lib/src/lib/session-restore.test.ts`. ## Future -**Scope: dor-tools** — what remains, staged, one phase per PR; Phase A, the -capability refactor, is [done](#capability-gating). - -- **B — `dor open`.** Table + dispatch only: an entry resolves to a terminal - command (`ensure`/`split`) or an existing browser Surface on a host-served - viewer page (iframe proxy). No OSC, no atom, **nothing new persisted**, so C1 - needs zero snapshot migration. The VS Code route ([The table](#the-table)) is - complete here, permanently for v1. -- **C0 — OSC 367 + header chip.** Parse/strip/register/sanitize the `serve` - verb, plus the inert chip of [Security](#security) whose click reuses the - existing port-connect flow — the entire security gate at minimal UI cost, and - a usable chip before the atom exists. -- **C1 — the tool atom.** `dor tool`, announce-minted upgrade-in-place, - identity dedupe, the console toggle, `surfaceType: 'tool'`, kill/teardown - (forcing the per-surface teardown hook `docs/specs/dor-browser.md` stages), - args-only cold restore. Standalone runs it behind `dormouse.flags.tools`; - `dor open` re-plumbs onto the real path. +**Scope: dor-tools** — remaining design, in implementation order. + +- **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 (a stateless tool can just be killed). + no Windows question. - **D2 — dehydrate/rehydrate.** The `367;dehydrate` verb + - `DORMOUSE_DEHYDRATE`, designed day 1 — its flag is reserved in the `serve` - payload from C0. The Windows graceful-stop answer is needed here only. -- **Later** — `ab-*` rendering, pointing the shipped surface-handle addressing - (`docs/specs/dor-cli.md` → Agent-Browser Surface Addressing) at a `tool`'s - browser so an agent can GUI-drive it. 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 on the live gating, and it changes -none of glossary.md's gating rules. Browser verbs stay renderMode-gated (an -iframe-rendered tool cannot be agent-driven), `kill` / `rename` stay universal, -and kinds stay **disjoint** for `dor list --kind`. - -- **Identity**: a tool Surface's id is its `SessionId` (I1 extends to tools) - and survives every capability and render-mode change — the tool counterpart - of I10, stronger than browsers have today. -- **Render swaps bypass `replaceSurface`.** A tool's browser is a param of the - tool's own leaf, so `iframe` ⇄ `ab-*` mutates `renderMode` in place instead - of routing through the id-minting browser-surface replacement path (I10) — - which is what makes the identity rule above hold. -- **Axes**: the tool column of the six-axis table reads terminal-column - semantics for its terminal, browser-column for its browser. -- **Activity**: the terminal's full machine, but WATCHING defaults off for - tool-spawned commands (`lib/src/lib/watched-commands.ts` rules). -- **Untouched**: input to **either** capability touches, so the first - browser-side interaction arms kill-confirm while an idle just-opened viewer - still dies silently. - -### 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 OSC 9/99/777 rules of -`docs/specs/alert.md`. - -``` -ESC ] 367 ; serve ; {"port":4242,"name":"…","identity":"…","dehydrate":true,"persist":"respawn","v":1} ESC \ -ESC ] 367 ; dehydrate ; {"v":1, …} ESC \ -``` - -- `serve` — `port` (host derives `http://localhost:/`), optional `name` - (feeds title candidates, `docs/specs/terminal-state.md`; priority stays user - pin > announce name > command), optional `identity` (dedupe key, below), the - `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 and size-capped - ([Dehydrate and rehydrate](#dehydrate-and-rehydrate)). -- **No third verb, ever** — titles are OSC 0/2, progress is OSC 9;4, and the - escape registry is the rest of the API; a `progress` or `title` verb would - mean tools had grown a protocol. -- **Safe to emit unconditionally** — well-behaved terminals drop unknown OSCs, - so checking `DORMOUSE_SURFACE_ID` is an optimization, not a capability sniff. - An OSC, not a control-socket call, because the socket does not exist over - ssh; tmux needs `allow-passthrough` (one line of tool-author docs). -- 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 — prompt-wait -typing, per-shell quoting, command-exit tracking (`dor/src/commands/ensure.ts`, -`dor/src/commands/shell-quote.ts`). **Terminal front from spawn**: startup logs -beat a spinner, and a command that never announces is a terminal running a -TUI — a complete outcome, and exactly what a "TUI tool" table entry is. - -**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, and the flip animation reads as teaching the terminal-plus-browser -pairing. - -**Command exit** → the browser is retired and the pane flips back to the -terminal, leaving a shell prompt above the tool's dying words. Re-running -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 tool, not the host** — only the tool knows that -`README.md`, `./readme.md`, and a symlink are one document, or that a scratch -editor *becomes* its save-file. - -- **Scope**: dedupe matches *(tool name as the host knows it from the spawn)* × - *(identity string from the OSC)*, so a payload cannot claim to be a different - tool. Identityless tools are never deduped — scratch semantics. -- **On match**: graceful-stop the redundant new spawn, tear its pane down - through the existing untouched-kill path (no confirmation; untouched by - construction), reveal the survivor, report its handle with an `ensure`-style - reuse note. -- **Races**: concurrent spawns serialize at announce; first wins. -- **Containment**: a match only ever *reveals* a Surface, never transferring - state, grants, or input — worst case for a spoofed identity is a wrong pane - getting focus. -- **Blessed pattern**: announce-and-let-Dormouse-dedupe. Warn against VS - Code-style internal forwarding (second invocation hands off and exits); it - looks to Dormouse like a failed tool. + `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). 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 -**Reap a tool announcing `dehydrate: true` on an idle threshold while -`Doored` / `Hidden`** — Surfaces of an inactive Workspace included — **never on -the minimize itself** (reattach must not cost a boot every time) **or under -memory pressure**. The headline case is Workspaces, not shutdown: an inactive -Workspace 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`). - -**In-session mechanism.** The payload lives with the running host; survival -across a full 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 it. - -1. Host sends the graceful-stop signal (grace window). -2. Tool emits `367;dehydrate;{json}` on the way out; host captures it. -3. Rehydrate = respawn the command with `DORMOUSE_DEHYDRATE` in the env, - rendered per-shell. - -Degradation tiers, Lath-restore-token style: dehydrated state → bare args → -error. **Args-only restart is the mandatory floor; the payload is fidelity, -never correctness.** It stays -small versioned JSON, never a document (session blobs have bloated storage -before). **A hung tool blocks nothing**: 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**; no `--key`, because 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 - rides the 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 command and cwd are deterministic functions of - the target — reuse and cold restore stay caller-independent, and the tool's - own relative assets resolve. -- `dor list`: rows report `kind: tool` with the browser's `render_mode`; the - location column shows the **target**, else the announce name; 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**, so one source of truth serves GUI gestures (file drop) as well -as the CLI. Two sections: named tools (name → command template) and glob rules -(pattern → tool name). An entry may dispatch to a plain terminal command (`*.*` -→ a pager) — the atom is minted by the announcement, not by the table. - -**VS Code v1 routes `dor open` to the native editor** — an in-pane md/code -viewer competes with the editor, which native-first forbids — and reports which -route it took. An agent there loses sight of what it opened — accepted for v1, -and the eventual argument for the full pipeline. - -### Security - -**Honor auto-upgrade on announce 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 — it only lights an inert -pane-header chip (the Dev-Server Chip pattern of `docs/specs/dor-browser.md`, -declared instead of port-scanned), and the click is the connecting user -gesture. **Output alone never creates Surfaces.** - -**Accepted risk — content-driven announce inside a blessed tool.** A tool -rendering hostile bytes (a pager on a malicious file) *is* the foreground -process, so those bytes pass the gate and can announce an attacker-chosen -localhost port, re-pointing the browser under the tool's name at a service -already listening. Accepted: the blast radius is the dedupe containment applied -to ports — an announce only reveals/frames and transfers no input authority, -grants, or state; the iframe proxy dials upstream as a fresh client with no -browser cookie authority; the link-local/cloud-metadata SSRF guard stands -regardless. The residual is a mislabeled view of the user's own service, inert -without further gestures. Escalations if field reports change the calculus: -gesture-gate re-announces that change the port, or constrain the framed port to -the session's process tree — not the default, because it breaks tools wrapping -double-forking daemons (agent-browser-style) whose port a 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, -never a persisted param.** 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. +For tools announcing `dehydrate: true`. Reap on an idle threshold while +`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 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. + +**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..0dc54308c --- /dev/null +++ b/docs/specs/dor-tool.rationale.md @@ -0,0 +1,45 @@ +# Dor Tools — Rationale + +> Informative evidence for `docs/specs/dor-tool.md`, keyed by its headings. + +## Declaring tools + +YAML authors naturally collapse one-element lists to scalars. Overloading a scalar dedupe key as a command would make `prespawn_dedupe: storybook` execute instead of identify. Separate future fields avoid that ambiguity. + +A misspelled substitution such as `$PROJECTROOT` retained as a literal silently makes distinct checkouts share a key. Rejecting unknown substitutions exposes the typo before reuse can target another checkout. + +## Identity and dedupe + +`pnpm storybook`, `pnpm run storybook`, and `pnpm storybook --quiet` are different command strings for the same intended tool. `dor ensure` already supplies exact-command/CWD identity. An explicit Tool key allows authors to choose their own scope without making the declaration of a short command name implicitly enable dedupe. + +A key list makes scope visible: `$PROJECT_ROOT` distinguishes worktrees without string-concatenation conventions. A runtime collision differs from a redundant spawn: both Surfaces may already hold edited documents, so merging or killing either can destroy work. + +## Trust + +A prompt rendered as terminal output is forgeable, and `dor send` can type bytes identical to a user's. The dedicated chrome action prevents terminal/control-socket input from granting approval through the normal command path. It does not establish a boundary against arbitrary programs running as the same OS user. + +Upstream grants reduce repeated approval across clones and worktrees. They rely on the URL reported by Git, without authenticating the checkout's provenance. Folder grants provide narrower scope. A copied directory carrying `.git/config` can claim a previously trusted URL; cloning a chosen URL has a different provenance story. + +Remembering a denial would disable tools across worktrees without a corresponding grant-management UI. Closing the pending pane is recoverable on another explicit invocation. Content-hashing approval would prompt after routine edits or pulls, making acceptance habitual. + +## Serving + +The standalone browser harness binds more than one HTTP port. Choosing the lowest port or the first observed listener cannot identify which service the user intended to see. A conflict in the browser area gives that refusal a visible explanation while keeping the terminal accessible. + +Successive startup listeners can appear in different scan ticks. One unchanged tick catches changes within that window; it does not prove no later listener will appear. Remembering the last applied announced port keeps repeated announcements from undoing URL-bar navigation. + +A hardcoded Storybook port can disagree with the port it obtains under contention, while Vite with strict-port behavior can fail entirely. Discovery therefore checks the Session process tree. An OSC can cross SSH, but the current host scan still requires a locally discoverable listener. + +## Lifecycle + +The September 2026 integration reuses Terminal Context for the Tool's primary terminal. The auxiliary helper's automatic refresh, Reset, and Promote semantics do not describe a serving command, whose Session also owns the browser and remote terminal identity. Sharing the presentation avoids introducing a second navigation mechanism or a second shell. + +## Security + +Hostile text printed by the designated command can contain an announcement. The current process-tree check limits port selection to that Session's discovered listeners; browser content still executes under the existing renderer boundaries. Earlier text describing arbitrary local-port selection did not match the scan implementation. + +## Persistence and hosts + +A derived URL or browser daemon binding belongs to one execution. Reusing it after cold restore can connect a Tool to another process that obtained the old port. The saved command and declaration metadata are sufficient to start again and discover the new endpoint. + +Routing `dor tool` to a native editor on one host would change its result from a Surface handle to a host-specific side effect. Native file opening remains a separate operation. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 9f62aa7ad..da5714a52 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -4,9 +4,10 @@ ## The core idea -A **Surface** is the durable occupant of a Pane — the content in a slot. Two kinds: +A **Surface** is the durable occupant of a Pane — the content in a slot. Three kinds: - a **terminal Surface**, which Dormouse calls a **Session**: a PTY-backed shell with scrollback and semantic terminal state. The six-axis model below describes this kind. +- a **tool Surface**: a Session with terminal and browser capabilities (`docs/specs/dor-tool.md`). - a **browser Surface**: a web view (`docs/specs/dor-browser.md`), taking only a subset of the axes ([Panes and Surfaces](#panes-and-surfaces)). **Unless a passage says "Surface" or "browser Surface," it describes a Session.** A Session's state lives on six distinct axes; an operation can change several together. Their separate preconditions define the **[Liskov contract](#liskov-contract)**. @@ -22,18 +23,20 @@ A Pane holds exactly one Surface today, but the model reserves several (a future | Kind | Sub-kinds | Backed by | |---|---|---| | `terminal` | — | a PTY + xterm.js instance — a **Session** | +| `tool` | — | a PTY and an optional browser on the same Session | | `browser` | `iframe`, `ab-screencast`, `ab-popout` | an iframe proxy grant, or an agent-browser daemon session (`docs/specs/dor-browser.md`) | **For a browser Surface `renderMode` is canonical**; the CLI `render_mode` is derived from it and never stored. | Surface | Persisted `surfaceType` (`docs/specs/transport.md`) | `renderMode` (`docs/specs/dor-browser.md`) | CLI `kind` | CLI `render_mode` | |---|---|---|---|---| +| tool Session | `'tool'` | `iframe` or `ab-screencast` when serving | `tool` | renderer or `null` | | terminal Session | `'terminal'` (default, omitted) | — | `terminal` | `null` | | browser · iframe | `'browser'` | `iframe` | `browser` | `iframe` | | browser · screencast | `'browser'` | `ab-screencast` | `browser` | `ab-screencast` | | browser · popped out | `'browser'` | `ab-popout` | `browser` | `ab-popout` | -**Kinds are capability sets, not exclusive categories** — the two above carry one capability each, the staged `tool` (`docs/specs/dor-tool.md`) both. **Operations gate on the capability they need, never on the kind enum** ([Liskov contract](#liskov-contract)): `read` / `send` / `await` / port scans need the terminal, nav / render-mode / agent-browser verbs the browser. **`dor list --json` rows always emit `has_terminal` and `has_browser`** (rationale). **Must declare each kind's capabilities in the `hasTerminal` / `hasBrowser` table.** Persistence keeps its own `PersistedSurfaceType` discriminant (`docs/specs/transport.md`). +**Kinds are capability sets, not exclusive categories** — terminal and browser carry one capability each, `tool` both. **Operations gate on the capability they need, never on the kind enum** ([Liskov contract](#liskov-contract)): `read` / `send` / `await` / port scans need the terminal, nav / render-mode / agent-browser verbs the browser. **`dor list --json` rows always emit `has_terminal` and `has_browser`** (rationale). **Must declare each kind's capabilities in the `hasTerminal` / `hasBrowser` table.** Persistence keeps its own `PersistedSurfaceType` discriminant (`docs/specs/transport.md`). Source of truth: `hasTerminal` / `hasBrowser` in `dor/src/commands/types.ts`; `surfaceKindFromParams` in `lib/src/components/wall/browser-surface.ts`. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2f6589961..78f42b98b 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -48,13 +48,15 @@ Panes are separated by a 7px gap (`PANE_GUTTER_PX`), odd so the 1px selection ri A 30px header doubling as a drag handle: **a `pointerdown` past a 5px threshold begins a Lath pane drag**; below the threshold the header's own click behavior stands. It uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, the shared terminal top radius from `lib/src/components/design.tsx`, and the `--color-header-active-*` / `--color-header-inactive-*` token pairs (VSCode file-tree list colors). +**Must use browser chrome for a serving Tool, with a Terminal Context disclosure for its serving terminal.** Tool composition belongs to `docs/specs/dor-tool.md` → Lifecycle. + Elements left to right: derived label; alert bell; TODO pill (compact+); flexible gap; mouse-reporting override icon (compact+, only while the inside program requests mouse reporting); notepad icon (`docs/specs/notepad.md` → "Notepad UI"); split left/right, split top/bottom, zoom/unzoom (full only); minimize; kill (hover turns error-red). The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal-state.md` owns the priority chain and disambiguator. Layout renders it: primary truncates with ellipsis, secondary muted beside it, a failed last command appends an error-colored glyph. Click renames/pins; right-click — or `>` in command mode — opens the header context menu. #### Header context menu -**Must open the terminal context from terminal header, alert, body, and command-mode `>` entry points.** Browser-only Surfaces and Doors have no context. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. +**Must open the terminal context from terminal header, alert, body, and command-mode `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. **Must float the context inside its source Pane with a one-rem inset on every side**, overlapping the header, with a theme-derived edge and raised shadow. Render it in the Lath leaf's overlay slot, outside the body's clipping box, so it follows the leaf's layout without remounting the helper. Keep one context per Wall. Outside pointer press and explicit close dismiss it. No separate context heading or clipboard toolbar is shown. @@ -198,7 +200,7 @@ The source cwd is read from `getTerminalPaneState(sourceId).cwd`. **Never inheri **Every kill routes through the notepad close coordinator**, confirmed and untouched-fast-path alike, which archives the Surface's notes before teardown and can refuse the close (`docs/specs/notepad.md` → "Closure"; that spec also names who may still tear a Surface down immediately). -**Untouched sessions skip this confirmation.** A newly spawned shell starts `untouched: true`; the first user-originated PTY input flips it to false. Counted: printable keys, Enter, control keys, keyboard CSI such as arrows/history, paste, file-drop path insertion. Not counted: replay-shaped terminal reports and stripped mouse-report-only input — **the gate checks `inputIsReplayTerminalReport`**, the broader synthetic-report check gating 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 that removal path, then kills it with no overlay. +**Untouched plain terminal sessions skip this confirmation; Tools still require it.** A newly spawned shell starts `untouched: true`; the first user-originated PTY input flips it to false. Counted: printable keys, Enter, control keys, keyboard CSI such as arrows/history, paste, file-drop path insertion. Not counted: replay-shaped terminal reports and stripped mouse-report-only input — **the gate checks `inputIsReplayTerminalReport`**, the broader synthetic-report check gating 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 that removal path, then kills it with no overlay. Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fast path, or staging the overlay) and `acceptKill` in `lib/src/components/Wall.tsx`, `lib/src/components/KillConfirm.tsx`. @@ -310,7 +312,7 @@ Source of truth: `lib/src/components/wall/IllegalRenameWarning.tsx`, `lib/src/co | **Swap** | `Cmd/Ctrl+Arrow` trades two leaf identities via a Lath `swap`; registry entries follow the ids ([Spatial navigation](#spatial-navigation)). | - **Untouched**: new `getOrCreateTerminal` sessions start untouched; `isUntouched(id)` exposes the flag, user-originated PTY input clears it, and resume/restore seed the persisted one. **Missing legacy snapshot data defaults to touched (`false`)**, keeping close confirmation 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.** An untouched selected pane or door has the new terminal take over its leaf via a Lath `replace` op (an atomic identity swap; doors reattach through the normal restore path first), the old session disposed and its ref retired; a touched selection, or none, spawns a new pane beside it. Announced spawns show a transient pane-anchored notice (`Switched to zsh`, `Opened bash`). **A replacement migrates the Surface's notepad to the new id rather than archiving it** (`docs/specs/notepad.md` → "Closure"). +- **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.** An untouched selected plain terminal pane or door has the new terminal take over its leaf via a Lath `replace` op (an atomic identity swap; doors reattach through the normal restore path first), the old session disposed and its ref retired; a touched selection, or none, spawns a new pane beside it. Announced spawns show a transient pane-anchored notice (`Switched to zsh`, `Opened bash`). **A replacement migrates the Surface's notepad to the new id rather than archiving it** (`docs/specs/notepad.md` → "Closure"). - **Replay-time terminal reports must be dropped; user input must not be** — during **resume** replay the registry drops the replies xterm.js emits to queries embedded in buffered output, before they reach the retained PTY (`docs/specs/terminal-escapes.md` → "Report filtering on the input side"). Source of truth: `lib/src/lib/terminal-store.ts` (registry maps and pending shell opts, imported directly, including by `lib/src/remote/burrow/`), `lib/src/lib/terminal-lifecycle.ts` (the ops), `lib/src/lib/terminal-registry.ts` (the facade). diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 1e296070d..c3223ab35 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -8,6 +8,8 @@ Owns terminal selection, copy, paste, mouse override, and their chrome across platforms. Header placement: `docs/specs/layout.md`; sequence registry: `docs/specs/terminal-escapes.md`. +For tools, these rules apply while the terminal is forward; the browser or conflict view owns the keys otherwise. + ## Background: The Two Mouse Regimes Mouse events belong to one of two consumers: diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index 7dd440630..661001ef1 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -73,6 +73,8 @@ Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealRe ## Notepad UI +**Must retain one notepad per Tool Surface.** Context changes its presentation only (`docs/specs/terminal-context.md` → Tool context); following a source pin reveals that same terminal. + **The header notepad icon sits after the mouse-override icon and before the split controls** (`docs/specs/layout.md` → "Pane header"), filled while the Surface has notes and regular otherwise. **At the minimal tier an empty notepad yields its space to the title; one with notes stays**, so notes are never invisible. - **The attached notepad is a panel in the top-right of the Surface body, three quarters of it wide and tall.** It closes on its close control, Escape, or an outside click. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 58407e0bd..af379d0d9 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -185,3 +185,15 @@ Source of truth: `SESSION_STATE_KEY` in `vscode-ext/src/session-state.ts`, **Must validate context directory arguments as existing absolute directories and pass the canonical path as one process argument without shell interpretation.** Keep this capability separate from the external-URL allowlist. VS Code per-terminal context requests and helper ownership updates remain scoped to the owning router. Source of truth: `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. Test: `standalone/sidecar/helper-terminal.test.js`. + +## Dor Tool configuration + +**Must keep repo-local named Tools inert until the user grants trust through Dormouse chrome.** The control socket exposes lookup and launch, never a trust-grant verb. Pending approval spawns neither its terminal nor a helper. Approval workflow belongs to `docs/specs/dor-tool.md` → Trust. + +**Must derive the grant key in the host**, using the canonical upstream URL or project-root folder; a renderer request cannot supply an arbitrary grant URL. **Must bound config reads and refuse symlinks on every host.** + +**An upstream grant trusts the claimed URL, not authenticated checkout provenance.** A supplied directory containing its own `.git/config` can claim an already-granted upstream; folder-only grants limit this sharing. **Must not describe the chrome gesture as a boundary against other processes running as the user**; the local account model is The dor control socket above. + +**Must restrict announced ports to the designated command's Session process tree** (`docs/specs/dor-tool.md` → Serving). Process output may select among that tree's discovered ports; it cannot turn an ordinary terminal into a Tool. + +Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `lookupTool` / `FileToolTrustStore` in `lib/src/host/tool-trust.ts`; `useToolServing` in `lib/src/components/wall/use-tool-serving.ts`. Tests: `lib/src/host/tool-host.test.ts`, `lib/src/host/tool-trust.test.ts`, `lib/src/components/wall/use-tool-serving.test.tsx`. diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index b820bc667..5c603232e 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -95,3 +95,5 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n ## Future Workspace switch / create / close / rename shortcuts (command mode) are staged with the workspaces rollout ([layout.md](layout.md#future), **Scope: workspaces-rollout**), following the tmux *window* bindings the rest of the keymap mirrors; listed here once bound. + +Tool context and its terminal keyboard routing follow `docs/specs/terminal-context.md` → Tool context. diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index a39d6393a..7267b043e 100644 --- a/docs/specs/terminal-context.md +++ b/docs/specs/terminal-context.md @@ -63,6 +63,18 @@ Source of truth: `context` in `standalone/sidecar/pty-core.js`; `terminalContext Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. +## Tool context + +**Must show a Tool's primary Session in Terminal Context instead of creating an auxiliary helper.** Reuse the title, directory, port, alert, and notepad presentation, showing Tool command status without helper Modify, Reset, or Promote controls. Pending approval cannot open context. + +**Must mount and refit Tool context before resolving a note source pin**, so its scroll position and selection use the displayed grid. + +**Must focus the Tool terminal instance directly**, bypassing its browser Surface focus handle. + +**Must mount only one terminal view for the Tool at a time**, moving its retained xterm between the full pane and context without disposing the Session. Context keystrokes, including terminal clipboard chords, belong to that terminal and never reach the browser underneath. Closing context preserves both processes and browser state. + +Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`. Tests: `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`. + ## Future Pocket context composition, remote helper creation, and SSH integration are unbuilt. diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 83cc13a63..ef3664df5 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -61,6 +61,8 @@ Replay (`pty:replay`) is the one raw stream and the one legitimate re-parse: **t | `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 a reserved name and runtime 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 ; File=...:<data> ST` / `MultipartFile=...` / `FilePart=...` / `FileEnd` | iTerm2 inline image protocol (IIP); passed through to ImageAddon. | [Inline graphics](#inline-graphics) | | `OSC 1337 ; ReportCellSize ST` | iTerm2 cell-size query; passed through and answered by the owner's ImageAddon. | [Inline graphics](#inline-graphics) | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index bb00c2b30..e6e8a8553 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -260,3 +260,9 @@ Source of truth: `getScrollbackReceived` / `getScrollbackSince` in `vscode-ext/s **Must acknowledge Windows directory opening when Explorer starts**, reporting process-launch errors without waiting for its exit. macOS and Linux retain opener exit-error reporting. Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types.ts`; `PtyInfo` in `lib/src/lib/platform/types.ts`; `resumeOrRestore` in `lib/src/lib/reconnect.ts`; `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. + +## Tool transport + +**Must forward parsed OSC 367 announcements from the PTY owner to its renderer**, without reparsing live display bytes or answering from a viewer (`docs/specs/dor-tool.md` → OSC 367). Tool persistence follows `docs/specs/dor-tool.md` → Persistence and hosts. + +Source of truth: `ExtensionMessage` in `vscode-ext/src/message-types.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. 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 5e3b1cf33..746e20e19 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 91e1ef771..87b0ba540 100644 --- a/lib/package.json +++ b/lib/package.json @@ -36,6 +36,7 @@ "remote-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", + "yaml": "^2.9.0", "uqr": "^0.1.3" }, "devDependencies": { diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index e0908d64c..4f041ffdd 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -19,8 +19,10 @@ 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'; import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; -import { addPlainNote, beginClosing, clearAllNotepads, getNotes } from '../lib/notepad/notepad-store'; +import { addPlainNote, beginClosing, clearAllNotepads, getNotes, setOpenNotepadId } from '../lib/notepad/notepad-store'; import type { NotepadArchiveV1 } from '../lib/notepad/types'; import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; @@ -58,7 +60,8 @@ beforeEach(() => { disconnect() {} } as unknown as typeof ResizeObserver; // Reduced motion so the Lath engine runs a 0 duration: the two-phase kill's - // deferred removal fires on a setTimeout(0) and completes within `flush()` — the + // deferred removal fires on a setTimeout(0); archive decisions may queue it + // after a flush has started, so those tests wait for the removal itself. The // instant path is also stage 3's "reduced motion" acceptance requirement. globalThis.matchMedia = ((query: string) => ({ matches: query.includes('prefers-reduced-motion'), @@ -1135,6 +1138,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 () => { @@ -1331,6 +1420,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 @@ -1946,7 +2432,10 @@ describe('Wall on the Lath engine', () => { await flush(); expect(archiveFailureModal()).toBeNull(); - expect(container.querySelector('[data-lath-leaf="pane-a"]')).toBeNull(); + await vi.waitFor(async () => { + await flush(); + expect(container.querySelector('[data-lath-leaf="pane-a"]')).toBeNull(); + }); expect(getNotes('pane-a')).toEqual([]); expect((await storedArchive()).batches).toEqual([]); }); @@ -1982,7 +2471,10 @@ describe('Wall on the Lath engine', () => { expect(archiveFailureModal()).toBeNull(); expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull(); expect(getNotes('pane-a')).toHaveLength(1); - expect(container.querySelector('[data-lath-leaf="pane-b"]')).toBeNull(); + await vi.waitFor(async () => { + await flush(); + expect(container.querySelector('[data-lath-leaf="pane-b"]')).toBeNull(); + }); }); it('migrates a notepad to the new id when a replacement mints one', async () => { @@ -2052,3 +2544,42 @@ describe('Wall on the Lath engine', () => { } }); }); + + +it('shares one primary terminal and notepad between a Tool pane and Terminal Context', async () => { + const openHelper = vi.spyOn(helpers, 'openHelper'); + const params = { surfaceType: 'tool', command: 'pnpm storybook', toolRender: 'iframe', toolPort: 'announced' }; + await act(async () => root.render(<Wall restoredLathLayout={{ version: 1, tree: { root: { kind: 'leaf', id: 'tool-context' } }, leafMeta: { + 'tool-context': { component: 'tool', tabComponent: 'tool', title: 'Storybook', params }, + } }} initialMode="command" showBaseboard />)); + await flush(); + act(() => { addPlainNote('tool-context', 'Keep this note'); setOpenNotepadId('tool-context'); }); + expect(container.querySelectorAll('[data-notepad-panel-for="tool-context"]')).toHaveLength(1); + act(() => setOpenNotepadId(null)); + act(() => container.querySelector('[data-lath-leaf="tool-context"] .lath-leaf-header')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))); + // The header's terminal label is the context entry point. + if (!container.querySelector('[data-terminal-context]')) { + act(() => container.querySelector('[data-session-id="tool-context"]')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))); + } + await flush(); + expect(openHelper).not.toHaveBeenCalled(); + expect(container.querySelector('[data-context-terminal="tool-context"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-session-id="tool-context"]')).toHaveLength(1); + act(() => setOpenNotepadId('tool-context')); + expect(container.querySelectorAll('[data-notepad-panel-for="tool-context"]')).toHaveLength(1); + act(() => setOpenNotepadId(null)); + act(() => container.querySelector<HTMLButtonElement>('[aria-label="Close terminal context"]')!.click()); + await flush(); + expect(container.querySelectorAll('[data-session-id="tool-context"]')).toHaveLength(1); + expect(getNotes('tool-context').map(note => note.content)).toEqual([{ kind: 'plain', text: 'Keep this note' }]); + const refit = vi.spyOn(terminalRegistry, 'refitSession').mockImplementation(id => { + expect(id).toBe('tool-context'); + expect(container.querySelector('[data-context-terminal="tool-context"] [data-session-id="tool-context"]')).not.toBeNull(); + }); + act(() => { + window.dispatchEvent(new CustomEvent('dormouse:reveal-note-source', { detail: { surfaceId: 'tool-context' } })); + // Pin resolution follows synchronously, before the React event returns. + expect(refit).toHaveBeenCalledOnce(); + }); + +}); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index ce29bf16d..cc1ec3c90 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -6,6 +6,7 @@ import type { PortUrlEntry } from './wall/port-url'; import { beginPromotion, cancelPromotion, closeHelperParent, finishPromotion, getHelper, helperHasWork } from '../lib/helper-terminal'; import { isHelperSession } from '../lib/terminal-store'; import { useRef, useState, useEffect, useCallback, useMemo, useSyncExternalStore, lazy, Suspense, type ReactNode } from 'react'; +import { flushSync } from 'react-dom'; import { clsx } from 'clsx'; import { Baseboard } from './Baseboard'; import { ExternalLinkModalHost } from './ExternalLinkModalHost'; @@ -35,6 +36,7 @@ import { disposeSession, dismissOrToggleAlert, focusSession, + refitSession, markSessionAttention, toggleSessionTodo, setPendingShellOpts, @@ -72,6 +74,7 @@ import { browserDisplayModeFromParams, browserUrlFromParams, surfaceKindFromParams, + isToolParams, namespacedToolKey, toolPendingFromParams, } from './wall/browser-surface'; import { hostPathDisplay } from './wall/browser-url'; import { WorkspaceSelectionOverlay } from './wall/WorkspaceSelectionOverlay'; @@ -85,6 +88,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'; @@ -405,6 +410,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); @@ -709,13 +718,13 @@ export function Wall({ const stage = () => { const door = doorsRef.current.find(item => item.id === id); if (door) { - handleReattachRef.current(door, { enterPassthrough: false, afterRestore: isUntouched(id) ? 'close' : 'confirm-kill' }); + handleReattachRef.current(door, { enterPassthrough: false, afterRestore: isUntouched(id) && !isToolParams(lath.getMeta(id)?.params) ? 'close' : 'confirm-kill' }); return; } // The helper inspection below can outlive the Surface (an exit, a `dor // kill`); a confirm overlay for a gone pane would never clear itself. if (!nav.hasPane(id) || lath.isDying(id)) return; - if (isUntouched(id)) { void closeSurface(id); return; } + if (isUntouched(id) && !isToolParams(lath.getMeta(id)?.params)) { void closeSurface(id); return; } setConfirmKill({ id, char: randomKillChar() }); }; if (!getHelper(id)) { stage(); return; } @@ -1114,6 +1123,8 @@ export function Wall({ cwd, requireIntegration, focusNeutral, + leafMeta, + deferTerminal, }: { command?: string; direction: DorResolvedSplitDirection; @@ -1121,11 +1132,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; @@ -1147,7 +1166,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 @@ -1158,7 +1180,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 } : {}), }); @@ -1182,11 +1207,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', @@ -1201,7 +1226,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', @@ -1209,7 +1234,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 } }; @@ -1342,7 +1367,7 @@ export function Wall({ const shouldReplaceUntouched = detail.replaceUntouched === true && selectedPaneVisible && - isReplaceableShell(selectedPaneId!); + !isToolParams(lath.getMeta(selectedPaneId!)?.params) && isReplaceableShell(selectedPaneId!); const shellName = detail.name?.trim() || 'terminal'; if (shouldReplaceUntouched) { @@ -1359,7 +1384,7 @@ export function Wall({ return; } - if (detail.replaceUntouched === true && selectedDoor && isReplaceableShell(selectedDoor.id)) { + if (detail.replaceUntouched === true && selectedDoor && !isToolParams(lath.getMeta(selectedDoor.id)?.params) && isReplaceableShell(selectedDoor.id)) { handleReattachRef.current(selectedDoor, { enterPassthrough: false, afterRestore: { @@ -1389,6 +1414,77 @@ 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. + await closeSurface(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') { + await closeSurface(id); + return; + } + + if (!lath.getMeta(id) || lath.isDying(id) || isSurfaceClosing(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, closeSurface, minimizePane]); + + // A tool grows its browser when its command starts serving. + useToolServing({ lath, doorsRef }); + const { findSurfaceByParams, updateSurfaceParams } = useDorControl({ lath, nav, @@ -1400,6 +1496,7 @@ export function Wall({ createContentSurface, isClosingSurface, closeSurface, + revealSurface, lastAgentBrowserBinaryPathRef, }); @@ -1431,6 +1528,20 @@ export function Wall({ onEventRef.current?.({ type: 'split', direction: splitDirection, source }); }, [enterTerminalMode, generatePaneId, surfaceRefForId, lath, nav]); + useEffect(() => { + const reveal = (event: Event) => { + const { surfaceId } = (event as CustomEvent<{ surfaceId: string }>).detail; + const meta = lath.getMeta(surfaceId); + if (!meta || !isToolParams(meta.params)) return; + // Pin resolution runs synchronously after this event. Commit the context + // mount and fit first, so its markers and selection use the visible grid. + flushSync(() => setTerminalContext({ id: surfaceId })); + refitSession(surfaceId); + }; + window.addEventListener('dormouse:reveal-note-source', reveal); + return () => window.removeEventListener('dormouse:reveal-note-source', reveal); + }, [lath]); + // --- Wall actions (for tab buttons) --- const wallActions: WallActions = useMemo(() => ({ @@ -1516,6 +1627,37 @@ export function Wall({ const params = nav.paneParams(id); const currentRenderMode = surfaceRenderModeFromParams(params); + // Tools keep their Session and current URL through renderer swaps. + if (isToolParams(params)) { + if (mode === currentRenderMode || mode === 'ab-popout') return; + const url = browserUrlFromParams(params); + const platform = getPlatform(); + if (!url || (mode === 'ab-screencast' && !platform.agentBrowserOpen)) return; + closeAgentBrowserSession(params); + disposeAgentBrowserSurfaceController(id); + lath.store.updateParams(id, { + toolRender: mode, renderMode: mode, url, + session: undefined, wsPort: undefined, syncEngaged: mode === 'ab-screencast', + }); + if (mode === 'ab-screencast') { + const runId = getTerminalPaneState(id).currentCommand?.id; + void platform.agentBrowserOpen!(url, {}, lastAgentBrowserBinaryPathRef.current).then(result => { + const current = lath.getMeta(id)?.params; + if (!current || lath.isDying(id) || current.renderMode !== mode || current.url !== url || getTerminalPaneState(id).currentCommand?.id !== runId) { + if (result.session) closeAgentBrowserSession({ renderMode: mode, session: result.session, binaryPath: result.binaryPath }); + return; + } + if (result.ok && result.session) { + lath.store.updateParams(id, { session: result.session, wsPort: result.wsPort, binaryPath: result.binaryPath }); + } else lath.store.updateParams(id, { toolRender: 'iframe', renderMode: 'iframe', syncEngaged: false }); + }).catch(() => { + const current = lath.getMeta(id)?.params; + if (current?.renderMode === mode && current.url === url && !current.session) lath.store.updateParams(id, { toolRender: 'iframe', renderMode: 'iframe', syncEngaged: false }); + }); + } + 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') { @@ -1616,7 +1758,12 @@ export function Wall({ }); }, resolveSurfaceRef: surfaceRefForId, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, 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); + }, + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, resolveToolApproval, lath, nav]); const contextPortLaunches = useRef(new Map<string, Promise<void>>()); const openContextPort = useCallback(async (id: string, entry: PortUrlEntry, mode: PortMode): Promise<void> => { const platform = getPlatform(); @@ -1659,7 +1806,7 @@ export function Wall({ const contextActions = useMemo(() => ({ id: terminalContext && !terminalContext.closing ? terminalContext.id : null, mounted: terminalContext, - open: (id: string, options?: TerminalContextOpenOptions) => { if (isHelperSession(id) || isSurfaceClosing(id) || lath.isDying(id)) return; setTerminalContext({ id, ...options }); }, + open: (id: string, options?: TerminalContextOpenOptions) => { if (toolPendingFromParams(lath.getMeta(id)?.params) || isHelperSession(id) || isSurfaceClosing(id) || lath.isDying(id)) return; setTerminalContext({ id, ...options }); }, close: () => { const instant = motionIsInstant(); setTerminalContext(current => { diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 2277544c4..c280e0d2c 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -80,6 +80,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 3267d5ec9..9e38667e5 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); }; @@ -283,12 +287,15 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // A screen modal (or any dialog) renders outside the pane element, so the // contains() check above misses it; without this, typing into the modal's // Custom W/H/DPI fields would be swallowed and forwarded to the browser. - if (e.target instanceof Element && e.target.closest('[role="dialog"]')) return; + if (e.target instanceof Element && e.target.closest('[role="dialog"], [data-terminal-context]')) return; // Likewise never hijack keystrokes destined for an editable field that // 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). @@ -334,6 +341,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/BrowserPanel.tsx b/lib/src/components/wall/BrowserPanel.tsx index 103bd1213..353dd8529 100644 --- a/lib/src/components/wall/BrowserPanel.tsx +++ b/lib/src/components/wall/BrowserPanel.tsx @@ -30,7 +30,7 @@ export type BrowserPanelParams = { syncEngaged?: boolean; }; -export function BrowserPanel(props: PaneProps) { +export function BrowserPanel(props: PaneProps & { renderNotepad?: boolean }) { const renderMode = resolveRenderMode(props.params); // The wrapper is the notepad panel's containing block, and the one thing both // renderers share; each child still fills it and owns its own chrome. @@ -39,7 +39,7 @@ export function BrowserPanel(props: PaneProps) { {renderMode === 'iframe' ? <IframePanel {...props} /> : <AgentBrowserPanel {...props} renderMode={renderMode} />} - <NotepadPanel surfaceId={props.id} /> + {props.renderNotepad !== false && <NotepadPanel surfaceId={props.id} />} </div> ); } diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index 8ac9e74af..5c57ae915 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'; @@ -174,6 +175,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'>; @@ -206,3 +232,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 08edec405..fb048bb30 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 { browserSurfaceUrl, hostPathDisplay } from './browser-url'; // Sandbox every framed page, proxied or raw, so a tool's @@ -94,6 +95,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"). @@ -233,12 +235,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(() => { @@ -266,6 +271,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; } @@ -287,7 +293,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 @@ -297,12 +303,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. @@ -341,7 +348,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 @@ -396,7 +406,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 306033856..d648836c0 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -1,3 +1,4 @@ +import { isToolParams } from './browser-surface'; // Lath's HTML adapter. Leaves use stable id-sorted DOM order and geometric // positioning only; gestures surface as proposals and never activate/focus. // See docs/specs/tiling-engine.md → "The HTML adapter (LathHost)". @@ -28,6 +29,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'; @@ -95,21 +98,25 @@ 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). * The terminal context floats over the whole leaf, so it lives here rather than * in the body, whose clipping box it must escape. */ -function TerminalLeafOverlay({ id, title }: PaneProps) { +function TerminalLeafOverlay({ id, title, params }: PaneProps) { const { mounted } = useContext(TerminalContextContext); return ( <> <AlertSpeechIndicator sessionId={id} /> - {mounted?.id === id && <TerminalContext {...mounted} title={title} />} + {mounted?.id === id && <TerminalContext {...mounted} title={title} tool={isToolParams(params)} />} </> ); } @@ -120,6 +127,8 @@ function TerminalLeafOverlay({ id, title }: PaneProps) { // 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/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 38595defe..fd7c3cc5b 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -6,6 +6,7 @@ import { TerminalContextView, type TerminalContextViewProps } from './TerminalCo import { TerminalPaneHeader } from './TerminalPaneHeader'; import { TerminalPanel } from './TerminalPanel'; import { TerminalContext } from './TerminalContext'; +import * as terminalRegistry from '../../lib/terminal-registry'; import * as helpers from '../../lib/helper-terminal'; import { addPlainNote, clearAllNotepads, getNotes, getOpenNotepadId } from '../../lib/notepad/notepad-store'; import { TerminalContextContext } from './wall-context'; @@ -190,3 +191,23 @@ it('opens the parent notepad from the Helper control and keeps edits on that par act(() => clearAllNotepads()); } }); + + +it('uses the Tool primary terminal without creating a helper or offering helper lifecycle actions', async () => { + const openHelper = vi.spyOn(helpers, 'openHelper'); + const focusTerminal = vi.fn(); + const terminal = vi.spyOn(terminalRegistry, 'getTerminalInstance').mockReturnValue({ focus: focusTerminal } as unknown as ReturnType<typeof terminalRegistry.getTerminalInstance>); + const focusSurface = vi.spyOn(terminalRegistry, 'focusSession'); + await act(async () => { root.render(<TerminalContext id="tool-source" title="Storybook" tool />); }); + expect(openHelper).not.toHaveBeenCalled(); + expect(container.querySelector('[data-context-terminal="tool-source"]')).not.toBeNull(); + expect(container.querySelector('[data-helper-terminal]')).toBeNull(); + expect(container.querySelector('[aria-label="Tool terminal status"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Modify autorun command"]')).toBeNull(); + expect(container.querySelector('[aria-label="Reset helper terminal"]')).toBeNull(); + expect(container.querySelector('[aria-label="Move this terminal into a new pane"]')).toBeNull(); + act(() => container.querySelector('[data-context-terminal]')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))); + expect(focusTerminal).toHaveBeenCalledOnce(); + expect(focusSurface).not.toHaveBeenCalled(); + openHelper.mockRestore(); terminal.mockRestore(); focusSurface.mockRestore(); +}); diff --git a/lib/src/components/wall/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index 88b5b54a8..a3be289e1 100644 --- a/lib/src/components/wall/TerminalContext.tsx +++ b/lib/src/components/wall/TerminalContext.tsx @@ -9,12 +9,12 @@ import { TerminalContextContext, WallActionsContext, type TerminalContextState } import { disposeHelper, getHelper, helperRevision, openHelper, setHelperVisible, subscribeHelpers } from '../../lib/helper-terminal'; import { getPlatform, IS_MAC, IS_WINDOWS } from '../../lib/platform'; import { buildAppTitleResolver, commandArgv0, createTerminalPaneState, cwdDisplay, deriveSurfaceLabel, explainTerminalTitle, type CwdState } from '../../lib/terminal-state'; -import { focusSession, getActivitySnapshot, getTerminalPaneStateSnapshot, isCommandWatched, setCommandWatched, subscribeToActivity, subscribeToTerminalPaneState, subscribeToWatchedCommands, getWatchedCommandsSnapshot, toggleSessionTodo } from '../../lib/terminal-registry'; +import { focusSession, getTerminalInstance, getActivitySnapshot, getTerminalPaneStateSnapshot, isCommandWatched, setCommandWatched, subscribeToActivity, subscribeToTerminalPaneState, subscribeToWatchedCommands, getWatchedCommandsSnapshot, toggleSessionTodo } from '../../lib/terminal-registry'; import { writeTextToClipboard } from '../../lib/clipboard'; import { listenerUrlsByPort } from './port-url'; import { DEFAULT_HELPER_COMMAND } from '../../lib/terminal-context-types'; -export function TerminalContext({ id, title, closing, origin, warning: openWarning }: TerminalContextState & { title?: string }) { +export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false }: TerminalContextState & { title?: string; tool?: boolean }) { const context = useContext(TerminalContextContext); const actions = useContext(WallActionsContext); const states = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); @@ -26,7 +26,7 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni const [defaultCommand, setDefaultCommand] = useState(DEFAULT_HELPER_COMMAND); const [helperError, setHelperError] = useState(''); const platform = getPlatform(); - const helper = getHelper(id); + const helper = tool ? undefined : getHelper(id); const state = states.get(id) ?? createTerminalPaneState(); const cwd = state.cwd?.path ? state.cwd : undefined; const helperState = helper ? states.get(helper.id) : undefined; @@ -37,25 +37,25 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni const display = (location: CwdState) => cwdDisplay(location, { style: 'full', homePath: home }); useEffect(() => { let cancelled = false; - void openHelper(id).catch(e => { if (!cancelled) setHelperError(messageOf(e)); }); + if (!tool) void openHelper(id).catch(e => { if (!cancelled) setHelperError(messageOf(e)); }); void platform.terminalContext?.({ op: 'settings' }).then(info => { if (!cancelled) { setHome(info.home ?? ''); setDefaultCommand(info.command ?? DEFAULT_HELPER_COMMAND); } }).catch(() => {}); void platform.getOpenPorts(id).then(ports => { if (!cancelled) setScan({ status: 'loaded', entries: listenerUrlsByPort(ports) }); }, () => { if (!cancelled) setScan({ status: 'failed' }); }); return () => { cancelled = true; }; - }, [id, platform]); + }, [id, platform, tool]); // The helper polls only while the context is open; an exit pauses it at once. useEffect(() => { - if (closing) return; + if (closing || tool) return; setHelperVisible(id, true); return () => setHelperVisible(id, false); - }, [id, closing]); - const onClose = useCallback(() => { context.close(); focusSession(id, true); }, [context, id]); + }, [id, closing, tool]); + const onClose = useCallback(() => { context.close(); if (!tool) focusSession(id, true); }, [context, id, tool]); const copy = async (value: string) => { if (!await writeTextToClipboard(value)) throw new Error('Could not copy to clipboard'); }; const mismatch = !!helper && !!cwd && !!helperCwd && (cwd.path !== helperCwd.path || cwd.isRemote !== helperCwd.isRemote || (cwd.isRemote && cwd.host !== helperCwd.host)); const warning = openWarning ?? (helperError || (helper && helper.status !== 'waiting' && (!cwd || !helperCwd) ? 'Directory comparison unavailable: a terminal has not reported its directory.' : undefined)); - return <TerminalContextView closing={closing} origin={origin} title={deriveSurfaceLabel(state, appTitleForPane, title ?? id)} surfaceRef={actions.resolveSurfaceRef(id)} + return <TerminalContextView terminalRole={tool ? 'tool' : 'helper'} closing={closing} origin={origin} title={deriveSurfaceLabel(state, appTitleForPane, title ?? id)} surfaceRef={actions.resolveSurfaceRef(id)} titleSources={titleSources} cwd={cwd ? display(cwd) : 'Directory unknown'} helperCwd={helperCwd && display(helperCwd)} mismatch={mismatch} scan={scan} argv0={argv0} watching={!!argv0 && isCommandWatched(argv0)} todo={activities.get(id)?.todo === true} notification={activities.get(id)?.notification} - status={helper?.status ?? 'waiting'} command={helper?.command ?? defaultCommand} defaultCommand={defaultCommand} warning={warning} + status={tool ? (state.currentCommand ? 'running' : 'completed') : helper?.status ?? 'waiting'} command={tool ? state.currentCommand?.rawCommandLine ?? state.lastCommand?.rawCommandLine ?? '' : helper?.command ?? defaultCommand} defaultCommand={defaultCommand} warning={warning} explorerLabel={IS_MAC ? 'Open in Finder' : IS_WINDOWS ? 'Open in Explorer' : 'Open folder'} canExplore={!!platform.terminalContext && !!cwd && !cwd.isRemote} canAgent={!!platform.agentBrowserOpen} canIframe={!!platform.createIframeProxyUrl} onClose={onClose} onCopyRef={() => copy(actions.resolveSurfaceRef(id))} onCopyPath={() => copy(cwd?.path ?? '')} @@ -64,8 +64,9 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni onPort={(entry, mode) => context.openPort(id, entry, mode)} onModify={async command => { await platform.terminalContext?.({ op: 'settings', command }); setDefaultCommand(command); }} notepadAction={<NotepadHeaderButton surfaceId={id} />} - notepadPanel={<NotepadPanel surfaceId={id} pins={false} />} + notepadPanel={<NotepadPanel surfaceId={id} pins={tool} />} onReset={async () => { if (isSurfaceClosing(id)) throw new Error('This terminal is closing'); disposeHelper(id); await openHelper(id); }} onPromote={() => context.promote(id)}> + {tool && <div data-context-terminal={id} className="h-full px-3 py-2" onMouseDown={() => getTerminalInstance(id)?.focus()}><TerminalPane id={id} isFocused={false} /></div>} {helper && <div data-helper-terminal={helper.id} className="h-full px-3 py-2" onMouseDown={() => focusSession(helper.id, true)}><TerminalPane key={helper.id} id={helper.id} isFocused={false} /></div>} </TerminalContextView>; } diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 8d40be1fd..a2c7794aa 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -43,6 +43,7 @@ const DETAILS = { } as const; type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { + terminalRole?: 'helper' | 'tool'; /** Exit in progress: the view is inert, and `onClose` is not called again. */ closing?: boolean; /** Viewport coordinates the reveal grows from; absent, the top-left corner. */ @@ -166,12 +167,13 @@ export function TerminalContextView(p: TerminalContextViewProps) { /** A detail-dialog action: closes the dialog on success and holds the buttons meanwhile. */ const submit = async (action: Action) => { setBusy(true); if (await attempt(action)) setDetail(null); setBusy(false); }; const status = HELPER_STATUS[p.status]; - const statusLabel = status.label(p.command); + const isTool = p.terminalRole === 'tool'; + const statusLabel = isTool ? (p.status === 'running' ? `Running ${p.command}…` : 'At prompt') : status.label(p.command); return <section ref={surface} aria-label="Terminal context" data-terminal-context tabIndex={-1} inert={p.closing} aria-hidden={p.closing || undefined} style={SURFACE_STYLE} className={`${TERMINAL_CONTEXT_SURFACE_CLASS} ${motionClass} ${p.closing ? 'pointer-events-none' : ''} absolute inset-4 flex flex-col overflow-hidden text-sm`} onContextMenu={event => event.preventDefault()} onKeyDown={event => { - if ((event.target as HTMLElement).closest('[data-helper-terminal]') && !detail) return; + if ((event.target as HTMLElement).closest('[data-helper-terminal], [data-context-terminal]') && !detail) return; if (detail && event.key === 'Tab') { event.preventDefault(); stepFocus(Array.from(detailRoot.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input,select') ?? []), event.shiftKey ? -1 : 1); @@ -205,12 +207,12 @@ export function TerminalContextView(p: TerminalContextViewProps) { {p.notification && <div className="ml-16 mt-2 border-l-2 border-border py-1 pl-3"><div>{p.notification.title}</div><div className="whitespace-pre-wrap text-muted">{p.notification.body}</div></div>} </div> <div className="@container flex min-h-0 flex-1 flex-col border-t border-border"> - <div aria-label="Helper terminal status" className="flex h-9 shrink-0 items-center gap-3 whitespace-nowrap px-3"> - <span className="hidden shrink-0 items-center gap-2 font-semibold @[48rem]:flex"><TerminalIcon size={15} />Helper terminal</span> + <div aria-label={isTool ? 'Tool terminal status' : 'Helper terminal status'} className="flex h-9 shrink-0 items-center gap-3 whitespace-nowrap px-3"> + <span className="hidden shrink-0 items-center gap-2 font-semibold @[48rem]:flex"><TerminalIcon size={15} />{isTool ? 'Tool terminal' : 'Helper terminal'}</span> <div className="flex min-w-0 items-center gap-2 text-muted">{status.icon}<span className="truncate" title={statusLabel}>{statusLabel}</span> - {status.reset ? <ContextAction label="Reset helper terminal" onClick={() => setDetail('reset')}><ArrowCounterClockwiseIcon size={13} />Reset…</ContextAction> : <ContextAction label="Modify autorun command" onClick={() => { setCommand(p.defaultCommand ?? p.command); setDetail('modify'); }}><SlidersHorizontalIcon size={15} />Modify</ContextAction>} + {!isTool && (status.reset ? <ContextAction label="Reset helper terminal" onClick={() => setDetail('reset')}><ArrowCounterClockwiseIcon size={13} />Reset…</ContextAction> : <ContextAction label="Modify autorun command" onClick={() => { setCommand(p.defaultCommand ?? p.command); setDetail('modify'); }}><SlidersHorizontalIcon size={15} />Modify</ContextAction>)} </div> - <div className="ml-auto flex shrink-0 items-center gap-2">{p.notepadAction}<ContextAction label="Move this terminal into a new pane" busy={busy} onClick={() => void submit(p.onPromote)}><ArrowLineUpIcon size={15} />Promote</ContextAction></div> + <div className="ml-auto flex shrink-0 items-center gap-2">{p.notepadAction}{!isTool && <ContextAction label="Move this terminal into a new pane" busy={busy} onClick={() => void submit(p.onPromote)}><ArrowLineUpIcon size={15} />Promote</ContextAction>}</div> </div> {p.mismatch && <div role="alert" className="mx-3 mb-2 flex shrink-0 items-start gap-2 border-l-4 border-error bg-error/10 px-3 py-2"><WarningIcon size={18} weight="fill" className="shrink-0 text-error" /><div><div className="font-semibold">Helper directory differs from parent</div><div className="mt-1 grid grid-cols-[4rem_1fr] gap-x-2"><span className="text-muted">Helper</span><strong>{p.helperCwd}</strong><span className="text-muted">Parent</span><span>{p.cwd}</span></div></div></div>} {(p.warning || (!detail && error)) && <div role="alert" className="mx-3 mb-2 border-l-4 border-error bg-error/10 px-3 py-2">{p.warning || error}</div>} diff --git a/lib/src/components/wall/TerminalPanel.tsx b/lib/src/components/wall/TerminalPanel.tsx index 8f1e40466..01c94c2e2 100644 --- a/lib/src/components/wall/TerminalPanel.tsx +++ b/lib/src/components/wall/TerminalPanel.tsx @@ -12,12 +12,12 @@ import { SelectedIdContext, } from './wall-context'; -export function TerminalPanel(props: PaneProps) { +export function TerminalPanel(props: PaneProps & { renderNotepad?: boolean; renderTerminal?: boolean }) { const context = useContext(TerminalContextContext); const mode = useContext(ModeContext); const selectedId = useContext(SelectedIdContext); const actions = useContext(WallActionsContext); - const isFocused = mode === 'passthrough' && selectedId === props.id && context.id !== props.id; + const isFocused = !props.parked && mode === 'passthrough' && selectedId === props.id && context.id !== props.id; const elRef = useRef<HTMLDivElement>(null); usePaneChrome(props.id, elRef); @@ -29,8 +29,8 @@ export function TerminalPanel(props: PaneProps) { if (mouse.mouseReporting !== 'none' && mouse.override === 'off') return; context.open(props.id, { origin: { x: event.clientX, y: event.clientY } }); }}> - <TerminalPane id={props.id} isFocused={isFocused} /> - {context.mounted?.id !== props.id && <NotepadPanel surfaceId={props.id} />} + {props.renderTerminal !== false && <TerminalPane id={props.id} isFocused={isFocused} />} + {props.renderNotepad !== false && context.mounted?.id !== props.id && <NotepadPanel surfaceId={props.id} />} </div> ); } 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..fa25be475 --- /dev/null +++ b/lib/src/components/wall/ToolPaneHeader.tsx @@ -0,0 +1,29 @@ +import { useContext } from 'react'; +import { TerminalIcon } from '@phosphor-icons/react'; +import { chromeButton } from '../design'; +import { SurfacePaneHeader } from './SurfacePaneHeader'; +import { TerminalPaneHeader } from './TerminalPaneHeader'; +import { toolFace } from './browser-surface'; +import { TerminalContextContext } from './wall-context'; +import type { PaneProps } from './pane-props'; + +export function ToolPaneHeader(props: PaneProps) { + const context = useContext(TerminalContextContext); + const face = toolFace(props.params); + if (face === 'terminal' || face === 'pending-approval') return <TerminalPaneHeader {...props} />; + return ( + <div className="flex h-full min-w-0 flex-1 items-center" onContextMenu={event => { + event.preventDefault(); event.stopPropagation(); + context.open(props.id, { origin: { x: event.clientX, y: event.clientY } }); + }}> + <button type="button" className={`${chromeButton()} ml-1 shrink-0`} + title="Terminal context" aria-label="Terminal context" aria-expanded={context.id === props.id} + onClick={event => { + event.stopPropagation(); + if (context.id === props.id) context.close(); + else { const rect = event.currentTarget.getBoundingClientRect(); context.open(props.id, { origin: { x: rect.left, y: rect.bottom } }); } + }}><TerminalIcon size={14} /></button> + {face === 'browser' ? <SurfacePaneHeader {...props} /> : <TerminalPaneHeader {...props} />} + </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..5a00668ef --- /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('shows the browser once serving, ignoring retired terminal-pin params', () => { + show(serving); + expect(half('browser').style.visibility).toBe('visible'); + show({ ...serving, showTerminal: true }); + expect(half('terminal').style.visibility).toBe('hidden'); + expect(half('browser').style.visibility).toBe('visible'); + }); + + 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('keeps a conflict visible despite retired terminal-pin params', () => { + show({ ...conflicted, showTerminal: true }); + expect(half('terminal').style.visibility).toBe('hidden'); + }); +}); + +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..aed25c155 --- /dev/null +++ b/lib/src/components/wall/ToolPanel.tsx @@ -0,0 +1,69 @@ +/** + * 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 { NotepadPanel } from '../NotepadPanel'; +import { TerminalContextContext, WallActionsContext } from './wall-context'; +import type { PaneProps } from './pane-props'; + +/** Keep hidden capability bodies sized. The primary xterm moves into the leaf's + * context overlay while it is open; TerminalPanel then renders no second view. + * The Session registry retains that xterm throughout the move. */ +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); + const context = useContext(TerminalContextContext); + const notepad = context.mounted?.id !== props.id && <NotepadPanel surfaceId={props.id} />; + + // 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 ( + <div className="relative h-full w-full"><ToolApproval + {...props} + onResolve={(id, choice) => void actions.onResolveToolApproval?.(id, choice)} + />{notepad}</div> + ); + } + + const showSecond = face !== 'terminal'; + return ( + <div className="relative h-full w-full"> + <Half shown={!showSecond}> + <TerminalPanel {...props} renderTerminal={context.mounted?.id !== props.id} renderNotepad={false} parked={props.parked || showSecond} /> + </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} renderNotepad={false} parked={props.parked || !showSecond} /> + )} + </Half> + {notepad} + </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 a5a8dbcf6..97c20403e 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -20,6 +20,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, @@ -179,6 +180,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; @@ -310,6 +313,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 = allowedBinaryPath(params.binaryPath); this.wsPort = params.wsPort; @@ -429,7 +435,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 ff89e7e74..a118727a2 100644 --- a/lib/src/components/wall/browser-surface.ts +++ b/lib/src/components/wall/browser-surface.ts @@ -16,6 +16,10 @@ type BrowserParamsLike = { renderMode?: unknown; session?: unknown; url?: 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; syncEngaged?: unknown; }; @@ -36,10 +40,112 @@ 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, or null when it has none yet. + * `toolFace` reads the conflict/browser mutual exclusion from this 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: until the human + // approves, there is no terminal to show — nothing has spawned. + if (toolPendingFromParams(params) !== null) return 'pending-approval'; + 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'; } @@ -63,6 +169,7 @@ export function browserDisplayModeFromParams(params: unknown): BrowserDisplayMod * 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/keyboard/handle-mouse-selection-keys.test.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts index 1099f9362..ebf2de747 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 @@ -26,13 +26,13 @@ vi.mock('../../../lib/notepad/capture', () => ({ isNotepadChordBound: vi.fn(() => true), })); -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: () => [], @@ -90,13 +90,35 @@ 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 contextTerminal = document.createElement('div'); + contextTerminal.dataset.contextTerminal = 'pane-a'; + const pinnedEvent = fakeEvent(contextTerminal, { key: 'v', metaKey: true }); + expect(handleMouseSelectionKeys(pinnedEvent, makeCtx(browser))).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 fdcd60f27..754add479 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts @@ -9,6 +9,8 @@ import { } from '../../../lib/mouse-selection'; import { addSelectionToNotepad, isNotepadChordBound } from '../../../lib/notepad/capture'; import { hasCopyModifier, hasPasteModifier } from './chords'; +import { hasTerminal } from 'dor/commands/types'; +import { surfaceKindFromParams, toolFace } from '../browser-surface'; import type { WallKeyboardCtx } from './types'; /** @@ -31,7 +33,8 @@ 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; + const contextTerminal = tgt?.closest?.<HTMLElement>('[data-context-terminal]'); + if (contextTerminal?.dataset.contextTerminal !== sid && !hasActiveTerminal(ctx, sid)) return false; const mouseState = getMouseSelectionState(sid); const sel = mouseState.selection; @@ -91,7 +94,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/types.ts b/lib/src/components/wall/keyboard/types.ts index 48da3fd6f..211df6b92 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 f3accd8ab..b64da9aec 100644 --- a/lib/src/components/wall/lath-wall-engine.ts +++ b/lib/src/components/wall/lath-wall-engine.ts @@ -91,6 +91,44 @@ 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, + // Retired development snapshots may carry this; never save it again. + 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 { @@ -106,7 +144,9 @@ export function leafMetaFromPersistedDoor(item: PersistedDoor): LeafMeta { * screencast canvas); terminals do not — the registry retains their xterm instance * and remounts 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 = { @@ -213,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-browser-session.ts b/lib/src/components/wall/tool-browser-session.ts new file mode 100644 index 000000000..ed4df3e85 --- /dev/null +++ b/lib/src/components/wall/tool-browser-session.ts @@ -0,0 +1,55 @@ +import type { PlatformAdapter } from '../../lib/platform/types'; +/** 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 }; + +/** + * Open `url` in `session` and hand `surfaceId` the resulting `{session, wsPort, + * binaryPath}` as one params write for the tool serving trigger. + * + * 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) { + 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; + // if it's absent or stale the panel recovers it later, so a miss is non-fatal. + let wsPort: number | undefined; + if (platform.agentBrowserStreamStatus) { + const status = await platform.agentBrowserStreamStatus(session, binaryPath); + if (status.ok) wsPort = status.wsPort; + } + // 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 } : {}), + }); + return { ok: true }; +} 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..f6c605664 --- /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('ignores a retired terminal-pin param once the tool serves', () => { + expect(toolFace({ ...serving, showTerminal: true })).toBe('browser'); + }); + + 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 f690af08c..358c1ef97 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,10 +23,18 @@ import { import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; import { isAllowedAgentBrowserBinary } from '../../lib/agent-browser-binary'; import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; -import { agentBrowserSessionFromParams } from './browser-surface'; +import { + agentBrowserSessionFromParams, + namespacedToolKey, + toolKeysEqual, + toolPendingFromParams, + type ToolPending, +} from './browser-surface'; + 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 { CloseSurfaceMode, DooredItem } from './wall-types'; type DorControlParams = { @@ -52,6 +61,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 @@ -237,6 +248,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 : ''; +} + type WaitOutcome = 'ready' | 'timeout' | 'aborted'; /** Resolve once `predicate` holds for the surface's live state, the timeout @@ -372,6 +405,7 @@ export function useDorControl({ createContentSurface, isClosingSurface, closeSurface, + revealSurface, lastAgentBrowserBinaryPathRef, }: { /** The Lath engine — visible-pane projection (`lath.listPanes()`), aspect-ratio @@ -395,6 +429,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; @@ -409,6 +450,7 @@ export function useDorControl({ * down. A string means the closure was refused, and is why; the Surface is * still here. */ closeSurface: (id: string, mode?: CloseSurfaceMode) => Promise<string | null>; + revealSurface: (id: string) => void; /** The last binary path a `dor ab` surface resolved on a terminal's PATH. */ lastAgentBrowserBinaryPathRef: MutableRefObject<string | undefined>; }): { @@ -677,6 +719,293 @@ 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, + detail.signal, + ); + if (detail.signal?.aborted || toolIntegrated !== 'ready') { + const refused = await closeSurface(created.value.id, 'silent'); + detail.respond({ ok: false, error: refused ?? (detail.signal?.aborted ? 'tool launch cancelled' : 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) { if (detail.signal?.aborted) { detail.respond({ ok: false, error: ENSURE_CANCELLED }); @@ -1085,7 +1414,7 @@ export function useDorControl({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, revealSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceByParams, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); return { findSurfaceByParams, updateSurfaceParams }; } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 37d8afd63..ab60c44cb 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..6020bf705 --- /dev/null +++ b/lib/src/components/wall/use-tool-serving.test.tsx @@ -0,0 +1,302 @@ +// @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 = 'x'; + +vi.mock('../../lib/terminal-registry', () => ({ + getTerminalPaneState: () => ({ currentCommand: currentCommand === null ? null : { id: currentCommand, rawCommandLine: currentCommand } }), +})); + +beforeEach(() => { + vi.useFakeTimers(); + resetToolAnnounces(); + currentCommand = 'x'; + 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'); + }); +}); + + +it('does not frame or re-key a different command running in a Tool Session', async () => { + currentCommand = 'cat untrusted.log'; + recordToolAnnounce('tool-1', { port: 6006, name: null, key: ['spoof'], dehydrate: false, persist: null }); + const { state, platform } = await run({ surfaceType: 'tool', command: 'x', toolPort: 'auto' }, [[tcp(6006)], [tcp(6006)]]); + expect(platform.getOpenPorts).not.toHaveBeenCalled(); + expect(state.params.url).toBeUndefined(); + expect(state.params.toolKey).toBeUndefined(); +}); + +it('ignores a scan that finishes after the command has changed', async () => { + const { lath, state } = fakeLath({ surfaceType: 'tool', command: 'x', toolPort: 'announced' }); + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + let resolve!: (ports: OpenPort[]) => void; + const platform = new FakePtyAdapter(); + platform.getOpenPorts = vi.fn(() => new Promise<OpenPort[]>(r => { resolve = r; })); + setPlatform(platform); + function Probe() { useToolServing({ lath, doorsRef: { current: [] } }); return null; } + await act(async () => { root.render(<Probe />); }); + currentCommand = 'cat other.log'; + await act(async () => { resolve([tcp(6006)]); }); + expect(state.params.url).toBeUndefined(); +}); 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..2b597f583 --- /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 './tool-browser-session'; +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 run = getTerminalPaneState(leaf.id).currentCommand; + const running = run !== null && run.rawCommandLine === leaf.params?.command; + const announce = running ? getToolAnnounce(leaf.id) : null; + + // 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; + + // 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, + 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; + if (!lath.getMeta(leaf.id) || getTerminalPaneState(leaf.id).currentCommand?.id !== run?.id) continue; + 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 && getTerminalPaneState(id).currentCommand?.id === run?.id) lath.store.updateParams(id, patch); + }, + }); + // 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 (cancelled || !lath.getMeta(leaf.id) || getTerminalPaneState(leaf.id).currentCommand?.id !== run?.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/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index c7734ea65..cbc51ad93 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -30,7 +30,8 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { const context = (e.target as HTMLElement | null)?.closest?.('[data-terminal-context]'); if (context) { if (handleEditableClipboard(e)) return; - const helperId = (e.target as HTMLElement).closest<HTMLElement>('[data-helper-terminal]')?.dataset.helperTerminal; + const terminalElement = (e.target as HTMLElement).closest<HTMLElement>('[data-helper-terminal], [data-context-terminal]'); + const helperId = terminalElement?.dataset.helperTerminal ?? terminalElement?.dataset.contextTerminal; if (helperId) handleMouseSelectionKeys(e, { ...c, selectedIdRef: { current: helperId } }); return; } diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index 74a62b329..64e26725b 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -54,6 +54,9 @@ export interface WallActions { /** The stable `surface:N` ref for a pane/door id (minted lazily, exactly as * `dor list` assigns refs). Used by the pane context menu to show the handle. */ resolveSurfaceRef: (id: string) => string; + /** 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>({ @@ -72,6 +75,7 @@ export const WallActionsContext = createContext<WallActions>({ onSwapRenderMode: () => {}, onOpenBrowserPane: () => {}, resolveSurfaceRef: (id: string) => id, + 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/notepad/pin.test.ts b/lib/src/lib/notepad/pin.test.ts index 65cd20bed..bce15ab7c 100644 --- a/lib/src/lib/notepad/pin.test.ts +++ b/lib/src/lib/notepad/pin.test.ts @@ -103,6 +103,25 @@ describe('revealNoteSource', () => { expect(markersOf(src).every((marker) => !marker.isDisposed)).toBe(true); }); + it('resolves markers after the source view has mounted and refit', () => { + const lines = [...LINES]; + const { terminal, scrollToLine } = makeTerminal(lines, { viewportY: 40, baseY: 40 }); + mocks.getTerminalInstance.mockReturnValue(terminal); + const { noteId, source: src } = pinnedNote(); + const target = new EventTarget(); + target.addEventListener('dormouse:reveal-note-source', () => { + // Model the marker movement caused by refitting the source's new view. + lines.unshift('new wrapped row'); + for (const marker of markersOf(src)) marker.line += 1; + }); + vi.stubGlobal('window', target); + try { + expect(revealNoteSource('term-1', noteId)).toEqual({ ok: true }); + expect(scrollToLine).toHaveBeenCalledWith(1); + expect(getMouseSelectionState('term-1').selection).toMatchObject({ startRow: 1, endRow: 2 }); + } finally { vi.unstubAllGlobals(); } + }); + it('reports a note that has no pin', () => { const noteId = addPlainNote('term-1', 'typed by hand'); expect(revealNoteSource('term-1', noteId)).toEqual({ ok: false, reason: 'no-source', kept: false }); diff --git a/lib/src/lib/notepad/pin.ts b/lib/src/lib/notepad/pin.ts index 724fe9419..e0223cd46 100644 --- a/lib/src/lib/notepad/pin.ts +++ b/lib/src/lib/notepad/pin.ts @@ -50,6 +50,7 @@ export function revealNoteSource(surfaceId: string, noteId: string): PinOutcome return failed('no-terminal'); } + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('dormouse:reveal-note-source', { detail: { surfaceId, terminalId: source.terminalId } })); const resolved = resolveTerminalSource(terminal, source); if (!resolved.ok) { const outcome = failed(resolved.reason); 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 9476be67e..ae714bd7f 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -6,6 +6,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'; import type { NotepadArchivePort } from '../notepad/types'; export interface PtyInfo { @@ -297,6 +300,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.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 4dd75f392..f61871d57 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -1,3 +1,4 @@ +import { getToolAnnounce, resetToolAnnounces } from '../tool-announce-store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const terminalStateStoreMocks = vi.hoisted(() => ({ @@ -223,6 +224,17 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(snapshots).toEqual([['claude', 'npm']]); }); + it('receives owner-parsed Tool announcements and reconstructs them on replay', () => { + resetToolAnnounces(); + const adapter = new VSCodeAdapter(); + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:toolAnnounce', id: 'tool-1', announce: { port: 6006, name: null, key: null, dehydrate: false, persist: null } })); + expect(getToolAnnounce('tool-1')?.port).toBe(6006); + const repliesBeforeReplay = postMessage.mock.calls.length; + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id: 'tool-1', data: '\x1b]367;serve;{"port":6007}\x1b\\' })); + expect(getToolAnnounce('tool-1')?.port).toBe(6007); + expect(postMessage.mock.calls).toHaveLength(repliesBeforeReplay); + }); + it('parses replay buffers into semantic events and strips OSCs before forwarding', () => { const adapter = new VSCodeAdapter(); const replays: Array<{ id: string; data: string }> = []; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 00fbf0318..26758d376 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,6 @@ +import { recordToolAnnounce } from '../tool-announce-store'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; -import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink } from './types'; +import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, ToolControlResult, ToolHostRequest } from './types'; import { OPEN_PORT_TIMEOUT_MS } from './types'; import { createBurrowLinkClient } from '../../host/remote/link-client'; import type { AwaitHandle, AwaitOptions, AwaitOutcome } from '../alert-manager'; @@ -178,10 +179,13 @@ export class VSCodeAdapter implements PlatformAdapter { // backstop, not the contract. const parser = new TerminalProtocolParser(themeColorProvider); const parsed = parser.process(msg.data); + for (const event of parsed.events) if (event.kind === 'toolAnnounce') recordToolAnnounce(msg.id, event.announce); applyTerminalSemanticEvents(msg.id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { handler({ id: msg.id, data: parsed.visibleData }); } + } else if (msg.type === 'terminal:toolAnnounce') { + recordToolAnnounce(msg.id, msg.announce); } else if (msg.type === 'terminal:semanticEvents') { applyTerminalSemanticEvents(msg.id, msg.events ?? []); } else if (msg.type === 'dormouse:flushSessionSave') { @@ -467,6 +471,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 13e7acd98..67f7d7d86 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -197,6 +197,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.each([undefined, { version: 1 }, { version: 1, tree: { root: { kind: 'leaf', id: 'stale-pane' } }, @@ -314,3 +337,14 @@ describe('restoreSession', () => { ); }); }); + + +it('recovers Tool metadata from pane rows when its layout is unusable', () => { + const restored = restoreSession(createPlatform({ version: 3, panes: [ + { id: 'tool', title: 'Storybook', cwd: '/repo', untouched: false, surfaceType: 'tool', command: 'pnpm storybook', tool: { render: 'iframe', port: 'auto', name: 'storybook', key: ['storybook', '/repo'] } }, + { id: 'web', title: 'Web', cwd: null, untouched: false, surfaceType: 'browser' }, + ] })); + expect(restored?.paneIds).toEqual(['tool']); + expect(restored?.lathLayout?.leafMeta.tool).toMatchObject({ component: 'tool', tabComponent: 'tool', params: { command: 'pnpm storybook', toolRender: 'iframe', toolPort: 'auto', toolName: 'storybook' } }); + expect(restored?.lathLayout?.leafMeta.tool.params?.url).toBeUndefined(); +}); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index 283b3730a..b459367d8 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,3 +1,4 @@ +import type { LathNode } from './lath/model'; import { type LathPersistedLayout, isLathPersistedLayout } from './lath/persistence'; import type { PlatformAdapter } from './platform/types'; import { carrySurfaceRefs, readPersistedSession, type PersistedDoor, type PersistedSession, type PersistedSurfaceRefs } from './session-types'; @@ -30,8 +31,24 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul const visibleIds = new Set(visiblePanes.map((pane) => pane.id)); const candidateLayout = persistedLathLayout(saved); const leafIds = candidateLayout ? Object.keys(candidateLayout.leafMeta) : []; - const lathLayout = candidateLayout && leafIds.length === visibleIds.size && leafIds.every((id) => visibleIds.has(id)) + let lathLayout = candidateLayout && leafIds.length === visibleIds.size && leafIds.every((id) => visibleIds.has(id)) ? candidateLayout : undefined; + // Tool commands remain runnable when geometry is corrupt. Rebuild their kind + // and stable metadata from the pane projection instead of seeding plain shells. + if (!lathLayout && visiblePanes.some(pane => pane.surfaceType === 'tool')) { + const recoverable = visiblePanes.filter(pane => pane.surfaceType !== 'browser'); + const nodes: LathNode[] = recoverable.map(pane => ({ kind: 'leaf', id: pane.id })); + lathLayout = { + version: 1, + tree: { root: nodes.length === 1 ? nodes[0] : { kind: 'split', dir: 'row', children: nodes.map(node => ({ node, weight: 1 })) } }, + leafMeta: Object.fromEntries(recoverable.map(pane => [pane.id, pane.surfaceType === 'tool' ? { + component: 'tool', tabComponent: 'tool', title: pane.title, + params: { surfaceType: 'tool', command: pane.command, cwd: pane.cwd, + toolName: pane.tool?.name, toolRender: pane.tool?.render ?? 'iframe', + toolPort: pane.tool?.port ?? 'announced', toolKey: pane.tool?.key }, + } : { component: 'terminal', tabComponent: 'terminal', title: pane.title }])), + }; + } const shellOpts = getDefaultShellOpts(); // Host-owned and single-use, and read here rather than off the pane: the // session blob the webview saves must never carry one, or a later restore @@ -54,7 +71,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 }), }); } @@ -62,7 +83,7 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul // Without a usable layout Wall seeds terminal metadata for each id. Browser // render params live only in that layout (or a door), so omit visible browser // ids instead of silently restoring them as shells. - paneIds: visiblePanes.filter((pane) => lathLayout || pane.surfaceType !== 'browser').map((pane) => pane.id), + paneIds: visiblePanes.filter((pane) => lathLayout ? !!lathLayout.leafMeta[pane.id] : pane.surfaceType !== 'browser').map((pane) => pane.id), lathLayout, doors, ...carrySurfaceRefs(saved), diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 063ae5733..15524fe58 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -224,6 +224,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 2cf2928e7..aed67e6cc 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, toPersistedAlertState, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; +import { browserPersistedPane, readPersistedSession, toPersistedAlertState, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedToolMetadata, type PersistedSurfaceType } from './session-types'; import { getActivity, getLivePersistedAlertState, getTerminalPaneState, isUntouched } 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( @@ -56,13 +66,23 @@ export async function saveSession( const liveAlert = getLivePersistedAlertState(pane.id); const cwd = await platform.getCwd(pane.id); - 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 = { @@ -76,6 +96,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 83e28dcf2..9eacf612e 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -10,7 +10,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`. */ @@ -21,6 +31,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; } /** @@ -142,11 +157,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 5c94542d4..baefbc529 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 { ImageAddon, type IImageAddonOptions } from '@xterm/addon-image'; @@ -525,7 +526,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; @@ -541,10 +551,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, @@ -557,17 +563,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; @@ -636,6 +644,7 @@ export function disposeSession(id: string): void { registry.delete(id); removeTerminalPaneState(id); removeMouseSelectionState(id); + clearToolAnnounce(id); clearTerminalActivity(id); } diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 4f3b4fb2a..f151cad3e 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 { STRING_CONTROL_INTRODUCER, STRING_CONTROL_INTRODUCER_SCAN, @@ -20,6 +23,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 }; @@ -90,7 +94,7 @@ const OSC99_SUPPORT_PAYLOAD = 'o=always:p=title,body'; const OSC99_RESPONSE_ID_RE = /^[^\s:;\x00-\x1f\x7f-\x9f]+$/; // Every OSC id `parseOsc` claims. Anything else is xterm.js's, which is what // lets an unterminated one stream instead of filling `pending`. -const OSC_CONSUMED_IDS = new Set(['0', '2', '7', '9', '10', '11', '12', '50', '52', '99', '133', '633', '777']); +const OSC_CONSUMED_IDS = new Set(['0', '2', '7', '9', '10', '11', '12', '50', '52', '99', '133', '367', '633', '777']); // The OSC 1337 subcommands ImageAddon owns; every other 1337 is consumed. const OSC1337_FORWARDED = ['File=', 'MultipartFile=', 'FilePart=', 'FileEnd', 'ReportCellSize'] as const; const TERMINAL_BELL_NOTIFICATION: ActivityNotification = { source: 'BEL', title: 'Terminal bell', body: null }; @@ -289,6 +293,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 []; @@ -428,12 +438,15 @@ 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); } } } /** - * The notification and progress events {@link applyTerminalProtocolEvents} acts + * The notification, progress, and Tool announcement events {@link applyTerminalProtocolEvents} acts * on. An owner whose `AlertManager` lives in another process — standalone's * sidecar, whose webview holds it — forwards exactly these; every other kind is * the owner's own to settle, a response above all. @@ -441,7 +454,7 @@ export function applyTerminalProtocolEvents( export function collectTerminalProtocolAlerts( events: TerminalProtocolEvent[], ): TerminalProtocolEvent[] { - return events.filter((event) => event.kind === 'notification' || event.kind === 'progress'); + return events.filter((event) => event.kind === 'notification' || event.kind === 'progress' || event.kind === 'toolAnnounce'); } export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[]): string[] { @@ -818,24 +831,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']; /** The iTerm2 extended-DA query, in both its ESC and C1 spellings. */ const DEVICE_ATTRIBUTE_QUERY = /\x1b\[>q|\x9b>q/g; diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 956fd3ab8..ae3d0e9e1 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -521,6 +521,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..72dd7fed5 --- /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). + * + * Host-parsed live events and renderer-parsed raw replay feed this store. + * It is renderer state; owners forward announcements rather than keep a second + * copy that cannot reach the Wall. + * + * **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..a1555b300 --- /dev/null +++ b/lib/src/lib/tool-announce.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { parseToolAnnounce } from './tool-announce'; +import { collectTerminalProtocolAlerts, collectTerminalProtocolResponses, 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); + }); +}); + + +it('consumes chunked OSC 367 and forwards the announcement without a terminal reply', () => { + const parser = new TerminalProtocolParser(); + expect(parser.process('before\x1b]367;serve;{"port":').visibleData).toBe('before'); + const parsed = parser.process('6006}\x1b\\after'); + expect(parsed.visibleData).toBe('after'); + expect(collectTerminalProtocolAlerts(parsed.events)).toEqual([ + { kind: 'toolAnnounce', announce: { port: 6006, name: null, key: null, dehydrate: false, persist: null } }, + ]); + expect(collectTerminalProtocolResponses(parsed.events)).toEqual([]); +}); 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 cf90cfefc..530b3d687 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: @@ -140,19 +140,22 @@ importers: uqr: specifier: ^0.1.3 version: 0.1.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 @@ -161,7 +164,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 @@ -182,10 +185,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)) relay: dependencies: @@ -269,7 +272,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 @@ -281,7 +284,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 @@ -299,10 +302,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: @@ -321,7 +324,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 @@ -333,7 +336,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) @@ -354,10 +357,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: @@ -385,10 +388,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/node': specifier: ^24.13.3 version: 24.13.3 @@ -406,10 +409,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: @@ -4698,6 +4701,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'} @@ -5361,11 +5369,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 @@ -5665,7 +5673,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 @@ -5691,7 +5699,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: @@ -5915,10 +5923,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 @@ -5934,25 +5942,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': {} @@ -5969,11 +5977,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 @@ -5983,7 +5991,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: @@ -5994,11 +6002,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 @@ -6008,7 +6016,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: @@ -6114,12 +6122,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': {} @@ -6320,10 +6328,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: @@ -6342,13 +6350,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: @@ -8734,7 +8742,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 @@ -8746,11 +8754,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 @@ -8767,7 +8776,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 @@ -8850,6 +8859,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-word-budgets.json b/scripts/spec-word-budgets.json index 63d729b76..b1ccbf7e7 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,27 +6,27 @@ "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 4850, - "docs/specs/dor-tool.md": 2100, + "docs/specs/dor-cli.md": 4900, + "docs/specs/dor-tool.md": 2300, "docs/specs/glossary.md": 2850, "docs/specs/layout.md": 8000, "docs/specs/mobile-terminal-ui.md": 1950, - "docs/specs/mouse-and-clipboard.md": 3750, - "docs/specs/notepad.md": 3700, + "docs/specs/mouse-and-clipboard.md": 3800, + "docs/specs/notepad.md": 3750, "docs/specs/pocket-app.md": 4050, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 3600, "docs/specs/remote-security-model.md": 4200, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, - "docs/specs/security-local.md": 2550, + "docs/specs/security-local.md": 2700, "docs/specs/security-remote.md": 4900, "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, - "docs/specs/shortcuts.md": 1000, + "docs/specs/shortcuts.md": 950, "docs/specs/standalone.md": 4300, - "docs/specs/terminal-context.md": 900, - "docs/specs/terminal-escapes.md": 3750, + "docs/specs/terminal-context.md": 1050, + "docs/specs/terminal-escapes.md": 3800, "docs/specs/terminal-state.md": 2300, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 8b86f2b4e..0968a4efb 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/burrow.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; @@ -26,6 +27,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: 'burrow.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 8f1af50e0..f5de444aa 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -135,6 +135,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), @@ -334,6 +336,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 3fa735078..30fb9e90a 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". @@ -52,6 +53,10 @@ const burrow = createSidecarBurrow({ 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 @@ -140,6 +145,11 @@ function handleLine(line) { case 'sidecar:shutdown': shutdown(); break; case 'dor:controlResponse': dorControl?.respond(data); break; case 'burrow:command': burrow.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 83a4e2ee4..fb6233cfc 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -523,6 +523,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 @@ -1894,6 +1912,7 @@ pub fn run() { pty_get_open_ports, pty_graceful_kill_all, iframe_create_proxy_url, + tool_control, pty_request_init, dor_control_response, burrow_command, diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 4384f3238..d58492be0 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolAnnounce } from '../../lib/src/lib/tool-announce-store'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import type { AgentBrowserCommandResult, @@ -14,6 +15,8 @@ import type { PtyDataDetail, PtyInfo, BurrowLink, + ToolControlResult, + ToolHostRequest, } from "dormouse-lib/lib/platform/types"; import { answerAskCommand, @@ -92,6 +95,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); @@ -172,6 +176,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", { @@ -341,6 +353,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // why the one-shot parser still needs the theme. const { id, data: text } = data as { id: string; data: string }; const parsed = new TerminalProtocolParser(themeColorProvider).process(text); + for (const event of parsed.events) if (event.kind === 'toolAnnounce') recordToolAnnounce(id, event.announce); applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData }); } else if (event === BURROW_RESULT_EVENT) { diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 0a051c231..6603039f9 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolAnnounce } from '../../lib/src/lib/tool-announce-store'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { invoke as rawInvoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; @@ -17,6 +18,8 @@ import type { PtyDataDetail, PtyInfo, BurrowLink, + ToolControlResult, + ToolHostRequest, } from "dormouse-lib/lib/platform/types"; import type { NotepadArchiveLoadResult, @@ -178,6 +181,7 @@ export class TauriAdapter implements PlatformAdapter { // reaches xterm.js instead, and answering is the owner's alone. const { id, data } = event.payload; const parsed = new TerminalProtocolParser(themeColorProvider).process(data); + for (const event of parsed.events) if (event.kind === 'toolAnnounce') recordToolAnnounce(id, event.announce); applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { handler({ id, data: parsed.visibleData }); @@ -326,6 +330,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 c743d5053..3c308e28f 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 { forgetRetiredState } from './retired-state'; import { captureAgentRecoveryCommands, mergeAlertStates, refreshSavedSessionStateFromPtys, takeRecoveryCommands } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; @@ -91,6 +92,7 @@ export function activate(context: vscode.ExtensionContext) { // The Burrow runs here, in the extension host that owns the PTYs — in // whichever window wins the bind (burrow.ts). context.subscriptions.push(initBurrow(context)); + 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 50d0ab206..ab3006f95 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -1,3 +1,4 @@ +import type { ToolAnnounce } from '../../lib/src/lib/tool-announce'; import * as vscode from 'vscode'; import * as ptyManager from './pty-manager'; import { AlertManager, type AwaitHandle, type AwaitOutcome } from '../../lib/src/lib/alert-manager'; @@ -25,6 +26,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 { archiveVolatileMirror, loadNotepadArchive, @@ -206,6 +209,7 @@ type ProcessedExitListener = (id: string, exitCode: number) => void; const processedExitListeners = new Set<ProcessedExitListener>(); type SemanticEventsListener = (id: string, events: TerminalSemanticEvent[]) => void; const semanticEventsListeners = new Set<SemanticEventsListener>(); +const toolAnnounceListeners = new Set<(id: string, announce: ToolAnnounce) => void>(); export function onProcessedPtyData(listener: ProcessedDataListener): () => void { processedDataListeners.add(listener); @@ -276,7 +280,10 @@ function createOwnerPtyStream(id: string): ProcessedPtyStream { return createProcessedPtyStream({ colorProvider: themeColorProvider, onEvents(events) { - applyTerminalProtocolEvents(alertManager, id, events); + applyTerminalProtocolEvents(alertManager, id, events.filter(event => event.kind !== 'toolAnnounce')); + for (const event of events) if (event.kind === 'toolAnnounce') { + for (const listener of toolAnnounceListeners) listener(id, event.announce); + } const semanticEvents = collectTerminalSemanticEvents(events); alertManager.applyTerminalSemanticEvents(id, semanticEvents); if (semanticEvents.length > 0) { @@ -531,6 +538,10 @@ export function attachRouter( if (!ownedPtyIds.has(id)) return; post({ type: 'pty:data', id, data: visibleData, textData } satisfies ExtensionMessage); }); + const onToolAnnounce = (id: string, announce: ToolAnnounce) => { + if (ownedPtyIds.has(id)) post({ type: 'terminal:toolAnnounce', id, announce } satisfies ExtensionMessage); + }; + toolAnnounceListeners.add(onToolAnnounce); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { if (!ownedPtyIds.has(id)) return; post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); @@ -549,6 +560,7 @@ export function attachRouter( return () => { removeProcessedListener(); removeSemanticListener(); + toolAnnounceListeners.delete(onToolAnnounce); removeExitListener(); removeAlertListener(); }; @@ -734,6 +746,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 : '', diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 305e62931..5bd4226bb 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,3 +1,4 @@ +import type { ToolAnnounce } from '../../lib/src/lib/tool-announce'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import type { AwaitOutcome, @@ -7,7 +8,7 @@ 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, AlertStateDetail, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; +import type { AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; import type { BurrowCommand, BurrowResult } from '../../lib/src/host/remote/service-protocol'; import type { VolatileNotepadSnapshot } from '../../lib/src/lib/notepad/types'; @@ -35,6 +36,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; embedderOrigins: string[]; requestId: string } + | { type: 'tool:control'; request: ToolHostRequest; requestId: string } // Peer surfaces: the Burrow 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 @@ -92,6 +94,7 @@ export type ExtensionMessage = // so this never doubles the bytes on the wire (docs/specs/transport.md). | { type: 'pty:data'; id: string; data: string; textData?: string } | { type: 'pty:exit'; id: string; exitCode: number } + | { type: 'terminal:toolAnnounce'; id: string; announce: ToolAnnounce } | { type: 'terminal:semanticEvents'; id: string; events: TerminalSemanticEvent[] } | { type: 'pty:list'; ptys: PtyInfo[] } | { type: 'pty:replay'; id: string; data: string } @@ -108,6 +111,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: `burrowRequestId` 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 24d06258d..4a15fc337 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -383,5 +383,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/" } ]