diff --git a/.gitignore b/.gitignore index 50f588415..5c1071b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ standalone/sidecar/alert-store.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 b348a48bf..8debb99b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,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 f6bdc5993..1d931af0d 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -643,6 +643,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 7fa52ec28..1ce8d6808 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -1,161 +1,201 @@ # 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 new designation; existing Tools retain serving and exit cleanup. Inert announcement parsing and capability predicates remain active. 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`; `useToolServing` in `lib/src/components/wall/use-tool-serving.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`. +- **Never apply the untouched-shell kill or shell-replacement shortcut to a Tool**, which spawns touched. +- **Must classify Tool params before browser params**, since a serving Tool carries `renderMode` too. + +Source of truth: `surfaceKindFromParams` / `isToolParams` in `lib/src/components/wall/browser-surface.ts`; `onSwapRenderMode` / `requestKill` / `isUntouchedShell` in `lib/src/components/Wall.tsx`; `lib/src/components/wall/tool-surface.test.ts`. + +## 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 dedupe within the answering Workspace.** `--workspace` uses the routing in `docs/specs/dor-cli.md` → Handle Model. + +- **Must serialize Tool launch requests and approval completion in the renderer**, covering lookup, matching, creation, and startup. The current lock serializes all Tool requests, not only matching keys. +- **Must retain the queue after integration until the new Tool command starts or completes**, or startup times out or is cancelled. A matching completion before waiting counts; integration alone does not prove injection occurred. +- **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 unless `--fresh` is set**, matching Tool name, project root, CWD, and fresh intent; preserve its approval state and report `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: `queueToolSpawn` / 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 require `trust-recorded` before re-resolving the named entry**, retaining the pending pane and its error until the next attempt after a rejected grant or failed re-resolution (blank reasons use a fallback), then stage the resolved command, renderer, port strategy, and key before exposing its terminal. A Surface closed during host calls must not start later or show a stale error. +5. **Must recheck the resolved key before launching an approved Tool**, honoring its original `--fresh` intent. Close a redundant approval through the ordinary close coordinator before revealing or restarting the match; a failed closure retains the approval and sends no command. +6. **Must close a declined approval through the ordinary close coordinator and record no denial.** Archive failure may retain the pane. (rationale) +7. **Must record each grant as its own atomically written file**, so hosts sharing one state directory never lock or merge. +8. **Never content-hash grants or re-prompt solely because the config changed.** (rationale) + +Approval layout follows `docs/specs/layout.md` → Pane body. + +**Must validate a bounded regular, non-symlink grant receipt for the requested key.** Missing, corrupt, or mismatched records grant nothing; a filename alone is never approval. + +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 and retire browser resources when the observed command-run id changes, even when the command text is unchanged; an initial observation preserves an imported live binding. (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**, clearing the existing session/stream binding during a reopen as well. Keep the session-less renderer inert and block Workspace transfer until the binding arrives. Close any browser session whose Tool disappeared or changed command during startup. +- **Must reuse an existing browser session and its binary path when an announcement changes its destination.** +- **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 return keyboard focus directly to the primary terminal when it becomes the selected passthrough face**, even while the retiring browser still owns a Surface focus handle. + +**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: `TerminalPane` in `lib/src/components/TerminalPane.tsx`; `focusSession` in `lib/src/lib/terminal-lifecycle.ts`; `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`, `lib/src/components/TerminalPane.test.tsx`, `lib/src/lib/terminal-registry.alert.test.ts`. + +## 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 and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement; a later serve in the same chunk survives. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` scoped to the owning webview, with null clearing the hint. The fake adapter applies locally. +- **Must reconstruct announcements and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, 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 retain live Tool browser params and OSC announcements in volatile Workspace-transfer content**, applying them to the destination plan without mutating the durable record. A serving iframe Tool participates in the ordinary iframe move confirmation. **Must refuse transfer while a Tool awaits approval or its browser startup has no session binding.** + +**Must pause serving updates during Workspace closure or transfer**, and recheck that a Workspace remains available after asynchronous launch lookup. Approval completion must not launch into a closing or transferring Workspace. + +Source of truth: `captureToolParams` / `restoreToolParams` in `lib/src/components/wall/tool-transfer.ts`; `captureTransferContent` in `lib/src/components/wall/workspace-transfer.ts`; `planArrival` in `standalone/src/workspace-move.ts`. Tests: `lib/src/components/wall/tool-transfer.test.ts`, `lib/src/components/WorkspaceWindow.test.tsx`, `lib/src/components/wall/use-tool-serving.test.tsx`. + +**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 @@ -167,98 +207,23 @@ Workspace of dehydratable tools drops to zero processes, relieving the parked-surface pressure hidden Workspaces carry (`docs/specs/layout.md` → Workspaces; `docs/specs/tiling-engine.md` → Parked leaves). -**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. +**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..0399f28e5 --- /dev/null +++ b/docs/specs/dor-tool.rationale.md @@ -0,0 +1,51 @@ +# 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 + +An exit and rerun can both occur inside the 1.5-second polling interval. Command text alone then leaves the previous browser and settle state attached to a new process. Run ids expose that transition; observing a transferred Workspace for the first time does not imply a restart. Clearing hints at the command-start event, in stream order, also avoids deleting a new serve emitted before the next poll. + +Renderer swaps and Workspace transfers can give a Tool a browser session name other than the serving hook's default. Reopening that existing session preserves its browser state and avoids orphaning it behind a second daemon. + +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. + +A Workspace transfer carries the live browser binding separately from its durable record. The arrival record can reach disk while the windows coordinate, whereas the content channel stays in memory; reusing the saved-record projection alone would reopen a Tool browser and lose its current page state. Pending approvals and unfinished browser startup still own asynchronous work in the source window, so the move waits for the user to resolve the approval or retry after startup. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 7272b30c5..3ddf1fa17 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 44ae61ddd..da1e27b49 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. @@ -88,6 +90,10 @@ Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.t The pane body paints `--color-terminal-bg` on the React pane wrapper and the `TerminalPane` mount point; the persistent xterm host element, `.xterm-screen`, and the xterm scroll container also carry the concrete background from `getTerminalTheme()`. **The host background must match the terminal screen exactly** and clip to the pane's shared rounded bottom corners (rationale). Source of truth: `lib/src/components/wall/TerminalPanel.tsx`, `lib/src/components/TerminalPane.tsx`. +**Must share scroll-safe pane messages across iframe status, Tool approval, and port conflicts**, wrapping long content and keeping all controls reachable in small panes. Center content only when it fits. + +Source of truth: `PaneMessage` in `lib/src/components/design.tsx`. Visual regression cases: `lib/src/stories/ToolApproval.stories.tsx`. + ### Spoken-alarm overlay A terminal Session with transient speech-delivery state gets a pointer-transparent overlay spanning its whole Lath leaf; browser surfaces never render it. It resolves through the tiling engine's per-leaf overlay slot (`docs/specs/tiling-engine.md`) and **must never intercept pointer/focus routing or change leaf geometry**. @@ -141,7 +147,7 @@ Source of truth: `lib/src/components/Baseboard.tsx`, `lib/src/components/Door.ts ## Workspaces -Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). Standalone mounts one Wall **per Workspace**; VS Code and the website playground mount a bare Wall with no Workspace id, which behaves exactly as a single-Workspace Window (VS Code's per-webview mapping is `docs/specs/vscode.md`). +Each Wall renders one Workspace's Content and Baseboard. Standalone mounts one Wall **per Workspace**; VS Code and the website playground mount a bare Wall with no Workspace id, which behaves exactly as a single-Workspace Window (VS Code's per-webview mapping is `docs/specs/vscode.md`). - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `visibility:hidden` (plus `inert`) and never `display:none` (rationale). - **Must switch by flipping `active` alone**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`WorkspaceWindow.test.tsx`). @@ -159,11 +165,13 @@ Sessions and its notes with it, and killing nothing on the way (`docs/specs/standalone.md` → Transfer). Leaving is not a close and arriving is not a create: a Workspace that arrives mounts from the record it brought. **Must confirm before a move that would destroy an iframe's page state**: a -plain iframe Surface's document cannot leave its webview, so it reopens at its +plain iframe or serving iframe Tool's document cannot leave its webview, so it reopens at its saved URL, and a Workspace holding one — Doored ones included — asks with the Close's typed confirmation before it leaves; agent-browser Surfaces reconnect and ask nothing (`iframeSurfaceIds` on the Wall handle; `workspace-drag.test.ts`). +**Must show drag refusals over Window content in a dialog** until dismissal, retry, or Workspace departure. Source of truth: `onDropOnOtherWindow` in `standalone/src/workspace-drag.ts`; `lib/src/components/WorkspaceStrip.test.tsx`. + **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, with a kill-confirm letter over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **A Workspace whose Wall has not registered is refused** (`workspace '' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers `workspace:` refs with it only where they are positional (`docs/specs/dor-cli.md` → "Handle Model"); **a press inside the open rename editor never starts a reorder**. **Must drop the closing Workspace’s rename editor and pending confirmation, and no other’s** (`releases the rename lease when the tab being renamed is middle-clicked closed` in `lib/src/components/WorkspaceStrip.test.tsx`; `preserves another Workspace’s rename and close confirmation when closing a sibling` in `lib/src/components/wall/workspace-lifecycle.test.ts`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. **Must use `WorkspaceKillConfirm` for Workspace close, the iframe move gate, @@ -238,7 +246,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, forwarded mouse reports. Not counted: replay-shaped terminal reports and mouse reports removed by an override. 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, forwarded mouse reports. Not counted: replay-shaped terminal reports and mouse reports removed by an override. 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`; `wireXtermHandlers` in `lib/src/lib/terminal-lifecycle.ts` (untouched input gate). @@ -350,7 +358,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 0fb23faa2..0b5a79684 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 8e0e3a3c6..1737b77f7 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -62,19 +62,22 @@ Source of truth: `extractRichRuns` and `captureRichSelection` in `lib/src/lib/no A pin is the runtime link from a captured note back to the scrollback it came from. - **Pin an ordinary Session's normal-buffer capture with two xterm markers plus the normalized endpoint columns and the raw text.** Markers ride the buffer as it scrolls; the columns and text rebuild and prove the range. -- Clicking a pin runs five steps: close the notepad; reattach a minimized Surface; resolve both markers and rebuild the range from their current lines and the stored columns; read it back and compare **exactly** with the captured raw text; on success scroll it into view and restore the Dormouse selection, outline and finalized popup included, plus the selection baseline a drag would leave — render-tick invalidation applies to a restored selection as to a dragged one. +- **Must prove a pin before opening Tool context.** Close the notepad, reattach a minimized Surface, rebuild the range from markers and columns, and compare it exactly with the captured raw text. On success open context, prove the displayed range again, then scroll and restore the Dormouse selection, popup, and render-invalidation baseline. - **Column restoration after a resize is best effort**; the raw-text equality is what prevents navigating to the wrong output. Trimmed scrollback is discovered only when a pin is used. - **While the alternate buffer is active a pin is temporarily unavailable and kept** — the markers belong to the normal buffer and resolve again once the program exits; the notepad says to exit it. -- **Every other pin failure removes the pin and keeps the note.** Disposed markers, rows out of range, and a text mismatch all report that the source is no longer available, the notepad kept or reopened to say so. +- **Must retain a pin that resolves before opening context but fails after its synchronous refit**, reporting that the layout changed without selecting stale coordinates. This does not remap wrapped text; subsequent attempts use ordinary proof and failure rules (rationale). +- **Every other pin failure removes the pin and keeps the note.** Disposed markers, rows out of range, and a text mismatch report that the source is unavailable; the notepad stays or reopens with the notice. - **Disposing or replacing a terminal instance drops its pins immediately**, notes untouched — a marker belongs to one xterm instance. - **Must drop source pins when a Workspace moves between windows**, keeping the notes (rationale). - **Pins never affect ordering and are not user-controlled favorites.** -Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealResolvedSource` in `lib/src/lib/notepad/source-link.ts`; `revealNoteSource` in `lib/src/lib/notepad/pin.ts`; `setTerminalSelectionBaseline` in `lib/src/lib/terminal-store.ts`; `dropSourcesForTerminal` in `lib/src/lib/notepad/notepad-store.ts`, called from `disposeSession` in `lib/src/lib/terminal-lifecycle.ts`. +Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealResolvedSource` in `lib/src/lib/notepad/source-link.ts`; `revealNoteSource` in `lib/src/lib/notepad/pin.ts` (tested in `lib/src/lib/notepad/pin.test.ts`); `setTerminalSelectionBaseline` in `lib/src/lib/terminal-store.ts`; `dropSourcesForTerminal` in `lib/src/lib/notepad/notepad-store.ts`, called from `disposeSession` in `lib/src/lib/terminal-lifecycle.ts`. ## 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/notepad.rationale.md b/docs/specs/notepad.rationale.md index efa4dcf52..80c736a3e 100644 --- a/docs/specs/notepad.rationale.md +++ b/docs/specs/notepad.rationale.md @@ -84,11 +84,14 @@ scrollback, a program that overwrote the rows — into one honest outcome instea scrolling the user to plausible-looking wrong output. It is why column restoration is allowed to be best effort at all. -Failure removes the pin rather than leaving it to fail again. A pin the user can -see is one that resolved the last time it was asked, which is a more useful promise -than a button that sometimes apologizes. - -The alternate buffer is the one failure that is not about the capture. A +An initial proof failure removes the pin rather than leaving it to fail again. +Opening Tool context is different: its narrower grid can change wrapping after +the same pin has just passed its proof. A second proof prevents stale-coordinate +selection, while keeping the link avoids treating our own layout change as lost +content. This bounded exception does not reconstruct columns or soft-wrap proof; +trying again while the grid still differs can fail the ordinary initial check. + +The alternate buffer also fails for a reason unrelated to the capture. A full-screen program covers the normal buffer rather than rewriting it, so the markers stay live and the range is still there underneath; the earlier code let the out-of-range rows fall through to the same removal as a dead pin, which meant diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 1fac8c03e..3ba3a4e8f 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -199,3 +199,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 f0616c566..82de2e71c 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -104,4 +104,3 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag - `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers - `lib/src/components/wall/agent-browser-surface-controller.ts` — browser key forwarding and the edit-chord bridge - diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 4b4a9566a..221f95dee 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -595,6 +595,8 @@ Source of truth: `prepareWorkspaceTransfer` in `lib/src/components/wall/workspace-transfer.ts`, `standalone/src/workspace-move.ts`, `transfer_workspace` in `standalone/src-tauri/src/lib.rs`. +Tool transfer follows `docs/specs/dor-tool.md` → Persistence and hosts. + Live Activity and alarm delivery follow `docs/specs/alert.md` → Live Workspace transfer. ### Tear-out @@ -612,7 +614,7 @@ source's invoke until the target adopts the Workspace or dies, and every step below reads that record rather than inferring itself from the suppression map. 1. **Source** prepares the Workspace, touching nothing, and invokes - `transfer_workspace` / `open_workspace_window`. On `Ok` it marks the Workspace + `transfer_workspace` / `open_workspace_window`. **Must return preparation refusals as `{ moved: false, reason }` without changing ownership.** On `Ok` it marks the Workspace **transferring**: the Wall stays mounted and the notes stay put, nothing is released, and `getWindowSnapshot` omits it. 2. **Rust** reassigns `terminalIds` to the target, keeps routing their output to @@ -656,7 +658,7 @@ below reads that record rather than inferring itself from the suppression map. - **A refused `adopt_done` unwinds the mount.** The `ARRIVAL_MAX` watchdog has already handed the shells back and the source kept the Workspace, so the target releases its Sessions (never kills them), drops the notes, and closes - the Workspace rather than leaving it live and persisted in two windows. + the Workspace rather than leaving it live and persisted in two windows. **Must unwind from the received payload without preparing another move.** - **Must remove unmounted semantic and alert state when arrival collection times out.** Source of truth: `planArrival` in `standalone/src/workspace-move.ts`. - **A refused arrival hands the shells back.** The target's `adopt_failed` diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index c46079d1c..ced363faa 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -108,6 +108,8 @@ ends at `adopt_failed` or at the target's `Destroyed`, not at a timer. ## Arrival queue +A target whose `adopt_done` is refused already has the arrival payload needed to release its Sessions. Preparing a new transfer first re-entered Tool startup checks and could throw while ownership was already back at the source; unwinding directly also avoids sending `adopt_failed` for that retired arrival. + **Why the mark is stamped in the stream rather than asked for.** A mark fetched by request answers at some instant the sidecar chose, while the source's xterm stands at whatever `pty:data` had reached it — two clocks nothing aligns, so a diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index 77ab7a466..1bf8ce3f0 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 preflight a note source pin before opening Tool context, then mount and refit context before resolving its displayed selection.** Failed refit resolution follows `docs/specs/notepad.md` → Source links. + +**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: `revealNoteSource` in `lib/src/lib/notepad/pin.ts`; the `dormouse:reveal-note-source` listener in `lib/src/components/Wall.tsx`; `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 1d6d6f04e..8a7a6b532 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -61,6 +61,8 @@ Replay (`pty:replay`) is the raw stream requiring re-parse: **the webview runs a | `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 81a7b0aca..0dab27ba3 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -168,6 +168,7 @@ with these transfer rules: serializer. Pinned by `preserves mouse tracking and encoding %i through real xterm parsing` in `lib/src/lib/terminal-transfer.test.ts` and `drains replay before adopting even when no note has a pin` in `standalone/src/workspace-move.test.ts`. +- Tool browser bindings and announcements follow `docs/specs/dor-tool.md` → Persistence and hosts. - Live Activity and delivery handoff follows `docs/specs/alert.md` → Live Workspace transfer. - Semantic-state transfer follows `docs/specs/terminal-state.md` → Core Model. - Source-pin limitations belong to `docs/specs/notepad.md` → Source links. @@ -213,7 +214,7 @@ Transport constraints: | Host → webview | `pty:openPorts` | `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. | | Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. | | Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. | -| Host → webview | `terminal:protocolEvents` | Standalone only: notification and progress events for the webview's `AlertManager`. VS Code holds its own in the extension host, so it needs no message. | +| Host → webview | `terminal:protocolEvents` | Standalone notification/progress delivery and ordered Tool announcements/resets (`docs/specs/dor-tool.md` → OSC 367). VS Code keeps its `AlertManager` in the extension host. | | Webview → host | `dormouse:themeColors` (VS Code) / `pty_theme_colors` (standalone) | Resolved foreground / background / cursor, so the owner's parser can answer OSC 10/11/12. | | Host → webview | `pty:replay` | Buffered raw output since spawn; the webview runs a one-shot parser over it, the only re-parse there is. | | Host → webview | `dormouse:newTerminal` | May carry `shell`, `args`, display `name`, `replaceUntouched`, `announce`. The webview replaces the selected untouched terminal in place only when `replaceUntouched` is true, otherwise spawns a new pane. | @@ -368,3 +369,9 @@ Source of truth: `openPortRequestTimeoutMs` in `lib/src/lib/platform/types.ts`; **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 b49fbe9ab..40fdfb16a 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 { workspaceCommand } from './commands/workspace.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; @@ -80,12 +81,15 @@ export type { SurfacePort, SurfaceRenderMode, SurfaceView, + ToolSurfaceRequest, + ToolSurfaceResponse, VersionMetadata, } from './commands/types.js'; const COMMANDS = [ splitCommand, ensureCommand, + toolCommand, versionCommand, skillCommand, sendCommand, @@ -101,6 +105,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/ensure.ts b/dor/src/commands/ensure.ts index 8ba7d717b..66c67ec28 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -13,6 +13,7 @@ import { errorMessage, renderJson, requireControlClient, + scanPreDelimiterArgs, stringParser, workspaceFlag, workspaceParam, @@ -38,35 +39,27 @@ const RESTART_TIMEOUT_MS = 60_000; // surfaces still respond instantly; this only raises the ceiling for the slow case. const ENSURE_TIMEOUT_MS = 20_000; +// Keep in sync with `parameters.flags`. +const BOOLEAN_FLAGS = new Set(['--json', '--minimize', '--restart']); +const FLAGS_WITH_VALUES = new Set(['--cwd', '--surface', '--workspace']); + // stricli treats a bare `--` as "rest are positionals", but it can't express // "everything after `--` is one required command tail, and only these flags may // precede it". `cli.ts` runs this before stricli parses so the argv-tail contract -// (and its friendly messages) is enforced next to the flag definitions above — -// keep the allowed-flag list here in sync with `parameters.flags`. +// (and its friendly messages) is enforced next to the flag definitions above. export function validateEnsureDelimiter(args: string[]): ParseResult<void> { const delimiterIndex = args.indexOf('--'); if (delimiterIndex === -1) { return { ok: false, message: 'dor ensure requires -- <command...>' }; } - for (let index = 0; index < delimiterIndex; index += 1) { - const arg = args[index]; - if (arg === '--json' || arg === '--minimize' || arg === '--restart') { - continue; - } - if (arg === '--cwd' || arg === '--surface' || arg === '--workspace') { - const value = args[index + 1]; - if (!value || value.startsWith('-') || index + 1 >= delimiterIndex) { - return { ok: false, message: `${arg} requires a value` }; - } - index += 1; - continue; - } - if (arg.startsWith('-')) { - return { ok: false, message: `unknown option '${arg}'` }; - } - return { ok: false, message: `unexpected argument '${arg}' before --` }; - } + // ensure has no positional of its own: everything it runs comes after `--`. + const scan = scanPreDelimiterArgs(args.slice(0, delimiterIndex), { + booleans: BOOLEAN_FLAGS, + valued: FLAGS_WITH_VALUES, + positionals: 'reject', + }); + if (!scan.ok) return scan; const command = args.slice(delimiterIndex + 1).join(' ').trim(); if (!command) { diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index 4701b41c1..1335e965f 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -163,7 +163,7 @@ function buildListCommand(): Command['command'] { docs: { brief: 'List Dormouse Surfaces.', customUsage: [ - '[--workspace ref|--all] [--window label] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]', + `[--workspace ref|--all] [--window label] [--kind ${SURFACE_KINDS.join('|')}] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]`, '--workspaces [--window label] [--json]', ], fullDescription: FULL_DESCRIPTION, diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index dd921b042..f978b42b9 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -57,6 +57,39 @@ export function parseIdFormat(value: string): IdFormat { throw new SyntaxError(`invalid --id-format '${value}'`); } +export interface PreDelimiterArgSpec { + /** Flags taking no value. */ + readonly booleans: ReadonlySet<string>; + /** Flags consuming the next argument. */ + readonly valued: ReadonlySet<string>; + /** `'reject'` fails on the first non-flag argument; `'collect'` returns them all. */ + readonly positionals: 'collect' | 'reject'; +} + +/** + * The argv-head shape check `dor ensure` and `dor tool` share. stricli cannot + * express "only these flags may precede `--`", so `cli.ts` runs this before it + * parses; each command supplies its own flag sets and keeps them beside its + * `parameters.flags`. + */ +export function scanPreDelimiterArgs(head: readonly string[], spec: PreDelimiterArgSpec): ParseResult<string[]> { + const positionals: string[] = []; + for (let index = 0; index < head.length; index += 1) { + const arg = head[index]; + if (spec.booleans.has(arg)) continue; + if (spec.valued.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}'` }; + if (spec.positionals === 'reject') return { ok: false, message: `unexpected argument '${arg}' before --` }; + positionals.push(arg); + } + return { ok: true, value: positionals }; +} + function resolveControlClient(options: CliOptions, timeoutMs?: number): ParseResult<ControlClient> { if (options.client) return { ok: true, value: options.client }; diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts new file mode 100644 index 000000000..5d3dce285 --- /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, + scanPreDelimiterArgs, + stringParser, + workspaceFlag, + workspaceParam, + writeStderr, + writeStdout, +} from './shared.js'; + +interface ToolFlags { + readonly json?: boolean; + readonly minimize?: boolean; + readonly fresh?: boolean; + readonly surface?: string; + readonly cwd?: string; + readonly workspace?: 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; + +// Keep in sync with `parameters.flags`. +const FLAGS_WITH_VALUES = new Set(['--cwd', '--surface', '--workspace']); +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. + */ +export function validateToolArgs(args: string[]): ParseResult<void> { + const delimiterIndex = args.indexOf('--'); + // A positional is the tool name when there is no `--` and an error when there + // is, so the scan collects them and each form judges them below. + const scan = scanPreDelimiterArgs(delimiterIndex === -1 ? args : args.slice(0, delimiterIndex), { + booleans: BOOLEAN_FLAGS, + valued: FLAGS_WITH_VALUES, + positionals: 'collect', + }); + if (!scan.ok) return scan; + const positionals = scan.value; + + 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] [--workspace ref]<TO-EOL>', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref] <name>\n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>...\n', + ], + }, + { + scope: 'command-usage', + findReplace: [ + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref]<TO-EOL>', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref] <name>\n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] [--workspace ref] -- <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' }, + workspace: workspaceFlag, + 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 }), + ...workspaceParam(flags.workspace), + 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 f526d788f..53b3c2000 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 @@ -243,6 +246,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 extends WorkspaceScopedRequest { + /** 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 extends WorkspaceScopedRequest { surface: string; input: string; @@ -395,6 +439,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 d06875052..e6ae99191 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -32,6 +32,8 @@ import type { SendSurfaceResponse, SplitSurfaceRequest, SplitSurfaceResponse, + ToolSurfaceRequest, + ToolSurfaceResponse, } from './commands/types.js'; import { SURFACE_CONTROL_METHODS, WORKSPACE_CONTROL_METHODS, type DorControlMethod } from './protocol.js'; import type { DorControlResult } from './protocol.js'; @@ -107,6 +109,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 55d7b1e5b..38ae8008f 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 8c6085071..8624b064c 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -177,6 +177,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 @@ -1668,3 +1689,104 @@ 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() })); +}); + +test('tool routes named and anonymous launches to an explicit Workspace', async () => { + for (const tail of [['storybook'], ['--', 'pnpm', 'dev']]) { + const client = fixtureClient(); + const result = await runCli(['tool', '--workspace', 'workspace:2', ...tail], { client, env: { PWD: '/work/site' } }); + assert.equal(result.exitCode, 0); + assert.equal(client.requests[0].method, 'toolSurface'); + assert.equal(client.requests[0].request.workspace, 'workspace:2'); + } +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index e2c2aa5c8..ca68c3415 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -6,6 +6,7 @@ Invocation: `dor --help` USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [--workspace ref] [-- <command>...] dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>... + dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] <name>... dor version [--json] dor skill [--install] [--json] dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref] @@ -14,7 +15,7 @@ USAGE dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] [--workspace ref] dor iframe [--json] [--minimize] [--surface id|ref] [--workspace ref] <target> dor agent-browser [--key name|--session name|--surface handle] [--workspace ref] [args...] - dor list [--all] [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] [--workspace ref] [--workspaces] [--window label] + dor list [--all] [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser|tool] [--port number] [--ports] [--view paned|zoomed|minimized] [--workspace ref] [--workspaces] [--window label] dor workspace new|rename|close|switch|move [args...] [flags...] dor --help @@ -27,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 af97a12f2..7ad89f368 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 [--workspace ref|--all] [--window label] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] + dor list [--workspace ref|--all] [--window label] [--kind terminal|browser|tool] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] dor list --workspaces [--window label] [--json] dor list --help diff --git a/dor/test/snapshots/help/tool.md b/dor/test/snapshots/help/tool.md new file mode 100644 index 000000000..9d08d0d4e --- /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] [--workspace ref] [--cwd path] <name>... + 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. + [--workspace] Workspace to act in, instead of the caller's. + [--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..3bc631469 --- /dev/null +++ b/dormouse.yml @@ -0,0 +1,26 @@ +# 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 innerdogfood + # 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 bridge and Vite each bind an OS-assigned port. Announce Vite's actual + # port via OSC 367 so Dormouse selects the UI from that pair of listeners. + port: announced + prespawn_dedupe: [standalone-harness, $PROJECT_ROOT] diff --git a/lib/package.json b/lib/package.json index 9d15a7a64..71aa855e6 100644 --- a/lib/package.json +++ b/lib/package.json @@ -37,6 +37,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/NoteList.tsx b/lib/src/components/NoteList.tsx index e79a8a612..0a00cbfda 100644 --- a/lib/src/components/NoteList.tsx +++ b/lib/src/components/NoteList.tsx @@ -11,24 +11,22 @@ import type { LiveNote, RichTextRun } from '../lib/notepad/types'; const COPY_FLASH_MS = 700; /** What a note's row says about a pin that just refused to resolve - * (docs/specs/notepad.md → Source links). `alternate-buffer` is the one - * failure that keeps the pin, so its row invites a retry. */ + * (docs/specs/notepad.md → Source links). */ export interface SourceNotice { noteId: string; - kind: 'unavailable' | 'alternate-buffer'; + kind: 'unavailable' | 'alternate-buffer' | 'layout-changed'; } const SOURCE_NOTICE_TEXT: Record<SourceNotice['kind'], string> = { unavailable: 'Source no longer available', 'alternate-buffer': 'Exit the full-screen program to show this source', + 'layout-changed': 'Opening the terminal changed its layout. Source link kept, but the range could not be shown.', }; -/** The notice a pin's outcome earns, or `null` when it resolved and there is - * nothing to say. A kept pin is the retryable one, so the kind follows - * `outcome.kept` rather than the reason. */ +/** The notice a pin's outcome earns, or `null` when it resolved. */ export function sourceNoticeFor(noteId: string, outcome: PinOutcome): SourceNotice | null { if (outcome.ok) return null; - return { noteId, kind: outcome.kept ? 'alternate-buffer' : 'unavailable' }; + return { noteId, kind: outcome.reason === 'alternate-buffer' || outcome.reason === 'layout-changed' ? outcome.reason : 'unavailable' }; } export interface NoteListProps { diff --git a/lib/src/components/NotepadPanel.test.tsx b/lib/src/components/NotepadPanel.test.tsx index eb550b919..12fb8d88d 100644 --- a/lib/src/components/NotepadPanel.test.tsx +++ b/lib/src/components/NotepadPanel.test.tsx @@ -628,6 +628,31 @@ describe('NotepadPanel — source pins', () => { // The markers outlive the program, so the pin is still there to retry. expect(container.querySelector('[aria-label="Show source"]')).not.toBeNull(); }); + + it('keeps the pin and explains when opening the terminal changed its layout', () => { + const marker = { line: 0, isDisposed: false, dispose: vi.fn() }; + const source = { ...deadSource(), startMarker: marker, endMarker: marker, startColumn: 0, endColumn: 3, expectedRawText: 'gone' }; + addTerminalNote(SURFACE, [{ text: 'gone' }], source); + let text = 'gone'; + registry.set(SURFACE, { + terminal: { + cols: 4, + buffer: { active: { type: 'normal', length: 1, baseY: 0, getLine: () => ({ translateToString: () => text }) } }, + }, + } as unknown as TerminalEntry); + const reflow = () => { text = 'go'; }; + window.addEventListener('dormouse:reveal-note-source', reflow); + try { + renderPanels(); + open(); + click(container.querySelector('[aria-label="Show source"]')); + expect(getOpenNotepadId()).toBe(SURFACE); + expect(panel()!.textContent).toContain('Opening the terminal changed its layout. Source link kept'); + expect(panel()!.textContent).not.toContain('Exit the full-screen program'); + expect(container.querySelector('[aria-label="Show source"]')).not.toBeNull(); + expect(marker.dispose).not.toHaveBeenCalled(); + } finally { window.removeEventListener('dormouse:reveal-note-source', reflow); } + }); }); describe('applyPlainEdit', () => { diff --git a/lib/src/components/TerminalPane.test.tsx b/lib/src/components/TerminalPane.test.tsx index 2e4dd4112..618d8a77a 100644 --- a/lib/src/components/TerminalPane.test.tsx +++ b/lib/src/components/TerminalPane.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TerminalPane } from './TerminalPane'; +import { focusSession } from '../lib/terminal-registry'; import { LathHost } from './wall/LathHost'; import { createLathWallEngine, terminalLeafMeta, type LathWallEngine } from './wall/lath-wall-engine'; import { createLathWallStore, type LathWallStore } from './wall/lath-wall-store'; @@ -316,3 +317,11 @@ describe('a hidden Workspace minimizes its terminals', () => { expect(registry.resizes.map(e => e.id).sort()).toEqual(['a', 'b']); }); }); + +// A retiring Tool iframe may still own the Surface focus handle this commit. +it('targets xterm when the terminal becomes focused again', () => { + act(() => root.render(<TerminalPane id="tool" isFocused={false} />)); + vi.mocked(focusSession).mockClear(); + act(() => root.render(<TerminalPane id="tool" isFocused />)); + expect(focusSession).toHaveBeenCalledWith('tool', true, 'terminal'); +}); diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 451ef186e..dd5f04629 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -72,7 +72,9 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { }, [id, resize, workspaceActive]); useEffect(() => { - focusSession(id, isFocused); + // A Tool's browser can retain its focus handle until its exit effect runs. + // This mount owns the terminal capability, including during that transition. + focusSession(id, isFocused, 'terminal'); }, [id, isFocused]); return ( diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 85af4d6d3..d5cdc4272 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'; import { getWallHandle, listWallHandles } from './wall/wall-handles'; @@ -1164,6 +1166,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 () => { @@ -1360,6 +1448,706 @@ describe('Wall on the Lath engine', () => { } }); + it.each([false, true])('serializes a newly created Tool until startup, including completion before waiting (%s)', async finishesBeforeWait => { + setToolsEnabled(true); + const controller = new AbortController(); + const command = 'pnpm storybook'; + let id: string | undefined; + const toolControl = vi.fn(async (request: { name?: string }) => request.name === 'probe' + ? { status: 'error', message: 'probe reached lookup' } + : { status: 'ok', name: 'storybook', projectRoot: '/repo', path: '/repo/dormouse.yml', run: command, render: 'iframe', port: 'announced', key: ['/repo'], warnings: [] }); + Object.assign(fake, { toolControl }); + vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); + const write = vi.spyOn(fake, 'writePty'); + const reportStart = (toolId: string) => { + terminalRegistry.seedTerminalManualCwd(toolId, '/repo'); + terminalRegistry.applyTerminalSemanticEvents(toolId, [{ type: 'commandLine', commandLine: command }, { type: 'commandStart', source: 'osc633_boundaries' }]); + }; + try { + await act(async () => root.render(<Wall initialPaneIds={['pane-a']} />)); + await flush(); + const first = vi.fn((response: { result?: { surfaceId: string } }) => { + id = response.result?.surfaceId; + if (finishesBeforeWait && id) { + reportStart(id); + terminalRegistry.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }, { type: 'promptStart' }]); + } + }); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { name: 'storybook', cwd: '/repo' }, signal: controller.signal, respond: first, + } }))); + await flush(); + expect(first).toHaveBeenCalledWith(expect.objectContaining({ result: expect.objectContaining({ status: 'created' }) })); + const second = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { name: finishesBeforeWait ? 'probe' : 'storybook', cwd: '/repo' }, signal: controller.signal, respond: second, + } }))); + if (!finishesBeforeWait) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); + expect(toolControl).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + act(() => reportStart(id!)); + } + await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); + expect(second).toHaveBeenCalledWith(finishesBeforeWait ? { ok: false, error: 'probe reached lookup' } + : expect.objectContaining({ result: expect.objectContaining({ status: 'existing', surfaceId: id }) })); + expect(toolControl).toHaveBeenCalledTimes(2); + expect(write).not.toHaveBeenCalled(); + expect(leafCount()).toBe(2); + } finally { + await act(async () => { controller.abort(); await new Promise(resolve => setTimeout(resolve, 125)); }); + if (id) { + pendingShellOpts.delete(id); + act(() => terminalRegistry.removeTerminalPaneState(id!)); + } + setToolsEnabled(false); + } + }); + + it.each([ + { fresh: false, archiveFails: false, idle: false }, + { fresh: true, archiveFails: false, idle: false }, + { fresh: false, archiveFails: false, idle: true }, + { fresh: false, archiveFails: true, idle: true }, + ])('serializes approval key reuse and preserves fresh/notes: %j', async ({ fresh, archiveFails, idle }) => { + setToolsEnabled(true); + const ids: string[] = []; + const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; + const idleState = createTerminalPaneState({ cwd }); + const runningState = createTerminalPaneState({ cwd, currentCommand: { + id: 'run-tool', rawCommandLine: 'pnpm storybook', displayCommand: 'pnpm storybook', + cwdAtStart: cwd, startedAt: 0, source: 'osc633_E', + } }); + let firstState = idleState; + vi.spyOn(terminalRegistry, 'getTerminalPaneState').mockImplementation(id => + id === ids[0] ? firstState : ids.includes(id) ? runningState : idleState); + vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); + const write = vi.spyOn(fake, 'writePty').mockImplementation((id, data) => { + if (id === ids[0] && data === 'pnpm storybook\r') firstState = runningState; + }); + let trusted = false; + const toolControl = vi.fn(async (request: { op: 'lookup' | 'trust' }) => { + const config = { projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook' }; + if (request.op === 'trust') { trusted = true; return { status: 'trust-recorded' as const }; } + return trusted + ? { ...config, status: 'ok' as const, render: 'iframe' as const, port: 'announced' as const, key: ['/repo'], warnings: [] } + : { ...config, status: 'untrusted' as const, 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(); + for (const launchCwd of ['/repo', fresh ? '/repo' : '/repo/subdir']) { + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: launchCwd, fresh }, respond, + } }))); + ids.push(respond.mock.calls[0][0].result.surfaceId); + } + expect(ids[0]).not.toBe(ids[1]); + act(() => addPlainNote(ids[1], 'keep this approval note')); + if (archiveFails) vi.spyOn(fake.notepadArchive, 'save').mockRejectedValue(new Error('disk full')); + const approvals = Array.from(container.querySelectorAll('button')).filter(button => button.textContent?.includes('Always allow for folder')); + vi.useFakeTimers(); + await act(async () => { + approvals[0].click(); + if (!idle) approvals[1].click(); + }); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(1); + expect(pendingShellOpts.has(ids[0])).toBe(true); + expect(pendingShellOpts.has(ids[1])).toBe(false); + const queuedLaunch = vi.fn(); + if (!fresh && !idle) { + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond: queuedLaunch, + } }))); + expect(queuedLaunch).not.toHaveBeenCalled(); + } + firstState = runningState; + await act(async () => vi.advanceTimersByTimeAsync(100)); + if (idle) { + firstState = idleState; + await act(async () => approvals[1].click()); + } + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(2); + if (!fresh && !idle) expect(queuedLaunch).toHaveBeenCalledWith(expect.objectContaining({ + ok: true, result: expect.objectContaining({ status: 'existing', surfaceId: ids[0] }), + })); + expect(pendingShellOpts.has(ids[1])).toBe(fresh); + expect(write.mock.calls.filter(([, data]) => data === 'pnpm storybook\r')).toHaveLength(idle && !archiveFails ? 1 : 0); + if (fresh || archiveFails) { + expect(container.querySelector(`[data-lath-leaf="${ids[1]}"]`)).not.toBeNull(); + expect(getNotes(ids[1])).toHaveLength(1); + if (archiveFails) expect(container.querySelector('[aria-labelledby="notepad-archive-failure-title"]')).not.toBeNull(); + } else { + expect(container.querySelector(`[data-lath-leaf="${ids[1]}"]`)).toBeNull(); + const archive = (await fake.notepadArchive.load())?.raw as NotepadArchiveV1; + expect(archive.batches.flatMap(batch => batch.notes)).toEqual([expect.objectContaining({ content: { kind: 'plain', text: 'keep this approval note' } })]); + } + } finally { + vi.useRealTimers(); + ids.forEach(id => pendingShellOpts.delete(id)); + setToolsEnabled(false); + } + }); + + it('moves the selection ring from a terminal to a pending Tool approval', async () => { + setToolsEnabled(true); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const left = this.dataset.lathLeaf === 'pane-a' ? 100 : 500; + return { x: left, y: 40, left, top: 40, right: left + 300, bottom: 240, width: 300, height: 200, toJSON() {} }; + }); + (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, + })); + const ring = () => container.querySelector('[data-ring="outline"]')?.closest('svg')?.parentElement; + try { + await act(async () => { root.render(<Wall initialPaneIds={['pane-a']} initialMode="command" />); }); + await flush(); + expect(ring()?.style.left).toBe('96px'); + 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: vi.fn(), + }, + })); + }); + await flush(); + expect(container.textContent).toContain('Always allow for folder'); + const approvalHeader = container.querySelector<HTMLElement>('[data-lath-leaf]:not([data-lath-leaf="pane-a"]) .lath-leaf-header > div'); + expect(approvalHeader).not.toBeNull(); + await act(async () => { approvalHeader!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + await flush(); + expect(ring()?.style.left).toBe('496px'); + } finally { + setToolsEnabled(false); + } + }); + + it.each(['', ' \t\n'])('shows a useful fallback for a blank grant failure (%j)', async message => { + setToolsEnabled(true); + const toolControl = vi.fn(async (request: { op: string }) => request.op === 'trust' + ? { status: 'error', message } + : { status: 'untrusted', projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }); + Object.assign(fake, { toolControl }); + try { + await act(async () => root.render(<Wall initialPaneIds={['pane-a']} />)); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, + } }))); + const id = respond.mock.calls[0][0].result.surfaceId; + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + expect(container.querySelector('[role="alert"]')?.textContent).toBe('The Tool permission could not be saved. Try allowing it again.'); + expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); + } finally { + setToolsEnabled(false); + } + }); + + it.each(['read error', 'unknown tool'])('retains a failed post-grant lookup with retry and quiet stale completion (%s)', async failure => { + setToolsEnabled(true); + const untrusted = { status: 'untrusted', projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }; + const failed = failure === 'read error' + ? { status: 'error', message: 'configuration temporarily unreadable' } + : { status: 'unknown-tool', projectRoot: '/repo', path: '/repo/dormouse.yml', names: [] }; + const toolControl = vi.fn(async (request: { op: string }) => request.op === 'trust' + ? { status: 'trust-recorded' } : toolControl.mock.calls.length === 1 ? untrusted : failed); + Object.assign(fake, { toolControl }); + try { + await act(async () => root.render(<Wall initialPaneIds={['pane-a']} />)); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, + } }))); + const id = respond.mock.calls[0][0].result.surfaceId; + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + await flush(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe(failure === 'read error' + ? 'configuration temporarily unreadable' : 'This Tool could not be resolved. Check dormouse.yml and try allowing it again.'); + expect(container.querySelector(`[data-lath-leaf="${id}"]`)).not.toBeNull(); + expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); + expect(pendingShellOpts.has(id)).toBe(false); + + const retry = Promise.withResolvers<typeof failed>(); + toolControl.mockImplementation(async request => request.op === 'trust' ? { status: 'trust-recorded' } : retry.promise); + await act(async () => allow.click()); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(3); + const decline = [...container.querySelectorAll('button')].find(button => button.textContent === 'Disallow and close')!; + await act(async () => decline.click()); + await flush(); + await act(async () => retry.resolve(failed)); + await flush(); + expect(container.querySelector(`[data-lath-leaf="${id}"]`)).toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(pendingShellOpts.has(id)).toBe(false); + } finally { + setToolsEnabled(false); + } + }); + + it.each([true, false])('keeps a tool deferred until trust succeeds (%s), lookup and shell staging finish', async grantSucceeds => { + setToolsEnabled(true); + let toolId: string | undefined; + vi.spyOn(terminalRegistry, 'getTerminalPaneState').mockImplementation(id => { + const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; + return createTerminalPaneState({ cwd, currentCommand: id === toolId ? { + id: 'approved-run', rawCommandLine: 'pnpm storybook', displayCommand: 'pnpm storybook', + cwdAtStart: cwd, startedAt: 0, source: 'osc633_E', + } : null }); + }); + const trustGate = Promise.withResolvers<{ status: 'trust-recorded' } | { status: 'error'; message: string }>(); + 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(grantSucceeds ? { status: 'trust-recorded' } : { status: 'error', message: 'grant could not be saved' }); }); + await flush(); + expect(container.querySelector(`[data-session-id="${toolId}"]`)).toBeNull(); + if (!grantSucceeds) { + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); + expect(pendingShellOpts.has(toolId)).toBe(false); + expect(container.querySelector('[role="alert"]')?.textContent).toBe('grant could not be saved'); + // A hidden Workspace can miss a transient notice. Its approval error + // remains when shown again, well past the old notice interval. + container.hidden = true; + vi.useFakeTimers(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + vi.useRealTimers(); + container.hidden = false; + expect(container.querySelector('[role="alert"]')?.textContent).toBe('grant could not be saved'); + + const retryGate = Promise.withResolvers<{ status: 'error'; message: string }>(); + toolControl.mockImplementation(() => retryGate.promise); + await act(async () => { allow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelector(`[data-session-id="${toolId}"]`)).toBeNull(); + + await act(async () => { retryGate.resolve({ status: 'error', message: 'retry grant failed' }); }); + expect(container.querySelector('[role="alert"]')?.textContent).toBe('retry grant failed'); + + const staleGate = Promise.withResolvers<{ status: 'error'; message: string }>(); + toolControl.mockImplementation(() => staleGate.promise); + await act(async () => { allow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(container.querySelector('[role="alert"]')).toBeNull(); + const decline = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent === 'Disallow and close'); + await act(async () => { decline!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + await act(async () => { staleGate.resolve({ status: 'error', message: 'late rejected grant' }); }); + await flush(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).toBeNull(); + expect(container.textContent).not.toContain('late rejected grant'); + expect(pendingShellOpts.has(toolId)).toBe(false); + return; + } + + 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(false); + } finally { + vi.useRealTimers(); + if (toolId) pendingShellOpts.delete(toolId); + setToolsEnabled(false); + } + }); + + it('starts an approved tool before applying its deferred minimize', async () => { + setToolsEnabled(true); + let toolId: string | undefined; + vi.spyOn(terminalRegistry, 'getTerminalPaneState').mockImplementation(id => { + const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; + return createTerminalPaneState({ cwd, currentCommand: id === toolId ? { + id: 'approved-run', rawCommandLine: 'pnpm storybook', displayCommand: 'pnpm storybook', + cwdAtStart: cwd, startedAt: 0, source: 'osc633_E', + } : null }); + }); + 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: false }); + 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 @@ -1995,7 +2783,10 @@ describe('Wall on the Lath engine', () => { await clickButton('Close anyway'); 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([]); }); @@ -2029,7 +2820,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 () => { @@ -2256,3 +3050,43 @@ describe('Wall session persistence: ownership filtering', () => { } }); }); + + +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' }]); + let mountedDuringRefit = false; + const refit = vi.spyOn(terminalRegistry, 'refitSession').mockImplementation(() => { + mountedDuringRefit = container.querySelector('[data-context-terminal="tool-context"] [data-session-id="tool-context"]') !== null; + }); + act(() => { + window.dispatchEvent(new CustomEvent('dormouse:reveal-note-source', { detail: { surfaceId: 'tool-context' } })); + // Pin resolution follows synchronously, before the React event returns. + expect(refit).toHaveBeenCalledExactlyOnceWith('tool-context'); + expect(mountedDuringRefit).toBe(true); + }); + +}); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 60cd2e04e..1cebc543d 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1,3 +1,5 @@ +import { captureToolParams } from './wall/tool-transfer'; +import { isWorkspaceTransferPending } from '../lib/window-session-aggregator'; import { TerminalContextContext, type TerminalContextOpenOptions, type TerminalContextState } from './wall/wall-context'; import { TERMINAL_CONTEXT_EXIT_MS } from './design'; import { motionIsInstant } from '../lib/ui-geometry'; @@ -6,6 +8,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 +38,7 @@ import { disposeSession, dismissOrToggleAlert, focusSession, + refitSession, markSessionAttention, toggleSessionTodo, setPendingShellOpts, @@ -81,6 +85,7 @@ import { browserUrlFromParams, isBrowserParams, surfaceKindFromParams, + isToolParams, namespacedToolKey, toolKeysEqual, toolPendingFromParams, } from './wall/browser-surface'; import { browserSurfaceUrl, hostPathDisplay } from './wall/browser-url'; import { WorkspaceSelectionOverlay } from './wall/WorkspaceSelectionOverlay'; @@ -94,12 +99,14 @@ 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'; import { useDevServerPortCorrelation } from './wall/use-dev-server-ports'; import { useAlertSpeech } from './wall/use-alert-speech'; -import { useDorControl } from './wall/use-dor-control'; +import { queueToolSpawn, restartSurfaceInPlace, useDorControl, waitForNewToolCommand } from './wall/use-dor-control'; import { useWindowFocused } from './wall/use-window-focused'; import { DialogKeyboardContext, @@ -248,10 +255,6 @@ function ShellSpawnNotice({ // --- Main component --- -/** A blank shell may be replaced in place; one that owns a helper is not blank - * (docs/specs/terminal-context.md → Helper lifecycle). */ -const isReplaceableShell = (id: string): boolean => isUntouched(id) && !getHelper(id); - export function Wall({ initialPaneIds, initialMode = 'command', @@ -316,6 +319,15 @@ export function Wall({ const lathRef = useRef<LathWallEngine | null>(null); if (lathRef.current === null) lathRef.current = createLathWallEngine(); const lath = lathRef.current; + /** An untouched *shell* — the only thing the kill-without-confirm and + * replace-in-place shortcuts may take. A Tool is never one: input to either + * of its capabilities is input to the Tool (docs/specs/dor-tool.md → The + * tool capability set). */ + const isUntouchedShell = (id: string): boolean => + isUntouched(id) && !isToolParams(lath.getMeta(id)?.params); + /** A blank shell may be replaced in place; one that owns a helper is not blank + * (docs/specs/terminal-context.md → Helper lifecycle). */ + const isReplaceableShell = (id: string): boolean => isUntouchedShell(id) && !getHelper(id); const restoredLathLayoutRef = useRef(restoredLathLayout); const dorSurfaceRefsRef = useRef<Map<string, string> | null>(null); const nextDorSurfaceRefIndexRef = useRef(1); @@ -426,6 +438,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); @@ -730,13 +746,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: isUntouchedShell(id) ? '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 (isUntouchedShell(id)) { void closeSurface(id); return; } setConfirmKill({ id, char: randomKillChar() }); }; if (!getHelper(id)) { stage(); return; } @@ -946,7 +962,8 @@ export function Wall({ const iframeSurfaceIds = useCallback( (): string[] => memberSurfaceIds().filter((id) => { const params = lath.getMeta(id)?.params; - return isBrowserParams(params) && resolveRenderMode(params) === 'iframe'; + return (isBrowserParams(params) || (isToolParams(params) && browserUrlFromParams(params) !== null)) + && resolveRenderMode(params) === 'iframe'; }), [lath, memberSurfaceIds], ); @@ -1157,14 +1174,19 @@ export function Wall({ * a port row is an explicit request to look at and control that browser. A visible pane * enters passthrough in place; a minimized one reattaches on the same terms * as clicking its Door chip. This is deliberately unlike `dor ab`, whose - * agent-initiated control path remains focus-neutral. */ - const revealSurface = useCallback((id: string) => { + * agent-initiated control path remains focus-neutral. + * + * Returns whether the Surface ended up visible, so `dor` can report the + * outcome without re-querying: the reattach commits to the store before it + * returns, and `nav.hasPane` reads the store, not React state. */ + const revealSurface = useCallback((id: string): boolean => { if (nav.hasPane(id)) { enterTerminalMode(id); - return; + return true; } const door = doorsRef.current.find((item) => item.id === id); if (door) handleReattachRef.current(door); + return nav.hasPane(id); }, [nav, enterTerminalMode]); // The Surfaces of the current Workspace. `buildDorSurfaces` is the visible-pane @@ -1251,6 +1273,8 @@ export function Wall({ cwd, requireIntegration, focusNeutral, + leafMeta, + deferTerminal, }: { command?: string; direction: DorResolvedSplitDirection; @@ -1258,11 +1282,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; @@ -1284,7 +1316,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 @@ -1295,6 +1330,10 @@ export function Wall({ shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd, + // Starting the command is Dormouse orchestration, not user input, but + // a commanded split keeps the conservative state anyway: a Tool is + // excluded from the untouched shortcuts by kind, not by this flag + // (docs/specs/dor-tool.md → The tool capability set). untouched: false, command, ...(requireIntegration ? { requireIntegration: true } : {}), @@ -1319,11 +1358,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', @@ -1338,7 +1377,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', @@ -1346,7 +1385,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 } }; @@ -1529,6 +1568,9 @@ export function Wall({ }, [generatePaneId, surfaceRefForId, forgetSurfaceRef, selectPane, enterTerminalMode, showShellSpawnNotice, lath, nav]); // --- dor control plane (the `dor` CLI's webview handler) --- + // A tool grows its browser when its command starts serving. + useToolServing({ lath, doorsRef, paused: useCallback(() => closingWorkspaceRef.current || isWorkspaceTransferPending(effectiveWorkspaceId), [effectiveWorkspaceId]) }); + const { findSurfaceByParams, updateSurfaceParams, handleDorControl } = useDorControl({ lath, nav, @@ -1540,6 +1582,7 @@ export function Wall({ createContentSurface, isClosingSurface, closeSurface, + revealSurface, isClosingWorkspace: useCallback(() => closingWorkspaceRef.current, []), lastAgentBrowserBinaryPathRef, workspaceRef: useCallback(() => workspaceRefFor(effectiveWorkspaceId), [effectiveWorkspaceId]), @@ -1548,6 +1591,116 @@ export function Wall({ workspaceScope: useCallback(() => workspaceId, [workspaceId]), }); + // 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 initialPending = toolPendingFromParams(meta?.params); + if (!initialPending || closingWorkspaceRef.current || isWorkspaceTransferPending(effectiveWorkspaceId)) return; + let pending = initialPending; + 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); + + const isCurrent = () => !closingWorkspaceRef.current && !isWorkspaceTransferPending(effectiveWorkspaceId) + && toolPendingFromParams(lath.getMeta(id)?.params) === pending && !lath.isDying(id) && !isSurfaceClosing(id); + try { + await queueToolSpawn(async () => { + if (!isCurrent()) return; + if (pending.error !== undefined) { + pending = { ...pending, error: undefined }; + lath.store.updateParams(id, { toolPending: pending }); + } + const platform = getPlatform(); + const grant = await platform.toolControl?.({ + op: 'trust', + kind: choice, + projectRoot: pending.projectRoot, + }); + if (!isCurrent()) return; + if (grant?.status !== 'trust-recorded') { + lath.store.updateParams(id, { toolPending: { + ...pending, + error: grant?.status === 'error' && grant.message.trim() ? grant.message : 'The Tool permission could not be saved. Try allowing it again.', + } }); + return; + } + + // 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 (!isCurrent()) return; + if (resolved?.status !== 'ok') { + lath.store.updateParams(id, { toolPending: { + ...pending, + error: resolved?.status === 'error' && resolved.message.trim() ? resolved.message : 'This Tool could not be resolved. Check dormouse.yml and try allowing it again.', + } }); + return; + } + + if (!isCurrent()) return; + const key = namespacedToolKey(resolved.name, resolved.key); + const match = !pending.fresh && key ? findSurfaceByParams(candidate => + toolKeysEqual((candidate as { toolKey?: unknown } | undefined)?.toolKey, key)) : null; + if (match) { + // Closing can fail while archiving notes. Retain the pending pane and + // do not restart the matching command unless that closure succeeds. + if (await closeSurface(id)) return; + if (closingWorkspaceRef.current || isWorkspaceTransferPending(effectiveWorkspaceId) + || !lath.getMeta(match.id) || lath.isDying(match.id) || isSurfaceClosing(match.id)) return; + const state = getTerminalPaneState(match.id); + if (state.currentCommand === null) { + const matchedCommand = lath.getMeta(match.id)?.params?.command; + const restarted = await restartSurfaceInPlace(match.id, + typeof matchedCommand === 'string' ? matchedCommand : resolved.run, state.cwd?.path ?? cwd); + if (!restarted.ok) showShellSpawnNotice(match.id, restarted.message); + } + revealSurface(match.id); + return; + } + lath.store.updateParams(id, { + command: resolved.run, + toolRender: resolved.render, + toolPort: resolved.port, + ...(key ? { toolKey: 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: false, + 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); + } + await waitForNewToolCommand(id, resolved.run, cwd); + }); + } finally { + toolApprovalsInFlightRef.current.delete(id); + } + }, [lath, closeSurface, minimizePane, effectiveWorkspaceId, findSurfaceByParams, revealSurface, showShellSpawnNotice]); + // --- Workspace handle --- /** Put DOM focus on — or off — this Wall's selection, honoring its own mode: @@ -1567,8 +1720,9 @@ export function Wall({ iframeSurfaceIds, hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { // A browser Surface has no "untouched" notion and always holds a page, so - // it counts; a terminal counts once its Session exists and has input. - if (!surfaceHasTerminal(id)) return true; + // it counts; so does a Tool, before its terminal exists to be asked. A + // terminal counts once its Session exists and has input. + if (!surfaceHasTerminal(id) || isToolParams(lath.getMeta(id)?.params)) return true; return getTerminalInstance(id) !== null && !isReplaceableShell(id); }), runningCount: () => countRunningSessionsIn(memberSurfaceIds()), @@ -1580,6 +1734,7 @@ export function Wall({ serialize: persistence.serialize, surfaceIds: memberSurfaceIds, hasTerminal: surfaceHasTerminal, + captureTools: () => captureToolParams(lath, memberSurfaceIds()), }), closeAll, cancelClose, @@ -1645,6 +1800,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(() => ({ @@ -1730,12 +1899,43 @@ 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') { // Canonical params.url (mirrored from the chrome snapshot) first; fall // back to the live snapshot for a surface that hasn't reported a tab yet. - const url = (typeof params?.url === 'string' && params.url) || getAgentBrowserScreenController(id)?.chrome().url; + const url = browserUrlFromParams(params) || getAgentBrowserScreenController(id)?.chrome().url; if (!url) { console.warn(`[dormouse] cannot swap surface '${id}' to iframe: no URL observed yet`); return; @@ -1760,7 +1960,7 @@ export function Wall({ if (currentRenderMode === 'iframe' && (mode === 'ab-screencast' || mode === 'ab-popout')) { const chromeUrl = getAgentBrowserScreenController(id)?.chrome().url; const rawUrl = (typeof chromeUrl === 'string' && chromeUrl) - || (typeof params?.url === 'string' ? params.url : ''); + || (browserUrlFromParams(params) ?? ''); // The swap is the second sink params.url reaches: IframePanel refuses a // non-http(s) source but still holds it, and this path would hand it to // a real Chromium tab — which `dor ab open` refuses at the CLI @@ -1844,7 +2044,10 @@ export function Wall({ }); }, resolveSurfaceRef: surfaceRefForId, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, requestKill, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, updateSurfaceParams, lath, nav]); + 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(); @@ -1887,7 +2090,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/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx index f98c415b1..147bfbd49 100644 --- a/lib/src/components/WorkspaceStrip.test.tsx +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -18,6 +18,7 @@ import { resetWorkspaceUi, setPendingWorkspaceClose, setPendingWorkspaceMove, + setWorkspaceMoveError, } from '../lib/workspace-ui-store'; import { createWorkspace, @@ -446,3 +447,17 @@ describe('WorkspaceStrip', () => { expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); }); }); + + +it('keeps a move refusal visible and releases the keyboard when dismissed', async () => { + await render(); + await act(async () => setWorkspaceMoveError({ id: 'ws-1', reason: 'Wait for the Tool browser to connect before moving this Workspace' })); + expect(document.querySelector('[role="alert"]')?.textContent).toContain('Wait for the Tool browser'); + expect(chromeKeyboardHeld()).toBe(true); + await act(async () => { + (document.querySelector('[role="dialog"] button') as HTMLButtonElement).click(); + }); + expect(document.querySelector('[role="alert"]')).toBeNull(); + expect(getWorkspaceUiSnapshot().moveError).toBeNull(); + expect(chromeKeyboardHeld()).toBe(false); +}); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index 55f827401..0b78f35b2 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -15,7 +15,7 @@ import { AlertBell } from './AlertBell'; import { InlineEditInput } from './wall/InlineEditInput'; import { WorkspaceKillConfirm } from './WorkspaceKillConfirm'; import { useTodoPillContent } from './TodoPillBody'; -import { chromeButton, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; +import { chromeButton, ModalFrame, modalActionButton, OVERLAY_MAX_HEIGHT, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; import { createWorkspaceStripDrag, type StripDragHost } from './workspace-strip-drag'; import { acquireChromeKeyboardLease } from './wall/chrome-keyboard-lease'; import { useDialogKeyboardOwner } from './wall/wall-context'; @@ -28,6 +28,7 @@ import { getWorkspaceUiSnapshot, setPendingWorkspaceClose, setPendingWorkspaceMove, + setWorkspaceMoveError, setRenamingWorkspace, subscribeToWorkspaceUi, } from '../lib/workspace-ui-store'; @@ -67,7 +68,7 @@ export function WorkspaceStrip({ const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot); const membership = useSyncExternalStore(subscribeToWorkspaceSurfaces, getWorkspaceSurfacesSnapshot); const activity = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); - const { renamingId, pendingClose, pendingMove } = useSyncExternalStore(subscribeToWorkspaceUi, getWorkspaceUiSnapshot); + const { renamingId, pendingClose, pendingMove, moveError } = useSyncExternalStore(subscribeToWorkspaceUi, getWorkspaceUiSnapshot); const [draggingId, setDraggingId] = useState<WorkspaceId | null>(null); const stripRef = useRef<HTMLDivElement>(null); @@ -75,7 +76,7 @@ export function WorkspaceStrip({ // The editor and the confirmation both sit outside every Wall, so a // capture-phase command-mode shortcut would still fire behind them. - useDialogKeyboardOwner(renamingId !== null || pendingClose !== null || pendingMove !== null, acquireChromeKeyboardLease); + useDialogKeyboardOwner(renamingId !== null || pendingClose !== null || pendingMove !== null || moveError !== null, acquireChromeKeyboardLease); const activate = useCallback((id: WorkspaceId) => { setActiveWorkspace(id); @@ -130,8 +131,8 @@ export function WorkspaceStrip({ // confirmation lands in the same place whichever Workspace it is about. No // Window (Storybook) leaves it viewport-centered. const confirmTarget = useMemo( - () => (pendingClose || pendingMove ? document.querySelector<HTMLElement>('[data-workspace-content]') : null), - [pendingClose, pendingMove], + () => (pendingClose || pendingMove || moveError ? document.querySelector<HTMLElement>('[data-workspace-content]') : null), + [pendingClose, pendingMove, moveError], ); // Cues observe the active Workspace too, so switching tabs cannot create one. @@ -201,6 +202,14 @@ export function WorkspaceStrip({ The move gate is the same typed letter (the page state it destroys is as gone as a killed pane's process) and waits behind a close, so only one gate ever listens for the letter on screen. */} + {moveError && !pendingClose && !pendingMove && !renamingId && ( + <ModalFrame titleId="workspace-move-error" targetElement={confirmTarget} + onEscape={() => setWorkspaceMoveError(null)} className={clsx("w-80 max-w-full overflow-auto text-sm", OVERLAY_MAX_HEIGHT.modal)}> + <h2 id="workspace-move-error" className="mb-2 font-semibold">Workspace could not move</h2> + <p role="alert" className="mb-3 break-words">{moveError.reason}</p> + <button type="button" className={modalActionButton()} onClick={() => setWorkspaceMoveError(null)}>Close</button> + </ModalFrame> + )} {pendingClose && !renamingId && ( <WorkspaceKillConfirm char={pendingClose.char} diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index dca2dab19..b21b2c337 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -1,3 +1,4 @@ +import { setToolsEnabled } from '../lib/feature-flags'; /** * @vitest-environment jsdom * @@ -302,7 +303,7 @@ describe('WorkspaceWindow', () => { expect(leafIdsIn(survivors[0].id)).toHaveLength(1); }); - it('refuses a Surface-creating dor request while its Workspace is closing', async () => { + it.each([SURFACE_CONTROL_METHODS.split, SURFACE_CONTROL_METHODS.tool])('refuses %s while its Workspace is closing', async (method) => { await render(); await act(async () => { createWorkspace({ id: 'ws-2' }); }); await flush(); @@ -316,7 +317,7 @@ describe('WorkspaceWindow', () => { const closing = handle.closeAll('silent'); handle.handleDorControl({ requestId: 'r1', - method: SURFACE_CONTROL_METHODS.split, + method, surfaceId: paneId, params: { direction: 'right' }, respond, @@ -434,3 +435,39 @@ describe('WorkspaceWindow', () => { expect(handle.runningCount()).toBe(0); }); }); + + +it('routes Tools to the requested Workspace and never launches after lookup races closure', async () => { + setToolsEnabled(true); + const lookup = { status: 'untrusted' as const, projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }; + const gate = Promise.withResolvers<typeof lookup>(); + const toolControl = vi.fn().mockResolvedValueOnce(lookup).mockImplementationOnce(() => gate.promise); + Object.assign(fake, { toolControl }); + try { + await render(); + const first = getActiveWorkspaceId(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + requestId: 'tool-route', surfaceId: 'pane-a', method: SURFACE_CONTROL_METHODS.tool, + params: { workspace: 'workspace:2', name: 'storybook', cwd: '/repo' }, respond, + } }))); + await flush(); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'pending' }) })); + expect(leafIdsIn(first)).toEqual(['pane-a']); + expect(leafIdsIn('ws-2')).toHaveLength(2); + expect(getActiveWorkspaceId()).toBe('ws-2'); + + const handle = getWallHandle('ws-2')!; + const late = vi.fn(); + act(() => handle.handleDorControl({ requestId: 'late-tool', method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo' }, respond: late })); + await flush(); + await act(async () => { await handle.closeAll('discard'); }); + await act(async () => gate.resolve(lookup)); + await flush(); + expect(late).toHaveBeenCalledWith({ ok: false, error: 'this workspace is closing' }); + expect(handle.surfaceIds()).toEqual([]); + } finally { setToolsEnabled(false); } +}); diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 5048a88c0..461e96120 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -86,6 +86,28 @@ 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'; +// Message-only panes use the terminal ground because they stand in for a +// Surface, not chrome. PaneMessage pairs this scrollable root with content that +// stays centered when it fits and fully reachable when the pane is small. +const PANE_MESSAGE_CLASS = 'flex h-full min-h-0 w-full min-w-0 flex-col overflow-auto bg-terminal-bg px-6 py-6 text-center text-sm'; + +export function PaneMessage({ + children, + className, + contentClassName, + ...props +}: ComponentProps<'div'> & { contentClassName?: string }) { + return ( + <div {...props} className={clsx(PANE_MESSAGE_CLASS, className)}> + {/* Auto margins center only spare space; overflowing content starts at the + scroll origin instead of being centered beyond its reachable bounds. */} + <div className={clsx('my-auto w-full min-w-0 max-w-[30rem] shrink-0 self-center [overflow-wrap:anywhere]', contentClassName)}> + {children} + </div> + </div> + ); +} + // `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 407a338e8..a1a41d174 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -824,3 +824,28 @@ 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); + }); +}); diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index bc05f89cd..a89630636 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -255,7 +255,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r }; canvas.addEventListener('wheel', onWheel, { passive: false }); return () => canvas.removeEventListener('wheel', onWheel); - }, [controller, toDevice]); + }, [controller, id, toDevice]); const onKeyDown = (e: React.KeyboardEvent) => { if (!interactiveRef.current) return; @@ -289,7 +289,7 @@ 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; @@ -303,7 +303,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]); // 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). 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 43ba65c47..ee7191865 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -277,3 +277,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 a7e39a628..0a59bdf67 100644 --- a/lib/src/components/wall/IframePanel.tsx +++ b/lib/src/components/wall/IframePanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; +import { PaneMessage, TERMINAL_BOTTOM_RADIUS_CLASS } from '../design'; import { getPlatform } from '../../lib/platform'; import { registerProxyOrigin } from '../../lib/iframe-proxy-registry'; import { registerSurfaceFocusHandle } from '../../lib/terminal-registry'; @@ -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 @@ -105,6 +106,7 @@ export function IframePanel({ id, title, params }: PaneProps) { // URL has no normalized form, so it survives raw and the refusal below fires. const framedUrl = browserSurfaceUrl(rawUrl); const sourceUrl = framedUrl ?? rawUrl; + 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"). @@ -257,12 +259,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(() => { @@ -420,13 +425,11 @@ 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'; - if (resolution.kind === 'resolving') { - return <div className={base}>Connecting to <span className="ml-1 font-semibold">{url}</span>…</div>; + return <PaneMessage className="text-muted">Connecting to <span className="ml-1 font-semibold">{url}</span>…</PaneMessage>; } if (resolution.kind === 'empty') { - return <div className={base}>No iframe URL was provided.</div>; + return <PaneMessage className="text-muted">No iframe URL was provided.</PaneMessage>; } // proxied/raw render the iframe itself, never this fallback. if (resolution.kind !== 'error') return null; @@ -437,14 +440,14 @@ function PanelMessage({ resolution, url }: { resolution: Resolution; url: string // can't front it. It refuses a non-http(s) target too (`normalizeConcreteOpenUrl`), // so pointing a refused scheme at it would be a dead end. return ( - <div className={`${base} flex-col gap-2`}> + <PaneMessage className="text-muted" contentClassName="flex flex-col gap-2"> <div>{messageFor(resolution)}</div> <div className="text-xs text-muted/80"> {resolution.reason === 'non-http' ? 'Enter an http:// or https:// address in the URL bar above.' : <>For arbitrary web pages, use <code className="rounded bg-app-bg px-1 py-0.5">dor ab open {url}</code></>} </div> - </div> + </PaneMessage> ); } diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index 318c37ff9..5590b5388 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -28,6 +28,9 @@ 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 { isToolParams } from './browser-surface'; +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..ab2f74efe --- /dev/null +++ b/lib/src/components/wall/ToolApproval.tsx @@ -0,0 +1,74 @@ +/** + * 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 { useRef } from 'react'; +import { usePaneChrome } from './use-pane-chrome'; +import { PaneMessage, 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 elRef = useRef<HTMLDivElement>(null); + usePaneChrome(id, elRef); + const pending = toolPendingFromParams(params); + if (!pending) return null; + + return ( + <PaneMessage ref={elRef} contentClassName="flex 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> + + {pending.error ? <div role="alert" className="text-error">{pending.error}</div> : null} + + <div className="flex 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="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> + </PaneMessage> + ); +} 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..c0f5f724f --- /dev/null +++ b/lib/src/components/wall/ToolPanel.test.tsx @@ -0,0 +1,156 @@ +// @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', () => { + show(serving); + 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(); + }); +}); + +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); + }); +}); diff --git a/lib/src/components/wall/ToolPanel.tsx b/lib/src/components/wall/ToolPanel.tsx new file mode 100644 index 000000000..f9dbdea39 --- /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) => 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..38ea8cde9 --- /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 { PaneMessage } from '../design'; +import { toolPortConflictFromParams } from './browser-surface'; +import type { PaneProps } from './pane-props'; + +export function ToolPortConflict({ params }: PaneProps) { + const ports = toolPortConflictFromParams(params) ?? []; + + return ( + <PaneMessage className="text-muted" contentClassName="flex flex-col gap-3"> + <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> + </PaneMessage> + ); +} 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..36799ebaf 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,117 @@ 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): params is Record<string, unknown> { + 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; + /** Preserve the launch request across the trust gate. */ + readonly fresh?: boolean; + readonly upstreamUrl: string | null; + readonly error?: string; +} + +/** 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.fresh !== undefined && typeof pending.fresh !== 'boolean') return null; + if (pending.error !== undefined && typeof pending.error !== 'string') 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. */ +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 +174,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 c31b56098..8b8f4dc27 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -9,7 +9,7 @@ import type { WorkspaceId } from '../../../lib/session-types'; 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 47273cf32..0003f7bd0 100644 --- a/lib/src/components/wall/lath-wall-engine.ts +++ b/lib/src/components/wall/lath-wall-engine.ts @@ -91,6 +91,43 @@ export function browserLeafMeta(title: string, params: Record<string, unknown>): return { component: 'browser', tabComponent: 'surface', title, params }; } +/** Meta for a `tool` leaf — one Session with a terminal and, once it serves, a + * browser (`docs/specs/dor-tool.md`). Its own tab component, because the + * header follows whichever half is forward. */ +export function toolLeafMeta(title: string, params: Record<string, unknown>): LeafMeta { + return { component: 'tool', tabComponent: 'tool', title, params }; +} + +/** + * A tool's browser is derived, never restored: its port is whatever the command + * bound *this* run, so a persisted `url` would frame a dead address — and a + * persisted agent-browser `session` would name a daemon that is gone + * (`docs/specs/dor-tool.md` -> Persistence and hosts). A restored tool is a + * terminal running its command until it serves again, which is the same state a + * cold spawn passes through. Everything else about the leaf persists. + */ +export function persistableLeafMeta(meta: LeafMeta): LeafMeta { + if (meta.component !== 'tool' || !meta.params) return meta; + // A tool still awaiting approval persists as a plain empty terminal. Keeping + // it a tool would restore a pane that spawns a shell in a repo nobody + // approved, with no gesture at all — and the prompt cannot be restored either, + // since the grant it was asking for was never made + // (`docs/specs/dor-tool.md` -> Trust rule 3). + if (meta.params.toolPending !== undefined) { + return { component: 'terminal', tabComponent: 'terminal', title: meta.title }; + } + const { + url: _url, + session: _session, + wsPort: _wsPort, + renderMode: _renderMode, + toolPortConflict: _toolPortConflict, + toolAnnouncedPort: _toolAnnouncedPort, + ...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 +143,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 +252,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..94c1b8b12 --- /dev/null +++ b/lib/src/components/wall/tool-browser-session.ts @@ -0,0 +1,49 @@ +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'>; + +/** + * Open `url` in `session` and hand `surfaceId` the resulting `{session, wsPort}` + * 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, + surfaceId, + binaryPath, + refreshSurface, +}: { + url: string; + platform: ConnectPlatform; + session: string; + surfaceId: string; + binaryPath?: string; + refreshSurface: (surfaceId: string, patch: Record<string, unknown>) => void; +}): Promise<void> { + if (!platform.agentBrowserCommand) return; + // '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; + } + // 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 } : {}), + }); +} 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..d166f5fb7 --- /dev/null +++ b/lib/src/components/wall/tool-surface.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { hasBrowser, hasTerminal } from 'dor/commands/types'; +import { + isBrowserParams, + isToolParams, + namespacedToolKey, + resolveRenderMode, + surfaceKindFromParams, + toolFace, + toolKeysEqual, + toolPendingFromParams, +} from './browser-surface'; +import { persistableLeafMeta, shouldParkOnMinimize, toolLeafMeta } from './lath-wall-engine'; +import { TOOLS_FLAG_KEY, isToolsEnabled, setToolsEnabled } from '../../lib/feature-flags'; + +const booting = { surfaceType: 'tool', command: 'pnpm storybook', cwd: '/repo' }; +const serving = { ...booting, url: 'http://localhost:6006/', renderMode: 'iframe' }; + +describe('tool params classification', () => { + it('classifies a tool as its own kind, before and after it serves', () => { + expect(surfaceKindFromParams(booting)).toBe('tool'); + expect(surfaceKindFromParams(serving)).toBe('tool'); + }); + + it('never classifies a serving tool as a browser, despite its renderMode', () => { + // The ordering that matters: `isBrowserParams` matches anything carrying a + // renderMode, so the tool test has to come first. + expect(isToolParams(serving)).toBe(true); + expect(isBrowserParams(serving)).toBe(false); + }); + + it('leaves plain terminals and browsers where they were', () => { + expect(surfaceKindFromParams(undefined)).toBe('terminal'); + expect(surfaceKindFromParams({ cwd: '/repo' })).toBe('terminal'); + expect(surfaceKindFromParams({ surfaceType: 'browser', url: 'https://x' })).toBe('browser'); + expect(surfaceKindFromParams({ renderMode: 'ab-screencast' })).toBe('browser'); + }); + + it('reports both capabilities, so row fields populate on both sides', () => { + const kind = surfaceKindFromParams(serving); + expect(hasTerminal(kind)).toBe(true); + expect(hasBrowser(kind)).toBe(true); + }); +}); + +describe('which half of a tool is forward', () => { + it('shows the terminal until the tool serves', () => { + expect(toolFace(booting)).toBe('terminal'); + }); + + it('shows the browser once it serves', () => { + expect(toolFace(serving)).toBe('browser'); + }); + + it('shows the terminal 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, + }); + 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('keeps failed approval feedback in the pending shape and rejects non-text errors', () => { + expect(toolPendingFromParams({ toolPending: { ...pending, error: 'disk full' } })?.error).toBe('disk full'); + expect(toolPendingFromParams({ toolPending: { ...pending, error: 42 } })).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/tool-transfer.test.ts b/lib/src/components/wall/tool-transfer.test.ts new file mode 100644 index 000000000..71a4301e8 --- /dev/null +++ b/lib/src/components/wall/tool-transfer.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { createLathWallEngine, persistableLeafMeta, toolLeafMeta } from './lath-wall-engine'; +import { captureToolParams, restoreToolParams } from './tool-transfer'; +import type { RestoredSession } from '../../lib/session-restore'; + +const params = { + surfaceType: 'tool', command: 'pnpm storybook', toolRender: 'ab-screencast', + url: 'http://localhost:6006/edited', renderMode: 'ab-screencast', + session: 'dormouse.1.tool-one', wsPort: 9222, toolAnnouncedPort: 6006, +}; + +function engine(initial = params) { + const lath = createLathWallEngine(); + lath.store.addLeaf('tool', toolLeafMeta('Storybook', initial), null); + return lath; +} + +describe('Tool Workspace transfer', () => { + it('carries a live binding to panes and Doors without changing the durable record', () => { + const lath = engine(); + const tools = captureToolParams(lath, ['tool']); + const durable = lath.serializeLayout(); + const door = { id: 'tool', ...persistableLeafMeta(lath.getMeta('tool')!) }; + const plan: RestoredSession = { paneIds: ['tool'], lathLayout: durable, doors: [door] }; + restoreToolParams(plan, tools); + expect(plan.lathLayout!.leafMeta.tool.params).toEqual(params); + expect(plan.doors[0].params).toEqual(params); + expect(durable.leafMeta.tool.params).not.toHaveProperty('url'); + expect(durable.leafMeta.tool.params).not.toHaveProperty('toolAnnouncedPort'); + expect(door.params).not.toHaveProperty('session'); + expect(lath.getMeta('tool')!.params).toEqual(params); + }); + + it('refuses while approval or browser startup still owns work in the source', () => { + const lath = engine(); + lath.store.updateParams('tool', { session: undefined }); + expect(() => captureToolParams(lath, ['tool'])).toThrow('connect'); + lath.store.updateParams('tool', { toolPending: { + name: 'storybook', run: 'pnpm storybook', path: '/repo/dormouse.yml', + projectRoot: '/repo', minimized: false, upstreamUrl: null, + } }); + expect(() => captureToolParams(lath, ['tool'])).toThrow('Approve or decline'); + expect(lath.store.has('tool')).toBe(true); + }); + + it('never overlays a binding on a terminal or an absent Surface', () => { + const plan: RestoredSession = { paneIds: ['shell'], doors: [{ id: 'shell', title: 'Shell', component: 'terminal' }] }; + restoreToolParams(plan, { shell: params, missing: params }); + expect(plan.doors).toEqual([{ id: 'shell', title: 'Shell', component: 'terminal' }]); + }); +}); diff --git a/lib/src/components/wall/tool-transfer.ts b/lib/src/components/wall/tool-transfer.ts new file mode 100644 index 000000000..035c2e0fc --- /dev/null +++ b/lib/src/components/wall/tool-transfer.ts @@ -0,0 +1,41 @@ +import type { LathWallEngine } from './lath-wall-engine'; +import type { RestoredSession } from '../../lib/session-restore'; +import { isToolParams, toolPendingFromParams } from './browser-surface'; + +/** Live browser bindings travel only in the volatile transfer content, never + * in the saved Workspace record. Cold restore must rediscover its own port. */ +export type TransferredTools = Record<string, Record<string, unknown>>; + +export function captureToolParams(lath: LathWallEngine, ids: readonly string[]): TransferredTools { + const tools: TransferredTools = {}; + for (const id of ids) { + const params = lath.getMeta(id)?.params; + if (!params || !isToolParams(params)) continue; + // Approval and browser startup own asynchronous work in this webview. Let + // them settle before moving their UI and ownership to another one. + if (toolPendingFromParams(params)) throw new Error('Approve or decline pending Tools before moving this Workspace'); + if (params.renderMode === 'ab-screencast' && !params.session) { + throw new Error('Wait for the Tool browser to connect before moving this Workspace'); + } + tools[id] = { ...params }; + } + return tools; +} + +export function restoreToolParams(plan: Partial<RestoredSession>, tools: TransferredTools): void { + // The plan may share objects with the arrival's durable record. Copy before + // overlaying live bindings, so publishing that record cannot persist them. + if (plan.lathLayout) { + plan.lathLayout = { ...plan.lathLayout, leafMeta: { ...plan.lathLayout.leafMeta } }; + for (const [id, meta] of Object.entries(plan.lathLayout.leafMeta)) { + const params = tools[id]; + if (meta.component === 'tool' && isToolParams(params)) { + plan.lathLayout.leafMeta[id] = { ...meta, params: { ...params } }; + } + } + } + plan.doors = (plan.doors ?? []).map(door => { + const params = tools[door.id]; + return door.component === 'tool' && isToolParams(params) ? { ...door, params: { ...params } } : door; + }); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 2b0647f04..e59c53d93 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,3 +1,5 @@ +import { isWorkspaceTransferPending } from '../../lib/window-session-aggregator'; +import { createSerialQueue } from '../../host/remote/serial-queue'; import { useCallback, type MutableRefObject } from 'react'; import { sessionForKey } from 'dor-lib-common/agent-browser'; import { getPlatform, PLATFORM_STRING } from '../../lib/platform'; @@ -13,6 +15,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 { @@ -26,10 +29,19 @@ import { isAllowedAgentBrowserBinary } from '../../lib/agent-browser-binary'; import { stringParam } from './dor-control-shared'; import { attachSurfacePorts } from './surface-ports'; 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 { toolCommandFromParams } from '../../lib/session-save'; +import type { LeafMeta } from '../../lib/lath/persistence'; import type { CloseSurfaceMode, DooredItem } from './wall-types'; /** The params a Wall reads. The Window-level params (`scope`, and the container @@ -61,6 +73,8 @@ export type DorControlParams = { window?: unknown; scrollback?: unknown; wsPort?: unknown; + name?: unknown; + fresh?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -253,6 +267,13 @@ const RESTART_POLL_INTERVAL_MS = 100; const RESTART_INTERRUPT_TIMEOUT_MS = 15_000; const RESTART_START_TIMEOUT_MS = 15_000; +/** + * Serialize Tool requests and approval completions across lookup, key matching, + * creation, and startup. Module scope shares the queue across control requests + * and Walls in this renderer. + */ +export const queueToolSpawn = createSerialQueue(); + type WaitOutcome = 'ready' | 'timeout' | 'aborted'; /** Resolve once `predicate` holds for the surface's live state, the timeout @@ -284,11 +305,21 @@ function waitForTerminalState( }); } +/** A newly spawned Tool has no earlier command history. Its first command may + * finish before the caller starts waiting, so a matching completion counts too. + * Hold the launch queue through this wait; integration alone precedes injection. */ +export function waitForNewToolCommand(id: string, command: string, cwd: string, signal?: AbortSignal): Promise<WaitOutcome> { + return waitForTerminalState(id, state => surfaceRunsCommand(state, command, cwd) + || (state.lastCommand !== null && surfaceRunsCommand({ ...state, currentCommand: state.lastCommand }, command, cwd)), + RESTART_START_TIMEOUT_MS, signal); +} + const RESTART_CANCELLED: ParseResult<undefined> = { ok: false, message: 'restart was cancelled' }; /** The control verbs that can add a Surface to the Wall. `resolveOpen` and * `resolveAgentBrowser` only answer questions, and every other verb addresses a * Surface that already exists. */ const CREATING_CONTROL_METHODS = new Set<string>([ + SURFACE_CONTROL_METHODS.tool, SURFACE_CONTROL_METHODS.split, SURFACE_CONTROL_METHODS.ensure, SURFACE_CONTROL_METHODS.iframe, @@ -303,7 +334,7 @@ const ENSURE_CANCELLED = 'ensure was cancelled'; * for it to go live. Drives the live PTY directly, so it works for minimized * doors too (their PTY keeps running). Returns a message on failure. */ -async function restartSurfaceInPlace(id: string, command: string, cwd: string, signal?: AbortSignal): Promise<ParseResult<undefined>> { +export async function restartSurfaceInPlace(id: string, command: string, cwd: string, signal?: AbortSignal): Promise<ParseResult<undefined>> { // Checked before the interrupt is written, not just before each wait. if (signal?.aborted) return RESTART_CANCELLED; // A match is by construction OSC-driven (surfaceRunsCommand only matches a @@ -399,6 +430,7 @@ export function useDorControl({ isClosingSurface, isClosingWorkspace, closeSurface, + revealSurface, lastAgentBrowserBinaryPathRef, workspaceRef, workspaceScope, @@ -424,6 +456,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; @@ -440,6 +479,9 @@ 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>; + /** Reveal a Surface (reattaching a Door first) and report whether it ended up + * visible. `Wall.tsx` -> `revealSurface`. */ + revealSurface: (id: string) => boolean; /** The last binary path a `dor ab` surface resolved on a terminal's PATH. */ lastAgentBrowserBinaryPathRef: MutableRefObject<string | undefined>; /** This Wall's own positional Workspace ref, reported by `dor list` so a caller @@ -718,6 +760,283 @@ 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. + await queueToolSpawn(async () => { + // Lookup and the launch lock can outlive the Workspace's close gesture. + const unavailable = () => { + const scope = workspaceScope(); + const error = detail.signal?.aborted ? 'tool launch cancelled' + : scope && isWorkspaceTransferPending(scope) ? 'this workspace is transferring' + : isClosingWorkspace() ? 'this workspace is closing' : null; + if (error) detail.respond({ ok: false, error }); + return error !== null; + }; + if (unavailable()) return; + // 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; + /** Approval, and a spawn, 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. + * Responds and returns true when it refuses. */ + const refuseCmdShell = (): boolean => { + if (!toolShell || shellCommandKind(toolShell, PLATFORM_STRING) !== 'cmd') return false; + detail.respond({ ok: false, error: missingIntegrationError(toolShell) }); + return true; + }; + + 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 }); + if (unavailable()) return; + 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': { + if (refuseCmdShell()) 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 + && (candidate as { cwd?: unknown }).cwd === cwd + && Boolean(waiting.fresh) === booleanParam(params.fresh); + }; + const respondPending = (id: string, ref: string, minimized: boolean) => detail.respond({ + ok: true, + result: { status: 'pending', surfaceId: id, surfaceRef: ref, command: lookup.run, cwd, minimized, key: null }, + }); + const already = booleanParam(params.fresh) ? null : findSurfaceByParams(matchesPending); + if (already) { + respondPending(already.id, surfaceRefForId(already.id), !revealSurface(already.id)); + 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), + fresh: booleanParam(params.fresh), + 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. + const stillMinimized = pending.value.minimized && !revealSurface(pending.value.id); + respondPending(pending.value.id, pending.value.ref, stillMinimized); + 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, detail.signal); + 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. + const revealed = revealSurface(match.id); + detail.respond({ + ok: true, + result: { + status: idle ? 'adopted' : 'existing', + surfaceId: match.id, + surfaceRef: surfaceRefForId(match.id), + command: matchedCommand, + cwd, + minimized: !revealed, + 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 (refuseCmdShell()) 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 } : {}), + }, + }); + await waitForNewToolCommand(created.value.id, command, cwd, detail.signal); + }); + return; + } + if (detail.method === SURFACE_CONTROL_METHODS.ensure) { if (detail.signal?.aborted) { detail.respond({ ok: false, error: ENSURE_CANCELLED }); @@ -1132,7 +1451,7 @@ export function useDorControl({ } detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, isClosingWorkspace, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, workspaceScope]); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, findSurfaceByParams, revealSurface, isClosingWorkspace, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, workspaceScope]); return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/use-pane-chrome.ts b/lib/src/components/wall/use-pane-chrome.ts index c6aec4178..a4b6169c6 100644 --- a/lib/src/components/wall/use-pane-chrome.ts +++ b/lib/src/components/wall/use-pane-chrome.ts @@ -2,8 +2,7 @@ import { useContext, useEffect, type RefObject } from 'react'; import { PaneElementsContext } from './wall-context'; /** - * Shared surface-pane boilerplate used by every panel component - * (terminal / iframe / agent-browser): registers the pane's root element in + * Registers a Surface body's root element in * `PaneElementsContext` so overlays (the selection ring, kill overlay, * shell-spawn notice) can measure it, and unregisters on unmount. */ diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index e0933bcde..e9d816e9b 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -12,6 +12,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, PersistedSession, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; @@ -90,24 +91,37 @@ export function useSessionPersistence({ }; }, [workspaceId]); - /** The pane + Door projection every save and serialization is built from. 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 collect = useCallback(() => { - 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..b64b5f8f5 --- /dev/null +++ b/lib/src/components/wall/use-tool-serving.test.tsx @@ -0,0 +1,393 @@ +// @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 { setToolsEnabled } from '../../lib/feature-flags'; +import { recordToolAnnounce, recordToolAnnounces, resetToolAnnounces } from '../../lib/tool-announce-store'; +import { TerminalProtocolParser } from '../../lib/terminal-protocol'; +import { useToolServing } from './use-tool-serving'; +import { captureToolParams } from './tool-transfer'; +import { createLathWallEngine, toolLeafMeta } 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 }; +} + +/** A real engine holding one tool leaf, the way the Wall builds one. */ +function toolEngine(params: Record<string, unknown>) { + const lath = createLathWallEngine(); + lath.store.addLeaf('tool-1', toolLeafMeta('Tool', params), null); + const updateParams = vi.spyOn(lath.store, 'updateParams'); + const state = { + get params() { return (lath.getMeta('tool-1')?.params ?? {}) as Record<string, unknown>; }, + /** Write params the way anything outside the hook does — a URL-bar + * navigation, a restore — since the store owns them. */ + set(patch: Record<string, unknown>) { lath.store.updateParams('tool-1', patch); }, + }; + return { lath, state, updateParams }; +} + +let container: HTMLDivElement; +let root: Root; +let currentCommand: string | null = 'x'; +let runId = 0; + +vi.mock('../../lib/terminal-registry', () => ({ + getTerminalPaneState: () => ({ currentCommand: currentCommand === null ? null : { id: `${runId}-${currentCommand}`, rawCommandLine: currentCommand } }), +})); + +beforeEach(() => { + vi.useFakeTimers(); + setToolsEnabled(true); + resetToolAnnounces(); + currentCommand = 'x'; + runId = 0; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + setToolsEnabled(false); + 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 } = toolEngine(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 { lath, state, updateParams, platform }; +} + +describe('port: announced', () => { + const announced = { surfaceType: 'tool', command: 'x', toolPort: 'announced' }; + + it('defers Workspace transfer until reopening an existing browser has settled', async () => { + const { lath, state } = toolEngine({ + ...announced, toolRender: 'ab-screencast', renderMode: 'ab-screencast', + session: 'existing-browser', wsPort: 9222, url: 'http://localhost:6006/', toolAnnouncedPort: 6006, + binaryPath: '/opt/custom-agent-browser', + }); + const opened = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>(); + const platform = Object.assign(new FakePtyAdapter(), { + getOpenPorts: vi.fn(async () => [tcp(6007)]), + agentBrowserCommand: vi.fn(() => opened.promise), + }); + setPlatform(platform); + recordToolAnnounce('tool-1', { port: 6007, name: null, key: null, dehydrate: false, persist: null }); + const doorsRef = { current: [] }; + function Probe() { useToolServing({ lath, doorsRef }); return null; } + await act(async () => root.render(<Probe />)); + expect(state.params.url).toBe('http://localhost:6007/'); + expect(() => captureToolParams(lath, ['tool-1'])).toThrow('Wait for the Tool browser to connect'); + await act(async () => opened.resolve({ exitCode: 0, stdout: '', stderr: '' })); + expect(captureToolParams(lath, ['tool-1'])['tool-1'].session).toBe('existing-browser'); + expect(platform.agentBrowserCommand).toHaveBeenCalledExactlyOnceWith('existing-browser', ['open', 'http://localhost:6007/'], '/opt/custom-agent-browser'); + }); + + 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.set({ 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 } = toolEngine(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 } = toolEngine(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.each([true, false])('retires the daemon and browser params with Tools enabled=%s', async (enabled) => { + 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. + state.set(params); + setToolsEnabled(enabled); + 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.url).toBeUndefined(); + expect(state.params.session).toBeUndefined(); + expect(state.params.wsPort).toBeUndefined(); + expect(state.params.renderMode).toBeUndefined(); + expect(state.params.syncEngaged).toBeUndefined(); + }); +}); + + +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 } = toolEngine({ 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(); +}); + +it('keeps the destination and browser binding after a Workspace transfer', async () => { + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const { state, platform } = await run({ + surfaceType: 'tool', command: 'x', toolRender: 'ab-screencast', + renderMode: 'ab-screencast', session: 'existing-browser', + url: 'http://localhost:6006/edited', toolAnnouncedPort: 6006, + }, [[tcp(6006)], [tcp(6006)]]); + expect(platform.getOpenPorts).not.toHaveBeenCalled(); + expect(state.params.url).toBe('http://localhost:6006/edited'); + expect(state.params.session).toBe('existing-browser'); +}); + +it('does not commit an in-flight scan once its Workspace begins transferring', async () => { + const { lath, state } = toolEngine({ surfaceType: 'tool', command: 'x', toolPort: 'announced' }); + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const gate = Promise.withResolvers<OpenPort[]>(); + const platform = new FakePtyAdapter(); + platform.getOpenPorts = vi.fn(() => gate.promise); + setPlatform(platform); + let moving = false; + const doorsRef = { current: [] }; + function Probe() { useToolServing({ lath, doorsRef, paused: () => moving }); return null; } + await act(async () => root.render(<Probe />)); + moving = true; + await act(async () => gate.resolve([tcp(6006)])); + expect(state.params.url).toBeUndefined(); +}); + +it('rediscovers ports after the same command restarts between polls', async () => { + const { state, platform } = await run({ surfaceType: 'tool', command: 'x', toolPort: 'auto' }, [[tcp(6006)], [tcp(6006)]]); + expect(state.params.url).toBe('http://localhost:6006/'); + runId += 1; + platform.getOpenPorts = vi.fn(async () => [tcp(6007)]); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.url).toBeUndefined(); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.url).toBeUndefined(); // the new run must settle independently + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.url).toBe('http://localhost:6007/'); +}); + +it('keeps a fresh same-chunk announcement emitted before the restart is polled', async () => { + recordToolAnnounce('tool-1', { port: 6006, name: null, key: null, dehydrate: false, persist: null }); + const { state, platform } = await run({ surfaceType: 'tool', command: 'x', toolPort: 'announced' }, [[tcp(6006)]]); + runId += 1; + recordToolAnnounces('tool-1', new TerminalProtocolParser().process('\x1b]633;C\x07\x1b]367;serve;{"port":6007}\x07').events); + platform.getOpenPorts = vi.fn(async () => [tcp(6007)]); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS * 2); }); + expect(state.params.url).toBe('http://localhost:6007/'); +}); + +it('does not inherit another command announcement when the designated command starts', async () => { + currentCommand = 'cat log'; + recordToolAnnounce('tool-1', { port: 6006, name: null, key: ['spoof'], dehydrate: false, persist: null }); + const { state } = await run({ surfaceType: 'tool', command: 'x', toolPort: 'announced', toolName: 'named', toolKey: ['named', 'original'] }, [[tcp(6006)]]); + currentCommand = 'x'; + runId += 1; + recordToolAnnounces('tool-1', new TerminalProtocolParser().process('\x1b]633;C\x07').events); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(state.params.toolKey).toEqual(['named', 'original']); + 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..cb928b7e1 --- /dev/null +++ b/lib/src/components/wall/use-tool-serving.ts @@ -0,0 +1,289 @@ +/** + * 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'; +import type { CommandRun } from '../../lib/terminal-state'; + +// 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; + +const neverPaused = () => false; + +type ToolLeaf = { id: string; params: Record<string, unknown> }; + +/** The registered name a tool was spawned under; null for `dor tool -- <cmd>`. */ +function toolNameFromParams(params: Record<string, unknown>): 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, + paused = neverPaused, +}: { + lath: LathWallEngine; + doorsRef: React.MutableRefObject<DooredItem[]>; + paused?: () => boolean; +}): 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()); + // A prompt and same-command restart can both happen between polls. Seed the + // first observed run without retiring an imported live Workspace binding. + const observedRuns = useRef<Map<string, string | null>>(new Map()); + + useEffect(() => { + const platform = getPlatform(); + if (!platform.getOpenPorts) return; + let cancelled = false; + + const tick = async () => { + if (paused()) return; + 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 observedRuns.current.keys()) { + if (!live.has(id)) observedRuns.current.delete(id); + } + + // Pass one, synchronous: fold in whatever the running commands announced + // and decide which leaves still need a port scan. Nothing here awaits, so + // no leaf can be retired out from under a later one. + const scanning: { leaf: ToolLeaf; run: CommandRun; announcedPort: number | null }[] = []; + for (const leaf of leaves) { + const run = getTerminalPaneState(leaf.id).currentCommand; + const runId = run?.id ?? null; + const runChanged = observedRuns.current.has(leaf.id) && observedRuns.current.get(leaf.id) !== runId; + observedRuns.current.set(leaf.id, runId); + 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 || runChanged) seenPorts.current.delete(leaf.id); + + if ((hasUrl || hasConflict) && (!running || runChanged)) { + 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, + toolAnnouncedPort: undefined, + toolPortConflict: undefined, + session: undefined, + wsPort: undefined, + renderMode: undefined, + syncEngaged: undefined, + }); + continue; + } + // An announcement outranks whatever autobind decided, framed or + // refused: a conflict is a verdict about *guessing*, not a final state, + // so a tool that names its port after autobind refused must still be + // framed rather than be told to announce a port it just announced. + // 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. The memory is + // `params.toolAnnouncedPort`, the announcement the framed URL came + // from, so there is no second map to keep in step with it. + const announcedPort = announce?.port ?? null; + const announcedPortChanged = announcedPort !== null && leaf.params.toolAnnouncedPort !== announcedPort; + if (!running) continue; + if ((hasUrl || hasConflict) && !announcedPortChanged) continue; + scanning.push({ leaf, run, announcedPort }); + } + + if (scanning.length === 0 || cancelled || paused()) return; + // One scan per leaf, all in flight together: each shells out (lsof / + // PowerShell), so running them in series would make a Workspace of tools + // take that cost times the number of tools on every poll. + const scans = await Promise.all( + // A scan that fails is a scan that finds nothing yet. + scanning.map(({ leaf }) => platform.getOpenPorts!(leaf.id).catch(() => null)), + ); + + // Pass two: apply each verdict, re-checking the state every scan was + // decided against — the awaits above gave the Surface time to be killed, + // moved, or handed a different command. + for (let index = 0; index < scanning.length; index += 1) { + if (cancelled || paused()) return; + const ports = scans[index]; + if (ports === null) continue; + const { leaf, run, announcedPort } = scanning[index]; + if (!lath.getMeta(leaf.id) || getTerminalPaneState(leaf.id).currentCommand?.id !== run.id) continue; + const entries = listenerUrlsByPort(ports); + let entry; + + if (announcedPort !== null) { + // The announcement disambiguates; the scan supplies the number, so an + // announced port that nothing bound frames nothing. + entry = entries.find((candidate) => candidate.port === announcedPort); + if (!entry) continue; + } 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'; + const session = typeof leaf.params.session === 'string' ? leaf.params.session : sessionForKey(`tool.${leaf.id}`); + const binaryPath = typeof leaf.params.binaryPath === 'string' ? leaf.params.binaryPath : undefined; + lath.store.updateParams(leaf.id, { + url: entry.url, + renderMode: agentDrivable ? 'ab-screencast' : 'iframe', + toolPortConflict: undefined, + toolAnnouncedPort: announcedPort ?? undefined, + // Reopening an existing browser is also an in-flight connection: + // withhold its binding until open settles so a Workspace move cannot + // capture the old stream while this webview still owns the launch. + ...(agentDrivable ? { session: undefined, wsPort: 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. + await attachAgentBrowserSession({ + url: entry.url, + platform, + session, + surfaceId: leaf.id, + binaryPath, + 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'], binaryPath).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, paused]); +} diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index 6ac7dca12..552520007 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -36,7 +36,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 3137fa8de..41f86bbae 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -62,6 +62,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>({ @@ -80,6 +83,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/components/wall/workspace-transfer.ts b/lib/src/components/wall/workspace-transfer.ts index 1bf5439e5..af159f79e 100644 --- a/lib/src/components/wall/workspace-transfer.ts +++ b/lib/src/components/wall/workspace-transfer.ts @@ -1,3 +1,6 @@ +import type { TransferredTools } from './tool-transfer'; +import { getToolAnnounce } from '../../lib/tool-announce-store'; +import type { ToolAnnounce } from '../../lib/tool-announce'; import type { AlertRuntimeSnapshot } from '../../lib/alert-manager'; import type { AlertDeliveryHandoff } from '../../lib/alert-delivery-state'; import { snapshotTerminalState, type TransferredTerminalState } from '../../lib/terminal-state-store'; @@ -45,6 +48,7 @@ export interface ReleaseForTransferDeps { surfaceIds: () => string[]; /** Whether a member Surface has a PTY behind it. */ hasTerminal: (id: string) => boolean; + captureTools?: () => TransferredTools; } /** @@ -57,6 +61,8 @@ export interface ReleaseForTransferDeps { */ export interface PreparedWorkspaceTransfer { payload: WorkspaceTransferPayload; + /** Kept out of the durable payload; sent with the volatile content. */ + tools?: TransferredTools; /** * The host took it. Forget the notes and detach every Session — **the point * of no return**, and never reachable from a Wall unmount. @@ -99,8 +105,10 @@ export async function prepareWorkspaceTransfer( }); const notepad = snapshotNotepadForTransfer(allIds); + const tools = deps.captureTools?.(); return { + ...(tools && Object.keys(tools).length ? { tools } : {}), payload: { workspaceId: deps.workspaceId, workspace: { id: deps.workspaceId, name: deps.name, session }, @@ -130,6 +138,7 @@ export interface TransferredTerminal { /** The buffer as the escape stream that rebuilds it; `''` for a Session this * Window no longer held. */ serialized: string; + toolAnnounce?: ToolAnnounce; /** Grid at serialization, applied before replay and before destination fitting. */ grid?: TerminalGrid; semanticState?: TransferredTerminalState; @@ -144,7 +153,7 @@ export interface TransferredTerminal { * passed, and attached to the arrival the host queued at the invoke. */ export interface WorkspaceTransferContent { terminals: Record<string, TransferredTerminal>; - + tools?: TransferredTools; } /** @@ -167,7 +176,8 @@ export async function captureTransferContent( const terminal = getTerminalInstance(id); const grid = terminal ? { cols: terminal.cols, rows: terminal.rows } : undefined; const mark = marks.get(id); - terminals[id] = { serialized, ...(grid ? { grid, semanticState: snapshotTerminalState(id) } : {}), ...(mark === undefined ? {} : { mark }) }; + const toolAnnounce = getToolAnnounce(id); + terminals[id] = { serialized, ...(toolAnnounce ? { toolAnnounce } : {}), ...(grid ? { grid, semanticState: snapshotTerminalState(id) } : {}), ...(mark === undefined ? {} : { mark }) }; } return { terminals }; } diff --git a/lib/src/host/atomic-json-file.ts b/lib/src/host/atomic-json-file.ts new file mode 100644 index 000000000..17e518716 --- /dev/null +++ b/lib/src/host/atomic-json-file.ts @@ -0,0 +1,43 @@ +/** + * The one way a Node-side host commits a private JSON file: owner-only, and + * temp-then-rename so a crash can never publish a half-written one. Shared by + * the Burrow state store and the Tool trust store, whose files hold a bearer + * credential and a security decision respectively. + */ + +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'; + +/** Write `value` as JSON to `path`, creating `dir` (which contains it) first. */ +export async function writeJsonAtomic(dir: string, path: string, value: unknown): Promise<void> { + // 0700 dir + 0600 file: these files decide what is authorized, and the app + // data directory is not otherwise private on a shared machine. + await mkdir(dir, { recursive: true, mode: 0o700 }); + // `mkdir` applies its mode only when it creates the final component. Tauri + // creates app_data_dir before spawning us, commonly under a 0755 umask, so + // tighten an existing directory too. Best-effort, like `peer-link.ts`'s: + // failing the whole save over the directory would lose the write instead. + // + // Skipped on Windows because there is nothing here to skip *to* — a Unix + // mode is a silent no-op on that platform, and so is the 0600 on the file + // below, so neither call protects anything. What protects it there is the + // owner-only DACL that `burrow_state_dir` in + // `standalone/src-tauri/src/lib.rs` applies to this directory before + // spawning us; the files written below inherit it. Node cannot set an ACL, + // which is why the guarantee lives on the Rust side rather than here. + if (process.platform !== 'win32') await chmod(dir, 0o700).catch(() => {}); + // Temp-then-rename in the same directory, so a crash mid-write leaves the + // previous contents intact rather than a truncated file that reads as empty. + // Unique per write rather than per process, so a second Dormouse sharing the + // state directory never renames a file the first one is still writing. + const tmp = `${path}.${randomUUID()}.tmp`; + let renamed = false; + try { + await writeFile(tmp, JSON.stringify(value), { mode: 0o600 }); + await rename(tmp, path); + renamed = true; + } finally { + // A failed rename must not accumulate temp files holding the same secret. + if (!renamed) await rm(tmp, { force: true }).catch(() => {}); + } +} 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/remote/burrow-state-store.ts b/lib/src/host/remote/burrow-state-store.ts index 29d4884c8..82a33982d 100644 --- a/lib/src/host/remote/burrow-state-store.ts +++ b/lib/src/host/remote/burrow-state-store.ts @@ -10,12 +10,12 @@ * is the sidecar's: one file, 0600, under a directory the app passes in. */ -import { randomUUID } from 'node:crypto'; -import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import type { BurrowAclRecord } from 'remote-lib-common'; import { filterAclRecords } from '../../remote/burrow/acl'; import { isEnrollment, type BurrowEnrollment } from '../../remote/burrow/enrollment'; +import { writeJsonAtomic } from '../atomic-json-file'; import { createSerialQueue } from './serial-queue'; // Re-exported so an implementor can name the record type without depending on @@ -193,38 +193,11 @@ export class FileBurrowStateStore implements BurrowStateStore { } } - async #write(state: BurrowStateFile): Promise<void> { - // 0700 dir + 0600 file: the enrollment is a bearer credential, and the app - // data directory is not otherwise private on a shared machine. - await mkdir(this.#dir, { recursive: true, mode: 0o700 }); - // `mkdir` applies its mode only when it creates the final component. Tauri - // creates app_data_dir before spawning us, commonly under a 0755 umask, so - // tighten an existing directory too. Best-effort, like `peer-link.ts`'s: - // failing the whole save over the directory would lose the Burrow instead. - // - // Skipped on Windows because there is nothing here to skip *to* — a Unix - // mode is a silent no-op on that platform, and so is the 0600 on the file - // below, so neither call protects anything. What protects it there is the - // owner-only DACL that `burrow_state_dir` in - // `standalone/src-tauri/src/lib.rs` applies to this directory before - // spawning us; the files written below inherit it. Node cannot set an ACL, - // which is why the guarantee lives on the Rust side rather than here. - if (process.platform !== 'win32') await chmod(this.#dir, 0o700).catch(() => {}); - // Temp-then-rename in the same directory, so a crash mid-write leaves the - // previous state intact rather than a truncated file that reads as "no Burrow". - // Unique per write rather than per process: `#mutate` already keeps this - // process's saves apart, and a second Dormouse sharing the state directory - // would otherwise rename a file the first one is still writing. - const tmp = `${this.#path}.${randomUUID()}.tmp`; - let renamed = false; - try { - await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); - await rename(tmp, this.#path); - renamed = true; - } finally { - // A failed rename must not accumulate bearer-credential temp files. - if (!renamed) await rm(tmp, { force: true }).catch(() => {}); - } + /** The enrollment is a bearer credential and a truncated file reads as "no + * Burrow", so the write is the shared owner-only atomic one. `#mutate` + * already keeps this process's saves apart. */ + #write(state: BurrowStateFile): Promise<void> { + return writeJsonAtomic(this.#dir, this.#path, state); } } diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index 34c533395..120418266 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -487,6 +487,15 @@ describe('the webview’s half of the parse', () => { ]); }); + it('preserves command-start resets between forwarded Tool announcements', () => { + bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]367;serve;{"port":6006}\x07\x1b]633;C\x07\x1b]367;serve;{"port":6007}\x07' }); + expect(emitted<{ events: unknown[] }>('terminal:protocolEvents')[0]?.events).toEqual([ + { kind: 'toolAnnounce', announce: { port: 6006, name: null, key: null, dehydrate: false, persist: null } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc633_boundaries' } }, + { kind: 'toolAnnounce', announce: { port: 6007, name: null, key: null, dehydrate: false, persist: null } }, + ]); + }); + it('still sends the chunk when the reply write throws', () => { // A PTY that died between the read and the reply write throws out of // `mgr.write`; the webview must still get what the parse produced. 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..a0030a546 --- /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 innerdogfood'); + // 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..456537688 --- /dev/null +++ b/lib/src/host/tool-registry.ts @@ -0,0 +1,209 @@ +/** + * `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'; +import { isRecord } from '../lib/is-record'; + +/** 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']); + +/** 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..8e149699b --- /dev/null +++ b/lib/src/host/tool-trust.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } 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('lands concurrent grants for different keys without a lock between them', 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 directory instead of retaining a + // cache that cannot observe another window's grant. + expect(await first.isTrusted([upstream])).toBe(true); + }); + + it('is idempotent: granting the same key twice leaves one grant', async () => { + const stateDir = join(root, 'state'); + const key = folderGrantKey('/repo'); + const store = new FileToolTrustStore(stateDir); + await store.grant(key, 'folder'); + await Promise.all([store.grant(key, 'folder'), new FileToolTrustStore(stateDir).grant(key, 'folder')]); + + expect(await store.isTrusted([key])).toBe(true); + // One file per grant, and no temp file left behind by the repeat writes. + expect(await readdir(join(stateDir, 'tool-trust'))).toHaveLength(1); + }); + + it('ignores files outside the expected grant path', async () => { + // Every grant is named for the hash of its key, so nothing an unrelated + // writer drops in the directory can vouch for a key. + const stateDir = join(root, 'state'); + const trustDir = join(stateDir, 'tool-trust'); + await mkdir(trustDir, { recursive: true }); + await writeFile(join(trustDir, 'tool-trust.json'), '{not json'); + await writeFile(join(trustDir, 'deadbeef.json'), JSON.stringify({ version: 1, key: folderGrantKey('/repo'), kind: 'folder' })); + + const store = new FileToolTrustStore(stateDir); + expect(await store.isTrusted([folderGrantKey('/repo')])).toBe(false); + expect(await store.isTrusted([upstreamGrantKey('https://github.com/diffplug/dormouse')])).toBe(false); + }); + + it('validates the receipt at the exact grant path and still checks other covering keys', async () => { + const stateDir = join(root, 'state'); + const key = folderGrantKey('/repo'); + const store = new FileToolTrustStore(stateDir); + await store.grant(key, 'folder'); + const [name] = await readdir(join(stateDir, 'tool-trust')); + const path = join(stateDir, 'tool-trust', name); + const valid = JSON.parse(await readFile(path, 'utf8')); + const upstream = upstreamGrantKey('https://github.com/diffplug/dormouse'); + await store.grant(upstream, 'upstream'); + + for (const invalid of [ + '', '{not json', 'null', '[]', '{}', + JSON.stringify({ ...valid, version: 2 }), + JSON.stringify({ ...valid, key: folderGrantKey('/other') }), + JSON.stringify({ ...valid, kind: 'upstream' }), + JSON.stringify({ ...valid, kind: 'unknown' }), + JSON.stringify({ ...valid, grantedAt: undefined }), + JSON.stringify({ ...valid, grantedAt: 0 }), + JSON.stringify({ ...valid, padding: 'x'.repeat(256 * 1024) }), + ]) { + await writeFile(path, invalid); + expect(await store.isTrusted([key])).toBe(false); + expect(await store.isTrusted([key, upstream])).toBe(true); + } + await writeFile(path, JSON.stringify(valid)); + expect(await store.isTrusted([key])).toBe(true); + }); + + it('does not accept a directory at the exact grant path', async () => { + const stateDir = join(root, 'state'); + const key = folderGrantKey('/repo'); + const store = new FileToolTrustStore(stateDir); + await store.grant(key, 'folder'); + const [name] = await readdir(join(stateDir, 'tool-trust')); + const path = join(stateDir, 'tool-trust', name); + await rm(path); + await mkdir(path); + expect(await store.isTrusted([key])).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('does not follow a symlink receipt even when its target is a valid grant', async () => { + const stateDir = join(root, 'state'); + const key = folderGrantKey('/repo'); + const store = new FileToolTrustStore(stateDir); + await store.grant(key, 'folder'); + const [name] = await readdir(join(stateDir, 'tool-trust')); + const path = join(stateDir, 'tool-trust', name); + const target = join(root, 'another-file.json'); + await writeFile(target, await readFile(path)); + await rm(path); + await symlink(target, path); + expect(await store.isTrusted([key])).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('does not spawn git once the folder grant already answers', async () => { + await write(); + const trust = new MemoryToolTrustStore(); + await trust.grant(folderGrantKey(root), 'folder'); + // `resolveUpstreamUrl` is two `git` subprocesses on every named invocation. + const upstream = vi.fn(async () => 'https://github.com/diffplug/dormouse'); + expect((await lookupTool('storybook', root, trust, undefined, upstream)).status).toBe('ok'); + expect(upstream).not.toHaveBeenCalled(); + }); + + + 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..1bf9afbb0 --- /dev/null +++ b/lib/src/host/tool-trust.ts @@ -0,0 +1,298 @@ +/** + * 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 — one file per grant, so two hosts sharing the state + * directory need no lock between them — and answers "is it trusted yet?". + */ +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { writeJsonAtomic } from './atomic-json-file'; +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(); + } +} + +/** Directory under the state dir; one file inside it per recorded grant. */ +const TRUST_DIR_NAME = 'tool-trust'; + +/** + * 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 directory holds both without collisions. */ +export function upstreamGrantKey(canonicalUrl: string): string { + return `upstream:${canonicalUrl}`; +} +export function folderGrantKey(root: string): string { + return `folder:${resolve(root)}`; +} + +/** + * One recorded grant — the whole content of one file. + * + * 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). + * + * A file rather than a bare marker 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. + * An empty marker file has nowhere to put one, so adding revocation later would + * be a schema change on a security file. `key` is here for the same reason: the + * name on disk is a hash, so only the file itself can say what was granted. + */ +interface TrustGrant { + readonly version: 1; + readonly key: string; + readonly kind: TrustGrantKind; + /** ISO timestamp, retained for display; grants do not expire. */ + readonly grantedAt: string; +} + +function newGrant(key: string, kind: TrustGrantKind): TrustGrant { + return { version: 1, key, kind, grantedAt: new Date().toISOString() }; +} + +/** + * Records grants, one file per grant under `<stateDir>/tool-trust/`, named for + * the SHA-256 of its key. + * + * Grants are add-only and idempotent, so nothing here merges and nothing here + * locks: two hosts granting at once write two different paths, and two hosts + * granting the same key record the same authority. The write is still + * temp-then-rename, so a crash mid-write cannot publish a truncated record. + * Reads validate the receipt; a filename alone never grants trust. + */ +export class FileToolTrustStore { + readonly #dir: string; + + constructor(stateDir: string) { + this.#dir = join(stateDir, TRUST_DIR_NAME); + } + + /** Hashed rather than escaped: a key is an arbitrary URL or absolute path, + * and a hash is a filename on every platform with no length limit to hit. */ + #pathFor(key: string): string { + return join(this.#dir, `${createHash('sha256').update(key).digest('hex')}.json`); + } + + /** 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> { + for (const key of keys) { + try { + const path = this.#pathFor(key); + // Reject special entries before opening: a FIFO must not block lookup. + if (!(await lstat(path)).isFile()) continue; + const grant = JSON.parse(await readToolFile(path)) as Partial<TrustGrant> | null; + if (grant?.version === 1 && grant.key === key + && (grant.kind === 'folder' || grant.kind === 'upstream') + && key.startsWith(`${grant.kind}:`) && typeof grant.grantedAt === 'string') return true; + } catch { + // Missing, unreadable, oversized, or malformed receipts grant nothing. + // Another key may still cover this project. + } + } + return false; + } + + /** Record a grant a human made in Dormouse's chrome. */ + async grant(key: string, kind: TrustGrantKind): Promise<void> { + await writeJsonAtomic(this.#dir, this.#pathFor(key), newGrant(key, kind)); + } +} + +/** 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, newGrant(key, kind)); + } +} + +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: this folder alone, or the upstream every + // worktree shares. The folder key is free, so it is checked first — a granted + // folder answers without spawning git at all. + const ok = { status: 'ok', projectRoot: found.dir, path: found.path, file, entry } as const; + if (await trust.isTrusted([folderGrantKey(found.dir)])) return ok; + + // Only now pay for git, which the untrusted answer needs anyway so the + // approval UI can offer the upstream grant. + const upstreamUrl = await resolveUpstream(found.dir); + if (upstreamUrl && await trust.isTrusted([upstreamGrantKey(upstreamUrl)])) return ok; + 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 42fd30338..a48069104 100644 --- a/lib/src/lib/feature-flags.ts +++ b/lib/src/lib/feature-flags.ts @@ -12,6 +12,30 @@ 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. + } +} + +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'; /** Whether the agent-browser high-rate `[ab-panel]`/`[agent-browser]` stream and diff --git a/lib/src/lib/notepad/pin.test.ts b/lib/src/lib/notepad/pin.test.ts index 65cd20bed..b0529f908 100644 --- a/lib/src/lib/notepad/pin.test.ts +++ b/lib/src/lib/notepad/pin.test.ts @@ -103,6 +103,74 @@ describe('revealNoteSource', () => { expect(markersOf(src).every((marker) => !marker.isDisposed)).toBe(true); }); + it('uses the current marker rows after opening the source view', () => { + 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 a row shift during the view change, without rewrapping content. + 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('keeps a previously valid pin when opening the view rewraps its text', () => { + const { terminal, scrollToLine } = makeTerminal(['alpha one br', 'avo two']); + mocks.getTerminalInstance.mockReturnValue(terminal); + const { noteId, source: src } = pinnedNote(source({ + endColumn: 6, + expectedRawText: 'alpha one br\navo two', + })); + const target = new EventTarget(); + const reveal = vi.fn(() => { + // Widening joins the two display rows and moves the end marker. The + // characters survive, but the stored columns and raw proof no longer fit. + Object.assign(terminal, makeTerminal(['alpha one bravo two', '']).terminal); + markersOf(src)[1].line = 0; + }); + target.addEventListener('dormouse:reveal-note-source', reveal); + vi.stubGlobal('window', target); + try { + expect(revealNoteSource('term-1', noteId)).toEqual({ ok: false, reason: 'layout-changed', kept: true }); + expect(reveal).toHaveBeenCalledOnce(); + expect(getNotes('term-1')[0].source).toBe(src); + expect(markersOf(src).every(marker => !marker.isDisposed)).toBe(true); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + expect(scrollToLine).not.toHaveBeenCalled(); + expect(terminal.scrollToLine).not.toHaveBeenCalled(); + } finally { vi.unstubAllGlobals(); } + }); + + it.each(['no-source', 'no-terminal', 'alternate-buffer', 'disposed', 'missing-rows', 'mismatch'] as const)( + 'does not open the source view when the initial resolution fails with %s', reason => { + const src = source(); + const { noteId } = pinnedNote(src); + if (reason === 'no-source') clearAllNotepads(); + if (reason === 'disposed') markersOf(src)[0].dispose(); + mocks.getTerminalInstance.mockReturnValue(reason === 'no-terminal' ? null : makeTerminal( + reason === 'missing-rows' ? ['alpha one'] : reason === 'mismatch' ? ['changed', 'rows'] : LINES, + { type: reason === 'alternate-buffer' ? 'alternate' : 'normal' }, + ).terminal); + const target = new EventTarget(); + const reveal = vi.fn(); + target.addEventListener('dormouse:reveal-note-source', reveal); + vi.stubGlobal('window', target); + try { + expect(revealNoteSource('term-1', noteId)).toEqual({ ok: false, reason, kept: reason === 'alternate-buffer' }); + expect(reveal).not.toHaveBeenCalled(); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + } 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..cc3b57186 100644 --- a/lib/src/lib/notepad/pin.ts +++ b/lib/src/lib/notepad/pin.ts @@ -1,8 +1,7 @@ // Following a note's source pin back to its scrollback (docs/specs/notepad.md -// → Source links). Every failure but an active alternate buffer is terminal for -// the pin: the markers are released and the link removed, so a pin the user can -// see is one that resolved the last time it was asked — or one waiting for a -// full-screen program to exit. The note itself is never touched. +// → Source links). Validate before changing the view, then prove the displayed +// range again: opening Tool context can reflow a previously valid source. The +// note itself is never touched. import { getTerminalInstance } from '../terminal-registry'; import { dropSource, getNotes } from './notepad-store'; import { resolveTerminalSource, revealResolvedSource } from './source-link'; @@ -11,6 +10,7 @@ export type PinFailureReason = | 'no-source' | 'no-terminal' | 'alternate-buffer' + | 'layout-changed' | 'disposed' | 'missing-rows' | 'mismatch'; @@ -20,16 +20,14 @@ export type PinOutcome = | { ok: false; reason: PinFailureReason; - /** Whether the pin survived — true only for `alternate-buffer`, the one - * failure that can resolve later. Everything downstream reads this - * rather than the reason: dropping the source, and the notice a row - * shows (`sourceNoticeFor` in `lib/src/components/NoteList.tsx`). */ + /** Whether the pin survived. Covered sources and sources invalidated by + * this reveal's layout change are retained. */ kept: boolean; }; /** The single place a failure reason decides whether the pin lives. */ function failed(reason: PinFailureReason): Extract<PinOutcome, { ok: false }> { - return { ok: false, reason, kept: reason === 'alternate-buffer' }; + return { ok: false, reason, kept: reason === 'alternate-buffer' || reason === 'layout-changed' }; } /** Resolve the note's pin against the live buffer; on success scroll the range @@ -60,6 +58,12 @@ export function revealNoteSource(surfaceId: string, noteId: string): PinOutcome return outcome; } - revealResolvedSource(source.terminalId, resolved.selection); + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('dormouse:reveal-note-source', { detail: { surfaceId, terminalId: source.terminalId } })); + // Context mounts and refits synchronously during the event. Never select the + // old coordinates or destroy a source that our own view change invalidated. + const displayed = resolveTerminalSource(terminal, source); + if (!displayed.ok) return failed('layout-changed'); + + revealResolvedSource(source.terminalId, displayed.selection); return { ok: true }; } 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 017fa6f6b..180e7d628 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -7,6 +7,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'; import type { PersistedAlertState, PersistedWindow } from '../session-types'; @@ -387,6 +390,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 3cd6350ea..292939fdd 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,23 @@ 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); + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:toolAnnounce', id: 'tool-1', announce: null })); + expect(getToolAnnounce('tool-1')).toBeNull(); + 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); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id: 'tool-1', data: '\x1b]633;C\x07' })); + expect(getToolAnnounce('tool-1')).toBeNull(); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id: 'tool-1', data: '\x1b]633;C\x07\x1b]367;serve;{"port":6008}\x07' })); + expect(getToolAnnounce('tool-1')?.port).toBe(6008); + }); + 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 3c47096a8..fb50780f2 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,6 @@ +import { recordToolAnnounce, recordToolAnnounces } 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 { openPortRequestTimeoutMs } 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); + recordToolAnnounces(msg.id, parsed.events); 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 e23b43e4b..389ede0ee 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' } }, @@ -315,6 +338,17 @@ 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(); +}); + describe('restoreSession alert seeding', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index a423e7ae9..4931ce256 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'; @@ -40,8 +41,24 @@ export function restoreSession(platform: PlatformAdapter, sources: RestoreSource 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 @@ -64,7 +81,11 @@ export function restoreSession(platform: PlatformAdapter, sources: RestoreSource 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 }), }); // The fresh PTY inherits the pane's persisted TODO/alert, on the hosts whose // AlertManager lives in the webview. Seeded after `restoreTerminal` so the @@ -77,7 +98,7 @@ export function restoreSession(platform: PlatformAdapter, sources: RestoreSource // 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 d1ed95c5a..a6f1fa229 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 78db7751f..35fce8597 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,6 +1,6 @@ import { normalizeAlertDeliveryOverrides, type AlertDeliveryOverrides } from './alert-delivery-model'; 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'; @@ -27,6 +27,7 @@ export interface SavePaneInput { id: string; title: string; surfaceType?: PersistedSurfaceType; + params?: Record<string, unknown>; } /** What one save may skip. See `SessionFlushRequest.probeCwd`. */ @@ -78,16 +79,26 @@ export async function buildPersistedSession( options: SaveOptions = {}, ): Promise<PersistedSession> { const previousPanes = previousPaneMap(previous ?? null); - 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 }); } // One probe for the whole set, before the per-pane build: a terminal pane's cwd @@ -108,13 +119,22 @@ export async function buildPersistedSession( } const liveAlert = getLivePersistedAlertState(pane.id); - return { + const terminalPane: PersistedPane = { id: pane.id, title: pane.title, cwd: cwds?.[pane.id] ?? 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 alertDelivery = normalizeAlertDeliveryOverrides(options.alertDelivery ?? previous?.alertDelivery); return { @@ -152,6 +172,24 @@ export async function saveSession( else platform.saveState(session); } +/** The command a tool Surface was given, or null when it has none yet — what + * persistence records and what a dedupe match re-runs. */ +export 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 534728266..a91569388 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -11,7 +11,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`. */ @@ -22,6 +32,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; } /** @@ -146,11 +161,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 a8cefc9f3..85381dc19 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 { serializeTransferTerminal, type TerminalGrid } from './terminal-transfer'; import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; @@ -485,7 +486,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; @@ -501,10 +511,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, @@ -517,17 +523,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; @@ -632,6 +640,7 @@ function teardownSession(id: string, { kill }: { kill: boolean }): void { registry.delete(id); removeTerminalPaneState(id); removeMouseSelectionState(id); + clearToolAnnounce(id); clearTerminalActivity(id); } @@ -730,10 +739,10 @@ export function registerSurfaceFocusHandle(id: string, handle: SurfaceFocusHandl }; } -export function focusSession(id: string, focused: boolean): void { +export function focusSession(id: string, focused: boolean, target: 'surface' | 'terminal' = 'surface'): void { // Non-terminal surfaces (iframe) aren't in the xterm registry — route to // their focus handle so onClickPanel → enterTerminalMode focuses them too. - const handle = surfaceFocusHandles.get(id); + const handle = target === 'surface' ? surfaceFocusHandles.get(id) : undefined; if (handle) { if (focused) handle.focus(); else handle.blur(); diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 4f3b4fb2a..10cab6ac3 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 { recordToolAnnounces } 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 []; @@ -423,6 +433,7 @@ export function applyTerminalProtocolEvents( id: string, events: TerminalProtocolEvent[], ): void { + recordToolAnnounces(id, events); for (const event of events) { if (event.kind === 'notification') { sink.notifyFromProtocol(id, event.notification); @@ -433,7 +444,7 @@ export function applyTerminalProtocolEvents( } /** - * The notification and progress events {@link applyTerminalProtocolEvents} acts + * The notification, progress, Tool announcement and command-start 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 +452,8 @@ 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' + || (event.kind === 'semantic' && event.event.type === 'commandStart')); } export function collectTerminalProtocolResponses(events: TerminalProtocolEvent[]): string[] { @@ -818,24 +830,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 6607e5c07..8af2b8387 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -121,6 +121,7 @@ import { dismissOrToggleAlert, dismissSessionAlert, focusSession, + registerSurfaceFocusHandle, getOrCreateTerminal, getActivity, getLivePersistedAlertState, @@ -538,6 +539,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' }); @@ -1265,6 +1280,26 @@ describe('terminal-registry alert behavior', () => { }); }); + it('focuses a Tool terminal while its retiring browser handle is still registered', () => { + const id = 'retiring-tool-browser'; + const session = createSession(id); + const focus = vi.spyOn(session.terminal, 'focus'); + const blur = vi.spyOn(session.terminal, 'blur'); + const browser = { focus: vi.fn(), blur: vi.fn() }; + const unregister = registerSurfaceFocusHandle(id, browser); + try { + focusSession(id, true); + expect(browser.focus).toHaveBeenCalledOnce(); + expect(focus).not.toHaveBeenCalled(); + focusSession(id, true, 'terminal'); + expect(focus).toHaveBeenCalledOnce(); + expect(browser.focus).toHaveBeenCalledOnce(); + focusSession(id, false, 'terminal'); + expect(blur).toHaveBeenCalledOnce(); + expect(browser.blur).not.toHaveBeenCalled(); + } finally { unregister(); } + }); + it('programmatic terminal focus does not count as attention', () => { const id = 'focus-without-attention'; createSession(id); diff --git a/lib/src/lib/tool-announce-store.ts b/lib/src/lib/tool-announce-store.ts new file mode 100644 index 000000000..8d464d951 --- /dev/null +++ b/lib/src/lib/tool-announce-store.ts @@ -0,0 +1,50 @@ +/** + * 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 { TerminalProtocolEvent } from './terminal-protocol'; +import type { ToolAnnounce } from './tool-announce'; + +const announces = new Map<string, ToolAnnounce>(); + +/** 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 | null): void { + if (announce) announces.set(id, announce); + else announces.delete(id); +} + +/** The one spelling of "record whatever announcements this parse produced", + * shared by every renderer-side seam that parses raw replay itself. */ +export function recordToolAnnounces(id: string, events: readonly TerminalProtocolEvent[]): void { + // Preserve stream order: a fresh command retires the previous run's hint, + // but a serve later in the same parsed chunk belongs to the new run. + for (const event of events) { + if (event.kind === 'toolAnnounce') recordToolAnnounce(id, event.announce); + else if (event.kind === 'semantic' && event.event.type === 'commandStart') clearToolAnnounce(id); + } +} + +/** 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 { + announces.delete(id); +} + +export function getToolAnnounce(id: string): ToolAnnounce | null { + return announces.get(id) ?? null; +} + +/** Test seam. */ +export function resetToolAnnounces(): void { + announces.clear(); +} diff --git a/lib/src/lib/tool-announce.test.ts b/lib/src/lib/tool-announce.test.ts new file mode 100644 index 000000000..b18206ea4 --- /dev/null +++ b/lib/src/lib/tool-announce.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { parseToolAnnounce } from './tool-announce'; +import { collectTerminalProtocolAlerts, collectTerminalProtocolResponses, TerminalProtocolParser } from './terminal-protocol'; +import { getToolAnnounce, recordToolAnnounces, 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: () => {} }; + + it.each(['live', 'replay', 'forwarded'] as const)('isolates successive commands without losing same-chunk serves (%s)', mode => { + const record = (data: string) => { + const events = new TerminalProtocolParser().process(data).events; + if (mode === 'replay') recordToolAnnounces('epoch', events); + else applyTerminalProtocolEvents(sink, 'epoch', mode === 'forwarded' ? collectTerminalProtocolAlerts(events) : events); + }; + const oldServe = '\x1b]367;serve;{"port":6006,"key":["old"]}\x07'; + const start = '\x1b]633;C\x07'; + record(oldServe + start); + expect(getToolAnnounce('epoch')).toBeNull(); + record(oldServe + start + '\x1b]367;serve;{"port":6007}\x07'); + expect(getToolAnnounce('epoch')?.port).toBe(6007); + record('since-mark replay without a command boundary'); + expect(getToolAnnounce('epoch')?.port).toBe(6007); + record(start); + expect(getToolAnnounce('epoch')).toBeNull(); + }); + + 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..7ad7df6aa --- /dev/null +++ b/lib/src/lib/tool-announce.ts @@ -0,0 +1,93 @@ +/** + * 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 { isRecord } from './is-record'; +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 (!isRecord(payload)) return null; + const record = payload; + + 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/lib/src/lib/workspace-ui-store.ts b/lib/src/lib/workspace-ui-store.ts index 12e17ec5a..73da5cf8b 100644 --- a/lib/src/lib/workspace-ui-store.ts +++ b/lib/src/lib/workspace-ui-store.ts @@ -12,13 +12,15 @@ export interface WorkspaceUiState { /** The Workspace awaiting its typed close confirmation, and the letter that * accepts it (minted once, so a re-render cannot change the letter on screen). */ pendingClose: { id: WorkspaceId; char: string } | null; + /** A refused move stays visible until dismissed or retried. */ + moveError: { id: WorkspaceId; reason: string } | null; /** A move between Windows awaiting its typed confirmation, because it would * destroy the page state of `iframeCount` iframe Surfaces; `proceed` runs the * move (`docs/specs/layout.md` → "Workspaces"). */ pendingMove: { id: WorkspaceId; char: string; iframeCount: number; proceed: () => void } | null; } -const EMPTY: WorkspaceUiState = { renamingId: null, pendingClose: null, pendingMove: null }; +const EMPTY: WorkspaceUiState = { renamingId: null, pendingClose: null, pendingMove: null, moveError: null }; let state: WorkspaceUiState = EMPTY; const listeners = new Set<() => void>(); @@ -55,6 +57,10 @@ export function setPendingWorkspaceMove(pending: WorkspaceUiState['pendingMove'] emit({ ...state, pendingMove: pending }); } +export function setWorkspaceMoveError(error: WorkspaceUiState['moveError']): void { + emit({ ...state, moveError: error }); +} + /** Clear every transient Workspace UI state in one notification: the host's * teardown dialog taking the window, and tests. */ export function resetWorkspaceUi(): void { @@ -64,11 +70,12 @@ export function resetWorkspaceUi(): void { /** Forget only the departing Workspace's chrome, preserving sibling dialogs. */ export function dismissWorkspaceUi(id: WorkspaceId): void { - const { renamingId, pendingClose, pendingMove } = state; - if (renamingId !== id && pendingClose?.id !== id && pendingMove?.id !== id) return; + const { renamingId, pendingClose, pendingMove, moveError } = state; + if (renamingId !== id && pendingClose?.id !== id && pendingMove?.id !== id && moveError?.id !== id) return; emit({ renamingId: renamingId === id ? null : renamingId, pendingClose: pendingClose?.id === id ? null : pendingClose, pendingMove: pendingMove?.id === id ? null : pendingMove, + moveError: moveError?.id === id ? null : moveError, }); } diff --git a/lib/src/stories/BrowserChromeHeader.stories.tsx b/lib/src/stories/BrowserChromeHeader.stories.tsx index f044fbdb4..ff9c51ca7 100644 --- a/lib/src/stories/BrowserChromeHeader.stories.tsx +++ b/lib/src/stories/BrowserChromeHeader.stories.tsx @@ -47,6 +47,7 @@ const loggingActions: WallActions = { onCancelRename: () => {}, onSwapRenderMode: (id, mode) => console.log('[story] swap render', id, mode), resolveSurfaceRef: (id) => id, + onResolveToolApproval: () => {}, }; interface StoryArgs { diff --git a/lib/src/stories/MouseHeaderIcon.stories.tsx b/lib/src/stories/MouseHeaderIcon.stories.tsx index 904c4ed70..d9f7f9342 100644 --- a/lib/src/stories/MouseHeaderIcon.stories.tsx +++ b/lib/src/stories/MouseHeaderIcon.stories.tsx @@ -34,6 +34,7 @@ const noopActions: WallActions = { onCancelRename: () => {}, onSwapRenderMode: () => {}, resolveSurfaceRef: (id) => id, + onResolveToolApproval: () => {}, }; function MouseIconStoryFrame({ diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 0fb3da2cf..8dbfc050c 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -56,6 +56,7 @@ const noopActions: WallActions = { onCancelRename: () => {}, onSwapRenderMode: () => {}, resolveSurfaceRef: (id) => id, + onResolveToolApproval: () => {}, }; const meta: Meta<typeof ShellCwdMatrix> = { diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index e96c9638c..498c7a729 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -33,6 +33,7 @@ const noopActions: WallActions = { onCancelRename: () => {}, onSwapRenderMode: () => {}, resolveSurfaceRef: (id) => id, + onResolveToolApproval: () => {}, }; function actionsRejecting(reason: 'empty' | 'reserved'): WallActions { diff --git a/lib/src/stories/ToolApproval.stories.tsx b/lib/src/stories/ToolApproval.stories.tsx new file mode 100644 index 000000000..1fef26120 --- /dev/null +++ b/lib/src/stories/ToolApproval.stories.tsx @@ -0,0 +1,70 @@ +import { useId } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ToolApproval } from '../components/wall/ToolApproval'; + +const longPath = `/worktrees/${'long-project-name-'.repeat(8)}/dormouse.yml`; + +function ToolApprovalStory({ width, height, error }: { width: number; height: number; error: string }) { + const id = useId(); + return ( + <div data-approval-story style={{ width, height }} className="overflow-hidden"> + <ToolApproval + id={id} + title="Pending Tool" + params={{ toolPending: { + name: 'storybook', + run: `pnpm exec storybook --config-dir ${longPath}`, + path: longPath, + projectRoot: longPath.slice(0, -'/dormouse.yml'.length), + upstreamUrl: `https://example.com/${'long-repository-name-'.repeat(8)}.git`, + minimized: false, + error, + } }} + onResolve={() => {}} + /> + </div> + ); +} + +const meta: Meta<typeof ToolApprovalStory> = { + title: 'Components/ToolApproval', + component: ToolApprovalStory, + args: { width: 320, height: 800, error: `Could not save permission: ${longPath}` }, +}; + +export default meta; +type Story = StoryObj<typeof ToolApprovalStory>; + +/** Long unbroken paths, upstream names, commands, and errors must wrap. */ +export const Narrow: Story = {}; + +/** Keep the start of overflowing content reachable, not centered above the pane. */ +export const Short: Story = { + args: { height: 180 }, + play: ({ canvasElement }) => { + const pane = canvasElement.querySelector('[data-approval-story]')!.firstElementChild as HTMLElement; + const content = pane.firstElementChild as HTMLElement; + if (pane.scrollWidth > pane.clientWidth + 1) throw new Error('Approval overflows horizontally'); + if (content.getBoundingClientRect().top < pane.getBoundingClientRect().top) { + throw new Error('Approval starts above the scroll origin'); + } + if (pane.scrollHeight <= pane.clientHeight) throw new Error('Short approval must overflow vertically'); + }, +}; + +/** Capture the controls after scrolling a short pane, and check their reachability. */ +export const ShortScrolledToControls: Story = { + args: { height: 180 }, + play: ({ canvasElement }) => { + const pane = canvasElement.querySelector('[data-approval-story]')!.firstElementChild as HTMLElement; + const buttons = [...pane.querySelectorAll('button')]; + for (const button of buttons) { + button.scrollIntoView({ block: 'nearest' }); + const bounds = button.getBoundingClientRect(); + const viewport = pane.getBoundingClientRect(); + if (bounds.top < viewport.top - 1 || bounds.bottom > viewport.bottom + 1) { + throw new Error('Approval control cannot be scrolled into view'); + } + } + }, +}; diff --git a/lib/src/stories/WorkspaceStrip.stories.tsx b/lib/src/stories/WorkspaceStrip.stories.tsx index 7c72188bf..ca4a53937 100644 --- a/lib/src/stories/WorkspaceStrip.stories.tsx +++ b/lib/src/stories/WorkspaceStrip.stories.tsx @@ -1,3 +1,4 @@ +import { setWorkspaceMoveError } from '../lib/workspace-ui-store'; import { useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { WorkspaceStrip } from '../components/WorkspaceStrip'; @@ -111,3 +112,13 @@ export const CloseConfirm: Story = { await requireElement('#kill-confirm-title', 'kill confirmation'); }, }; + + +export const MoveRefused: Story = { + parameters: { primedWorkspaces: primed(['Workspace 1', 'Deploys'], 1) }, + play: async () => { + await requireElement('[data-workspace-tab]', 'workspace tab'); + setWorkspaceMoveError({ id: ws(1), reason: 'Approve or decline pending Tools before moving this Workspace' }); + await requireElement('#workspace-move-error', 'move refusal'); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c3d2ac11..990719cb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ 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 @@ -4777,6 +4780,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'} @@ -8851,6 +8859,7 @@ 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.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)): dependencies: @@ -8955,6 +8964,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 76d3f4492..e081a597d 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,30 +7,30 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, "docs/specs/dor-cli.md": 5950, - "docs/specs/dor-tool.md": 2100, + "docs/specs/dor-tool.md": 2700, "docs/specs/glossary.md": 3000, - "docs/specs/layout.md": 8750, + "docs/specs/layout.md": 8850, "docs/specs/mobile-terminal-ui.md": 1950, - "docs/specs/mouse-and-clipboard.md": 3750, - "docs/specs/notepad.md": 3950, + "docs/specs/mouse-and-clipboard.md": 3800, + "docs/specs/notepad.md": 4000, "docs/specs/pocket-app.md": 4900, "docs/specs/relay.md": 10200, "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4800, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, - "docs/specs/security-local.md": 2650, + "docs/specs/security-local.md": 2850, "docs/specs/security-remote.md": 5850, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 10200, - "docs/specs/terminal-context.md": 900, - "docs/specs/terminal-escapes.md": 3750, + "docs/specs/standalone.md": 10250, + "docs/specs/terminal-context.md": 1050, + "docs/specs/terminal-escapes.md": 3800, "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5950, + "docs/specs/transport.md": 6050, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7550, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 75b0863a8..6e08dae0d 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 // - lib/src/host/recovery.ts → sidecar/recovery.cjs // - lib/src/host/alert-store-host.ts → sidecar/alert-store.cjs @@ -52,6 +53,7 @@ if (!SIDECAR_RUNTIME_DEPS.includes('node-datachannel')) { 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: 'recovery.ts', out: 'recovery.cjs' }, { entry: 'alert-store-host.ts', out: 'alert-store.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..82f792204 --- /dev/null +++ b/standalone/scripts/dev-agent-browser-announce.test.mjs @@ -0,0 +1,25 @@ +// 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'); + assert.match(source, /const vitePort = Number\(new URL\(viteOrigin\).port\)/); + assert.ok(source.indexOf('await startVite();') < source.indexOf('const vitePort =')); +}); diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index e9e8b7bb7..ed08ac88d 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -144,6 +144,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), @@ -404,6 +406,11 @@ try { 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}'`); await startVite(); startSidecar(); + // Announce the actual bound port, including an OS-assigned one. + const vitePort = Number(new URL(viteOrigin).port); + process.stdout.write( + `\u001b]367;serve;${JSON.stringify({ port: vitePort, name: 'Dormouse dev', v: 1 })}\u001b\\`, + ); await openAgentBrowser(); log('running; Ctrl-C to stop'); } catch (err) { diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index fd29c4a6c..a96868f9d 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". @@ -72,6 +73,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 @@ -201,6 +206,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 98ae3dbb7..4a8cd3783 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1422,6 +1422,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 @@ -4372,6 +4390,7 @@ pub fn run() { capture_agent_recovery, take_recovery_commands, 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 d4dba2d90..85a1cdbe3 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolAnnounces } from '../../lib/src/lib/tool-announce-store'; import type { AlertRuntimeSnapshot } from 'dormouse-lib/lib/alert-manager'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { installWorkspaceRegistry, type WorkspaceRegistrySnapshot } from "./workspace-registry"; @@ -18,6 +19,8 @@ import type { PtyMarkedDetail, PtyReplayDetail, BurrowLink, + ToolControlResult, + ToolHostRequest, } from "dormouse-lib/lib/platform/types"; import { answerAskCommand, @@ -111,6 +114,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); @@ -247,6 +251,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", { @@ -441,6 +453,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // why the one-shot parser still needs the theme. const { id, data: text, requestId } = data as PtyReplayDetail; const parsed = new TerminalProtocolParser(themeColorProvider).process(text); + recordToolAnnounces(id, parsed.events); applyTerminalSemanticEvents(id, this.alertManager.applyReplay(id, requestId, parsed)); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData, requestId }); } else if (event === BURROW_RESULT_EVENT) { diff --git a/standalone/src/quit-confirm-store.test.ts b/standalone/src/quit-confirm-store.test.ts index d239d467a..e2db2cd41 100644 --- a/standalone/src/quit-confirm-store.test.ts +++ b/standalone/src/quit-confirm-store.test.ts @@ -17,7 +17,7 @@ import { import { chromeKeyboardHeld } from '../../lib/src/components/wall/chrome-keyboard-lease'; import { createWorkspace, closeWorkspace, moveWorkspace, renameWorkspace, resetWorkspaces, setActiveWorkspace } from 'dormouse-lib/lib/workspace-store'; -import { getWorkspaceUiSnapshot, resetWorkspaceUi, setPendingWorkspaceClose, setPendingWorkspaceMove, setRenamingWorkspace } from 'dormouse-lib/lib/workspace-ui-store'; +import { getWorkspaceUiSnapshot, resetWorkspaceUi, setPendingWorkspaceClose, setPendingWorkspaceMove, setRenamingWorkspace, setWorkspaceMoveError } from 'dormouse-lib/lib/workspace-ui-store'; import { isWorkspaceTransferPending, resetWindowSessionAggregator, setWorkspaceTransferPending } from 'dormouse-lib/lib/window-session-aggregator'; // The gate↔orchestrator seam itself is covered by quit.test.ts. @@ -164,10 +164,11 @@ describe("quit-confirm store", () => { setPendingWorkspaceClose({ id: 'workspace-1', char: 'a' }); setPendingWorkspaceMove({ id: 'workspace-1', char: 'b', iframeCount: 1, proceed: vi.fn() }); setRenamingWorkspace('workspace-1'); + setWorkspaceMoveError({ id: 'workspace-1', reason: 'Wait for the Tool browser to connect' }); setWorkspaceTransferPending('workspace-1', true); if (kind === 'confirm') openQuitConfirm(makeCtx()); else openQuitArchiveFailure('disk full', makeCtx()); - expect(getWorkspaceUiSnapshot()).toEqual({ pendingClose: null, pendingMove: null, renamingId: null }); + expect(getWorkspaceUiSnapshot()).toEqual({ pendingClose: null, pendingMove: null, renamingId: null, moveError: null }); expect(isWorkspaceTransferPending('workspace-1')).toBe(true); expect(chromeKeyboardHeld()).toBe(true); }); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 5256a35d3..1878d66f6 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolAnnounces } from '../../lib/src/lib/tool-announce-store'; import type { AlertRuntimeSnapshot } from 'dormouse-lib/lib/alert-manager'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { invoke as rawInvoke } from "@tauri-apps/api/core"; @@ -21,6 +22,8 @@ import type { PtyMarkedDetail, PtyReplayDetail, BurrowLink, + ToolControlResult, + ToolHostRequest, SessionFlushRequest, } from "dormouse-lib/lib/platform/types"; import type { @@ -203,6 +206,7 @@ export class TauriAdapter implements PlatformAdapter { // (docs/specs/standalone.md → "Routing"). const { id, data, requestId } = event.payload; const parsed = new TerminalProtocolParser(themeColorProvider).process(data); + recordToolAnnounces(id, parsed.events); applyTerminalSemanticEvents(id, this.alertManager.applyReplay(id, requestId, parsed)); // A listed exited buffer can contain a command-start with no finish. // Apply its exit after rebuilding the replay's watch, for either target @@ -452,6 +456,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/standalone/src/workspace-drag.test.ts b/standalone/src/workspace-drag.test.ts index b20d7e26b..57c162b71 100644 --- a/standalone/src/workspace-drag.test.ts +++ b/standalone/src/workspace-drag.test.ts @@ -9,8 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), - transferWorkspaceTo: vi.fn(async () => {}), - tearOutWorkspace: vi.fn(async () => {}), + transferWorkspaceTo: vi.fn(async (): Promise<import('./workspace-move').MoveOutcome> => ({ moved: true })), + tearOutWorkspace: vi.fn(async (): Promise<import('./workspace-move').MoveOutcome> => ({ moved: true })), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); vi.mock("./workspace-move", async (importOriginal) => ({ @@ -287,3 +287,17 @@ describe("releasing the drag", () => { expect(mocks.tearOutWorkspace).not.toHaveBeenCalled(); }); }); + + +it.each([false, true])('shows a blocked Tool move reason for drag transfer and tear-out (%s)', async transfer => { + hit = transfer ? { label: 'ws-2', x: 10, y: 4 } : null; + const move = transfer ? mocks.transferWorkspaceTo : mocks.tearOutWorkspace; + const reason = 'Approve or decline pending Tools before moving this Workspace'; + move.mockResolvedValueOnce({ moved: false, reason }); + onDropOnOtherWindow('ws-1', { clientX: 900, clientY: 8 }, false); + await settle(); + expect(getWorkspaceUiSnapshot().moveError).toEqual({ id: 'ws-1', reason }); + onDropOnOtherWindow('ws-1', { clientX: 900, clientY: 8 }, false); + await settle(); + expect(getWorkspaceUiSnapshot().moveError).toBeNull(); +}); diff --git a/standalone/src/workspace-drag.ts b/standalone/src/workspace-drag.ts index 94283dd6b..08071320e 100644 --- a/standalone/src/workspace-drag.ts +++ b/standalone/src/workspace-drag.ts @@ -6,7 +6,7 @@ import { currentWindowLabel } from "./window-label"; import { tearOutWorkspace, transferWorkspaceTo } from "./workspace-move"; import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; import { randomKillChar } from "dormouse-lib/components/KillConfirm"; -import { setPendingWorkspaceMove } from "dormouse-lib/lib/workspace-ui-store"; +import { setPendingWorkspaceMove, setWorkspaceMoveError } from "dormouse-lib/lib/workspace-ui-store"; import { workspaceTabRect } from "./workspace-tabs"; /** @@ -165,9 +165,15 @@ export function onDropOnOtherWindow( // Probed fresh rather than reusing the throttled answer: up to // HIT_TEST_THROTTLE_MS of pointer travel could otherwise choose the window. const hit = await probe(); - const move = hit && hit.label !== currentWindowLabel() - ? () => void transferWorkspaceTo(id, hit.label, { x: hit.x, y: hit.y }) - : () => void tearOutWorkspace(id, grab); + const move = () => { + setWorkspaceMoveError(null); + const moving = hit && hit.label !== currentWindowLabel() + ? transferWorkspaceTo(id, hit.label, { x: hit.x, y: hit.y }) + : tearOutWorkspace(id, grab); + void moving.then(outcome => { + if (!outcome.moved) setWorkspaceMoveError({ id, reason: outcome.reason }); + }).catch(error => setWorkspaceMoveError({ id, reason: error instanceof Error ? error.message : String(error) })); + }; // The one thing a move cannot carry is a plain iframe's document, Doored // ones included; it reopens at its saved URL. The user says so first, with // the same typed letter a kill takes (docs/specs/layout.md → Workspaces). diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 5f148fb4c..d957e5bc4 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -1,3 +1,4 @@ +import { getToolAnnounce, resetToolAnnounces } from 'dormouse-lib/lib/tool-announce-store'; import { applyTerminalSemanticEvents, snapshotTerminalState, removeTerminalPaneState, countRunningSessionsIn, getTerminalPaneState, isPaneOscDriven } from 'dormouse-lib/lib/terminal-state-store'; // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -740,7 +741,7 @@ describe("the target half", () => { } }); - it("unwinds the mount when adopt_done is refused, releasing the Sessions rather than killing them", async () => { + it("unwinds an expired arrival without preparing another move, even while a Tool is starting", async () => { const platform = fakePlatform(); const killPty = vi.spyOn(platform, "killPty"); const host = mocks.invoke.getMockImplementation()!; @@ -748,14 +749,14 @@ describe("the target half", () => { // The `ARRIVAL_MAX` watchdog retired the record while this window was // wedged between the drain and the mount: Rust has handed the shells // back to the source, so `adopt_done` finds no arrival to settle. - if (cmd === "adopt_done") arrivals = []; + if (cmd === "adopt_done") { + expect(getTerminalInstance("pane-a")).not.toBeNull(); + arrivals = []; + } return host(cmd, args); }); - // The Wall this window mounts for the arrival, with its release observable. - const released = vi.fn(); - registerWallHandle(stubWallHandle(WORKSPACE_ID, { - prepareWorkspaceTransfer: async () => prepared(released), - })); + const prepare = vi.fn(async () => { throw new Error("Wait for the Tool browser to connect before moving this Workspace"); }); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: prepare })); arrivals = [payload()]; initWorkspaceMoves(platform); await settle(); @@ -769,7 +770,8 @@ describe("the target half", () => { expect(getWorkspaceBootPlan(WORKSPACE_ID)).toEqual({}); // Its Sessions were released — the shells are the source's again — and // nothing was killed. - expect(released).toHaveBeenCalledTimes(1); + expect(prepare).not.toHaveBeenCalled(); + expect(getTerminalInstance("pane-a")).toBeNull(); expect(killPty).not.toHaveBeenCalled(); expect(mocks.invoke).not.toHaveBeenCalledWith("adopt_failed", expect.anything()); }); @@ -833,7 +835,7 @@ describe("the target half", () => { }); describe("a transfer's content", () => { - it("guards close during preparation and releases the guard if preparation fails", async () => { + it.each(['probe failed', 'Approve or decline pending Tools before moving this Workspace', 'Wait for the Tool browser to connect before moving this Workspace'])("returns a preparation refusal without retaining the close guard (%s)", async reason => { let rejectPrepare!: (error: Error) => void; registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: () => new Promise((_, reject) => { rejectPrepare = reject; }), @@ -841,8 +843,8 @@ describe("a transfer's content", () => { const moving = transferWorkspaceTo(WORKSPACE_ID, "ws-2"); expect(isWorkspaceTransferPending(WORKSPACE_ID)).toBe(true); expect(await tearOutWorkspace(WORKSPACE_ID, { x: 0, y: 0 })).toEqual({ moved: false, reason: "Workspace is already in flight" }); - const rejected = expect(moving).rejects.toThrow("probe failed"); - rejectPrepare(new Error("probe failed")); + const rejected = expect(moving).resolves.toEqual({ moved: false, reason }); + rejectPrepare(new Error(reason)); await rejected; expect(isWorkspaceTransferPending(WORKSPACE_ID)).toBe(false); }); @@ -1021,3 +1023,36 @@ describe("workspaceDropTarget", () => { expect(workspaceTabRect("gone")).toBeNull(); }); }); + + +it('restores volatile Tool browser state without publishing it in the durable Workspace', async () => { + resetToolAnnounces(); + const move = payload(); + const stable = { surfaceType: 'tool', command: 'pnpm storybook', toolRender: 'ab-screencast', toolPort: 'announced' }; + move.workspace.session.panes[0] = { ...move.workspace.session.panes[0], surfaceType: 'tool', command: 'pnpm storybook' }; + move.workspace.session.lathLayout = { + version: 1, tree: { root: { kind: 'leaf', id: 'pane-a' } }, + leafMeta: { 'pane-a': { component: 'tool', tabComponent: 'tool', title: 'Storybook', params: stable } }, + }; + const browser = { ...stable, url: 'http://localhost:6006/edited', session: 'browser-to-keep', renderMode: 'ab-screencast', toolAnnouncedPort: 6006 }; + const announce = { port: 6006, name: null, key: null, dehydrate: false, persist: null }; + arrivals = [Object.assign(move, { tools: { 'pane-a': browser }, terminals: { 'pane-a': { serialized: '', toolAnnounce: announce } } })]; + const plans = await bootFromTearOut(fakePlatform()); + expect(plans?.[WORKSPACE_ID].restoredLathLayout).toMatchObject({ leafMeta: { 'pane-a': { params: browser } } }); + expect(getToolAnnounce('pane-a')).toEqual(announce); + expect(move.workspace.session.lathLayout).toMatchObject({ leafMeta: { 'pane-a': { params: stable } } }); + expect(JSON.stringify(move.workspace.session)).not.toContain('browser-to-keep'); + resetToolAnnounces(); +}); + +it('sends Tool browser bindings only with volatile transfer content', async () => { + initWorkspaceMoves(fakePlatform([], { marks: { 'pane-a': 42 } })); + const move = { ...prepared(), tools: { 'pane-a': { surfaceType: 'tool', session: 'browser-to-keep' } } }; + registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => move })); + void transferWorkspaceTo(WORKSPACE_ID, 'ws-2'); + await contentSent(); + const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === 'transfer_workspace_content')!; + expect(args).toMatchObject({ content: { tools: move.tools } }); + const [, persisted] = mocks.invoke.mock.calls.find(([cmd]) => cmd === 'transfer_workspace')!; + expect(JSON.stringify(persisted)).not.toContain('browser-to-keep'); +}); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 51c164c76..69220039b 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -1,3 +1,6 @@ +import { dismissWorkspaceUi } from 'dormouse-lib/lib/workspace-ui-store'; +import { restoreToolParams } from 'dormouse-lib/components/wall/tool-transfer'; +import { recordToolAnnounce } from 'dormouse-lib/lib/tool-announce-store'; import { pauseAlertDelivery, resumeAlertDelivery, snapshotAlertDelivery, restoreAlertDelivery, forgetAlertDelivery } from 'dormouse-lib/lib/alert-delivery-state'; import { setIncomingAlertPolicy } from 'dormouse-lib/lib/alert-delivery-policy'; import type { AlertRuntimeSnapshot } from 'dormouse-lib/lib/alert-manager'; @@ -133,7 +136,7 @@ async function startMove( prepared = await handle.prepareWorkspaceTransfer(); } catch (error) { setWorkspaceTransferPending(workspaceId, false); - throw error; + return { moved: false, reason: reasonOf(error) }; } return handOff(prepared, command, args(prepared.payload)); } @@ -189,6 +192,7 @@ async function handOff( const marks = await pendingMarks; if (inFlight.get(workspaceId)?.prepared === prepared) { // else handed back while we waited: nothing to send const content = await captureTransferContent(terminalIds, marks); + if (prepared.tools) content.tools = prepared.tools; if (inFlight.get(workspaceId)?.prepared === prepared) { // else handed back while serializing const alertRuntime = new Map<string, AlertRuntimeSnapshot>(); inFlight.get(workspaceId)!.alertRuntime = alertRuntime; @@ -423,6 +427,7 @@ async function planArrival( for (const [id, terminal] of transferred) { if (terminal.alertDelivery) restoreAlertDelivery(id, terminal.alertDelivery); if (terminal.semanticState) restoreTransferredTerminalState(id, terminal.semanticState); + if (terminal.toolAnnounce) recordToolAnnounce(id, terminal.toolAnnounce); } const live = await collectLivePtys(platform, { // The token rides through Rust to the sidecar's `list` and comes back on the @@ -460,6 +465,7 @@ async function planArrival( ptyIds, terminalGrids, }); + if (payload.tools) restoreToolParams(result, payload.tools); // The notes travelled in the payload rather than through the archive: a move // is not a closure (`docs/specs/notepad.md` → "Closure"). hydrateNotepadFromVolatile(payload.notepad, payload.allIds); @@ -478,6 +484,7 @@ async function planArrival( * Workspace this window never owned. */ function discardArrival(platform: PlatformAdapter, payload: MovePayload): void { + dismissWorkspaceUi(payload.workspaceId); for (const id of payload.allIds) { removeSurface(id); forgetHelper(id); } for (const id of new Set([...payload.terminalIds, ...payload.workspace.session.panes.map((pane) => pane.id)])) { if (terminalRegistry.has(id)) releaseSession(id); @@ -534,11 +541,9 @@ async function adoptWorkspace(platform: PlatformAdapter, payload: MovePayload): // The `ARRIVAL_MAX` watchdog had already expired the record and handed // the shells back, and the source kept the Workspace. Left mounted here // too, it would be live in two windows and persisted by both. A mounted - // Wall releases through its own transfer commit; the rest is the same - // unwind an unmounted arrival gets. + // or unmounted arrival releases from the received payload: preparing a + // new move here can fail on a Tool that is still starting. console.error("[workspace-move] adopt_done refused; unwinding the mount", err); - const handle = getWallHandle(id); - if (handle) (await handle.prepareWorkspaceTransfer()).commit(); discardArrival(platform, payload); } } catch (err) { 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 2bc5f4de5..494402ef1 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'; @@ -26,6 +27,8 @@ import type { DorControlRequest } from './pty-manager'; import { dorWorkspaceRefusal } from './dor-workspace-guard'; 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, @@ -207,6 +210,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 | null) => void>(); export function onProcessedPtyData(listener: ProcessedDataListener): () => void { processedDataListeners.add(listener); @@ -285,7 +289,20 @@ function createOwnerPtyStream(id: string): ProcessedPtyStream { return createProcessedPtyStream({ colorProvider: themeColorProvider, onEvents(events) { - applyTerminalProtocolEvents(alertManager, id, events); + // `applyTerminalProtocolEvents` records announcements into renderer state + // this process cannot reach, so the router withholds them and forwards + // them to the webviews instead — and only the rare chunk that carries one + // pays for the filtered copy. + const hasAnnounce = events.some(event => event.kind === 'toolAnnounce'); + applyTerminalProtocolEvents(alertManager, id, hasAnnounce ? events.filter(event => event.kind !== 'toolAnnounce') : events); + // A null announcement retires the previous command's hint in the owning + // webview. Keep starts and serves in parse order, including one chunk. + for (const event of events) { + if (event.kind === 'toolAnnounce' || (event.kind === 'semantic' && event.event.type === 'commandStart')) { + const announce = event.kind === 'toolAnnounce' ? event.announce : null; + for (const listener of toolAnnounceListeners) listener(id, announce); + } + } const semanticEvents = collectTerminalSemanticEvents(events); alertManager.applyTerminalSemanticEvents(id, semanticEvents); if (semanticEvents.length > 0) { @@ -540,6 +557,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 | null) => { + 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); @@ -558,6 +579,7 @@ export function attachRouter( return () => { removeProcessedListener(); removeSemanticListener(); + toolAnnounceListeners.delete(onToolAnnounce); removeExitListener(); removeAlertListener(); }; @@ -743,6 +765,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..8c7d8bd58 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 | null } | { 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/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index bb2f222a6..e070df4f4 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -34,6 +34,7 @@ vi.mock('../src/peer-link', () => ({ /** The pty host, as far as a disposal is concerned: what it was asked, what it * answered, and the order the archive write and the kills happened in. */ const ptys = vi.hoisted(() => ({ + callbacks: null as { onData(id: string, data: string): void; onExit(id: string, code: number): void } | null, cwd: null as string | null, cwdAsked: [] as string[], cwdWait: null as Promise<void> | null, @@ -44,6 +45,10 @@ const ptys = vi.hoisted(() => ({ vi.mock('../src/pty-manager', async (importOriginal) => ({ ...(await importOriginal<typeof import('../src/pty-manager')>()), + addCallbacks: (callbacks: NonNullable<typeof ptys.callbacks>) => { + ptys.callbacks = callbacks; + return () => {}; + }, spawn: (id: string) => { ptys.buffered.set(id, { alive: true }); }, getBufferedPtys: () => new Map(ptys.buffered), getCwd: async (id: string) => { @@ -131,6 +136,25 @@ it('reports rejected helper creation as an exited terminal', () => { } finally { disposable.dispose(); } }); +it('forwards command-start resets and Tool announcements in stream order only to the owning webview', () => { + const owner = fakeWebview(); + const other = fakeWebview(); + const first = router.attachRouter(owner.channel, {}); + const second = router.attachRouter(other.channel, {}); + try { + owner.send({ type: 'dormouse:init' }); + other.send({ type: 'dormouse:init' }); + owner.send({ type: 'pty:spawn', id: 'tool-epoch', options: { cwd: '/repo' } }); + ptys.callbacks!.onData('tool-epoch', '\x1b]367;serve;{"port":6006}\x07\x1b]633;C\x07\x1b]367;serve;{"port":6007}\x07'); + const announcements = owner.posted.filter(message => message.type === 'terminal:toolAnnounce'); + expect(announcements.map(message => message.announce?.port ?? null)).toEqual([6006, null, 6007]); + expect(other.posted.filter(message => message.type === 'terminal:toolAnnounce')).toEqual([]); + } finally { + first.dispose(); + second.dispose(); + } +}); + describe('session flush', () => { it('waits for ordered host writes after the webview acknowledges its flush', async () => { vi.useFakeTimers(); diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 87c523500..370d4ea8c 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -446,5 +446,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/" } ]