From 3667ae60efb9241a82b4ad8a603b97b9f5627dfc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 18:30:13 -0700 Subject: [PATCH 01/47] chore(spec-lint): split AGENTS.md's budget into prose plus a per-index-line cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single whole-file ceiling made a new spec's index line compete with the Specs/Spec lifecycle prose, and the index always lost — it is the easier thing to shave, and shaving it makes routing lines vaguer rather than shorter. A budget may now be a number (whole file) or {prose, indexLine}. Authored in the working tree during the dor-tool design session; committed separately so it is not buried under an unrelated docs message. --- scripts/spec-lint.mjs | 60 ++++++++++++++++++++++++++++++---- scripts/spec-word-budgets.json | 5 ++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/scripts/spec-lint.mjs b/scripts/spec-lint.mjs index a564dec8..ec9db347 100644 --- a/scripts/spec-lint.mjs +++ b/scripts/spec-lint.mjs @@ -36,7 +36,10 @@ * 10. Word-budget ratchet: every checked file stays under its budget in * scripts/spec-word-budgets.json. Growth past the budget fails; the fix * is to cut, or to raise the budget deliberately in the same PR. Budgets - * carry small headroom so routine edits don't trip it. + * carry small headroom so routine edits don't trip it. A budget may be a + * number (whole file) or {prose, indexLine} — the split form AGENTS.md + * uses, capping its conventions prose and each spec-index line + * separately so adding a spec never costs another spec's routing line. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, dirname, normalize } from 'node:path'; @@ -289,15 +292,60 @@ for (const spec of foldCheckedFiles) { // --- Check 10: word-budget ratchet ------------------------------------------ const BUDGETS_FILE = 'scripts/spec-word-budgets.json'; const budgets = JSON.parse(read(BUDGETS_FILE)); +const countWords = (text) => text.split(/\s+/).filter(Boolean).length; +const raiseHint = + `cut, or raise the budget in ${BUDGETS_FILE} deliberately in the same PR`; + +/** + * AGENTS.md's spec index — the `- **\`path\`** — …` bullets between "## Specs" + * and "## Design". Scoped to that slice on purpose: the Architecture section + * uses the same bullet shape for package paths. + */ +function specIndexLines(text) { + const slice = text.split('\n## Specs')[1]?.split('\n## Design')[0] ?? ''; + return slice.split('\n').filter((l) => /^- \*\*`/.test(l)); +} + +/** + * A split budget caps AGENTS.md's two halves independently, because they grow + * for unrelated reasons and the pooled form made them compete: the index grows + * only when a spec is added and every line of it is routing an agent uses, so + * charging a new spec's line against convention prose taxed the wrong half — + * and the cheapest way to pay was to make the line vaguer, not the file smaller. + */ +function checkSplitBudget(rel, text, budget) { + const index = specIndexLines(text); + const prose = countWords(text) - index.reduce((n, l) => n + countWords(l), 0); + if (prose > budget.prose) { + problems.push( + `${rel}: ${prose} words of prose (excluding the ${index.length}-line spec ` + + `index) exceeds its ${budget.prose}-word budget — ${raiseHint}`, + ); + } + for (const line of index) { + const words = countWords(line); + if (words > budget.indexLine) { + const name = /`([^`]+)`/.exec(line)?.[1] ?? line.slice(0, 40); + problems.push( + `${rel}: spec-index line for ${name} is ${words} words, over the ` + + `${budget.indexLine}-word per-line cap — ${raiseHint}`, + ); + } + } +} + for (const rel of allFiles) { - const words = read(rel).split(/\s+/).filter(Boolean).length; + const text = read(rel); const budget = budgets[rel]; if (budget === undefined) { - problems.push(`${BUDGETS_FILE}: no budget for ${rel} — add one (currently ${words} words)`); - } else if (words > budget) { problems.push( - `${rel}: ${words} words exceeds its ${budget}-word budget — cut, ` + - `or raise the budget in ${BUDGETS_FILE} deliberately in the same PR`, + `${BUDGETS_FILE}: no budget for ${rel} — add one (currently ${countWords(text)} words)`, + ); + } else if (typeof budget === 'object') { + checkSplitBudget(rel, text, budget); + } else if (countWords(text) > budget) { + problems.push( + `${rel}: ${countWords(text)} words exceeds its ${budget}-word budget — ${raiseHint}`, ); } } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 6d189eed..97456122 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,8 @@ { - "AGENTS.md": 2750, + "AGENTS.md": { + "prose": 2050, + "indexLine": 55 + }, "SELF_HOST.md": 7100, "docs/specs/alert.md": 7350, "docs/specs/alert.rationale.md": 850, From d5a844c5291d29564edf68a8a859f1e38188dfd9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 18:30:38 -0700 Subject: [PATCH 02/47] =?UTF-8?q?docs(dor-tool):=20re-cut=20the=20design?= =?UTF-8?q?=20=E2=80=94=20opt-in=20identity,=20port-triggered=20serving,?= =?UTF-8?q?=20repo=20trust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites everything below the fold, and splits the evidence into a paired rationale file per the new house form. - Ledger re-cut to B1 (the atom) / B2 (OSC 367) / B3 (ab-* rendering) / C (glob table + `dor open`). The atom moves ahead of `dor open`, whose viewer pages, glob table, and loopback file endpoint are a parallel feature rather than the layer the atom stands on. - Identity is opt-in only, via `dormouse.yml`'s `prespawn_dedupe`. No key is derived from command+cwd: command strings are spelling-unstable, `dor ensure` already is command+cwd idempotency, and declaring a tool to get a short name should not silently switch deduping on. - Dedupe at spawn time only; a runtime re-key re-labels and never kills. - The port scan is the primary serving trigger and OSC 367 the disambiguator. Under parallel-worktree contention an announcement states intent while the scan states what actually bound — storybook drifts to 6007, vite under strictPort does not start at all. - New Trust section: repo-local `dormouse.yml` is inert until a gesture in Dormouse's own chrome approves the repo root. Path-level, never hashed. - `dor tool` is no longer routed to the VS Code editor: a verb returning a handle on one host and a note on another is one command with two types. dor-tool.md's budget rises 2450 -> 2800 for three new normative sections, plus 1100 for the new rationale file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XDByKnEGLaNj45bNZjzjeF --- AGENTS.md | 4 +- docs/specs/dor-tool.md | 493 +++++++++++++++++-------------- docs/specs/dor-tool.rationale.md | 41 +++ scripts/spec-word-budgets.json | 3 +- 4 files changed, 319 insertions(+), 222 deletions(-) create mode 100644 docs/specs/dor-tool.rationale.md diff --git a/AGENTS.md b/AGENTS.md index ef762d9b..1d6394c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Use one implementation map per spec: either an exhaustive `Files` / `Code Map` s - **`docs/specs/theme.md`** — Theme system: the two-layer CSS variable strategy, the consumed-token resolver, the terminal color contract, and the theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI staged onto every Dormouse terminal's `PATH`: bundling + env contract, `spawnAndCapture` rules for external binaries, control-socket plumbing, the Surface handle model, and the command set. - **`docs/specs/dor-browser.md`** — The unified browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, and the iframe proxy + CSP boundaries. Builds on the handle model in dor-cli.md. -- **`docs/specs/dor-tool.md`** — Dor Tools (design-stage): the `tool` Surface — a terminal and a browser on one Session spine — with its capability-gated verb model and OSC 367 contract. Only the capability gating is implemented. +- **`docs/specs/dor-tool.md`** — Dor Tools (design-stage): the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs, the port scan that grows the browser, `dormouse.yml` identity, and the repo-trust gate. Only the capability gating is implemented. Only the capability gating is implemented. - **`docs/specs/vscode.md`** — VS Code host layer: webview hosting, webview ↔ Workspace mapping, persistence ordering, theme integration, CSP, and the build/dogfood pipeline. The transport protocol it speaks lives in transport.md. - **`docs/specs/standalone.md`** — Standalone (Tauri) host layer: the Rust ↔ Node-sidecar bridge, boot sequence, AppBar, persistence, shutdown ordering, and the build/dev workflow. The transport protocol it speaks lives in transport.md. - **`docs/specs/auto-update.md`** — Standalone auto-update: check → user-approved download → install-on-quit, the Baseboard update notice, Windows sidecar teardown, and per-platform quit behavior. @@ -90,7 +90,7 @@ Specs are written ahead of the code on purpose: a new component's spec starts as - **Reservations.** When unbuilt design constrains present code — a reserved wire field, a reserved ref grammar, an additive-evolution guarantee — state that constraint in the body, marked `Reserved:`, pointing at the `## Future` item it serves. Test: if deleting the sentence would let someone break future compatibility today, it belongs in the body. - **Promotion is part of done.** Implementing a staged item is not finished until its text moves above the fold — rewritten from "will" to "is", with `Source of truth:` added — and the built portion is deleted from `## Future`. Never leave completed plan text (build orders, phase lists) below the fold; delete it — git history keeps the record. -The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, one implementation map per spec, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. It also ratchets file size: every spec, rationale file, and this file carries a word budget in `scripts/spec-word-budgets.json`, and growth past it fails the lint — cut, or raise the budget deliberately in the same PR. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. +The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, one implementation map per spec, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. It also ratchets file size: every spec, rationale file, and this file carries a word budget in `scripts/spec-word-budgets.json`, and growth past it fails the lint — cut, or raise the budget deliberately in the same PR. This file's budget is split — conventions prose and each spec-index line are capped separately, so adding a spec never costs another spec's routing line. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. Advisory spec/comment reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 26b53ac1..ab192eec 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -6,23 +6,24 @@ > See `docs/specs/glossary.md` for canonical Surface / Session / Pane > vocabulary. Builds on `docs/specs/dor-cli.md` (surface handles, the `ensure` -> spawn path) and `docs/specs/dor-browser.md` (render modes, the iframe proxy); -> this design subsumes the "plugin/backend target axis" staged in that spec's -> Future. +> spawn path) and `docs/specs/dor-browser.md` (render modes, the iframe proxy, +> the Dev-Server Chip port scan); this design subsumes the "plugin/backend +> target axis" staged in that spec's Future. **Pitch**: a Dor Tool is a console app that opens a web port. Dormouse frames it in a pane where the human and the agent both see it and both drive it — the human clicks, the agent sees the click; the agent types, the human sees the -typing. No SDK, no protocol: print one escape sequence, read one env var. +typing. No SDK, no protocol, and in the common case no cooperation: Dormouse +already watches the ports its Sessions bind. ## Capability gating Phase A of the ledger below is implemented, and nothing in it is -`tool`-specific, so it is documented where it belongs rather than restated -here: the capability model and its `hasTerminal` / `hasBrowser` predicates in -`docs/specs/glossary.md` → Panes and Surfaces, the `dor list --json` -`has_terminal` / `has_browser` row fields and the matching `has no terminal` / -`has no browser` failures in `docs/specs/dor-cli.md` → `dor list`. +`tool`-specific, so it is documented where it belongs: the capability model and +its `hasTerminal` / `hasBrowser` predicates in `docs/specs/glossary.md` → Panes +and Surfaces, the `dor list --json` `has_terminal` / `has_browser` row fields +and the matching `has no terminal` / `has no browser` failures in +`docs/specs/dor-cli.md` → `dor list`. Source of truth: `dor/src/commands/types.ts` (the `KIND_CAPABILITIES` table both predicates read, and `SURFACE_KINDS` derived from it so `--kind` parsing @@ -39,258 +40,312 @@ What this spec still owes is the kind that has both — see capability refactor, is implemented; see [Capability gating](#capability-gating).) -- **B — `dor open`.** User-level table + dispatch only: entries resolve to a - terminal command (the `ensure`/`split` machinery) or an existing **browser - surface** pointing at a host-served viewer page (the iframe-proxy path). No - OSC, no atom, **nothing new persisted** — viewers are plain browser - surfaces, so C1 requires zero snapshot migration. The VS Code route (see - [The table](#the-table)) is complete here, permanently for v1. -- **C0 — OSC 367 + header chip.** Parse/strip/register/sanitize for the serve - verb, plus the inert header-chip affordance in ordinary terminals - (announcement lights a chip; clicking connects via the existing port-connect - flow). Ships standalone value — any announcing tool gets a clickable chip - before the atom exists — and exercises the entire security gate with minimal - UI surface. -- **C1 — the tool atom.** `dor tool`, announce-minted upgrade-in-place, - identity dedupe, the console toggle, `surfaceType: 'tool'`, kill/teardown - (forcing the general per-surface teardown hook `docs/specs/dor-browser.md` - already stages), args-only cold restore. Standalone runs the pipeline behind - a `dormouse.flags.tools` flag; `dor open` is re-plumbed onto the real path. +- **B1 — the tool atom.** `dor tool` in both forms, repo-local `dormouse.yml` + with `prespawn_dedupe`, the [trust](#trust) gate, port-triggered + [upgrade-in-place](#serving), `surfaceType: 'tool'`, the terminal toggle, + kill/teardown (forcing the per-surface teardown hook + `docs/specs/dor-browser.md` stages), args-only cold restore. No OSC, no + prespawn process, no glob table. Standalone gates it on + `dormouse.flags.tools` (`lib/src/lib/feature-flags.ts`). +- **B2 — OSC 367 `serve` + header chip.** Parse/strip/register/sanitize, the + runtime re-key, and the inert chip in ordinary terminals. Ships value alone — + any announcing tool gets a clickable chip — and exercises the security gate + with minimal UI. `dev:standalone:ab` is the first announcer. +- **B3 — `ab-*` rendering for tools.** Agent GUI-driving of a tool's browser. + Its CLI mechanism, `dor ab --surface surface:N `, is shipped + (`docs/specs/dor-cli.md` → Agent-Browser Surface Addressing); what remains is + pointing it at a `tool` Surface and letting a tool declare its renderer. +- **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"`. Covers every stateless tool with - no new API and no Windows question (a stateless tool can just be killed). + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, + no Windows question. - **D2 — dehydrate/rehydrate.** The `367;dehydrate` verb + - `DORMOUSE_DEHYDRATE` per the contract below (designed day 1; the `dehydrate` - flag is reserved in the serve payload from C0). The Windows graceful-stop - answer is needed here only. -- **Later** — `ab-*` browser rendering: agent GUI-driving of a tool's browser - via the agent-browser render modes. The CLI mechanism it needed — - surface-handle addressing, `dor ab --surface surface:N ` — is shipped - (`docs/specs/dor-cli.md` → Agent-Browser Surface Addressing) and already - reaches any agent-browser-rendered Surface; what remains here is pointing it - at a `tool` Surface's browser. Pocket/remote browser - view (rides the browser-surface staging in `docs/specs/remote-api.md`; - reserve the kind on the wire now). The VS Code full pipeline. An in-pane - terminal/browser strip (decide against the glossary's reserved - multiple-Surfaces-per-Pane). A `boots: web` table hint if the terminal flash - grates. `--has terminal` / `--has browser` filters for `dor list`. A - pre-spawn dedupe fast path. + `DORMOUSE_DEHYDRATE`; the `dehydrate` flag is reserved in the serve payload + from B2. The Windows graceful-stop is needed here only. +- **Later** — `prespawn_*` beyond the dedupe literal: a computed key, and + `prespawn_port`. Pocket/remote browser view (rides the browser-surface + staging in `docs/specs/remote-api.md`; reserve the kind on the wire now). The + VS Code pipeline. An in-pane terminal/browser strip (decide against the + glossary's reserved multiple-Surfaces-per-Pane). A `boots: web` hint if the + terminal flash grates. `--has terminal` / `--has browser` for `dor list`. ### The tool capability set -`tool` = terminal + browser, the third kind added to the live gating. Verbs -stay gated on the capability they need, exactly as glossary.md defines it, and -the browser verbs stay renderMode-gated as for browser Surfaces (an -iframe-rendered tool cannot be agent-driven). `kill` / `rename` stay universal. -Kinds remain **disjoint** for `dor list --kind`. +`tool` = terminal + browser, the third kind in the live gating. Verbs stay +gated on the capability they need, and browser verbs stay renderMode-gated (an +iframe-rendered tool cannot be agent-driven). `kill` / `rename` stay universal; +kinds remain **disjoint** for `dor list --kind`. - **Identity**: a tool Surface's id is its SessionId (I1 extends to tools). Capabilities and render modes change over its life without changing identity - — the tool counterpart of I10, and stronger than browsers have today. -- **Render swaps bypass `replaceSurface`.** A tool's browser is a param of - the tool's own leaf: swapping `iframe` ⇄ `ab-*` mutates `renderMode` in - place and never routes through the browser-surface replacement path. That is - what makes the invariant above true — the same gesture that replaces a - browser Surface's id (I10) merely updates a tool's params. + — the tool counterpart of I10, stronger than browsers have today. +- **Render swaps bypass `replaceSurface`.** A tool's browser is a param of its + own leaf: swapping `iframe` ⇄ `ab-*` mutates `renderMode` in place instead of + routing through the browser-surface replacement path, which is what makes the + invariant above true. - **Axes**: the tool column of the six-axis table reads terminal-column - semantics for its terminal and browser-column semantics for its browser. + semantics for its terminal, browser-column for its browser. - **Activity**: full machine via the PTY; WATCHING defaults off for tool-spawned commands (`lib/src/lib/watched-commands.ts` rules). -- **Untouched**: input to **either** capability touches — the first - browser-side interaction arms kill-confirm, so an unsaved scratch tool gets - the confirmation letter while an idle just-opened viewer dies silently. +- **Untouched**: input to **either** capability touches, so the first + browser-side interaction arms kill-confirm — an unsaved scratch tool gets the + confirmation letter while an idle just-opened viewer dies silently. -### OSC 367 +### Declaring tools -`DOR` on a phone keypad. Verb-multiplexed (the OSC 633 pattern): one registry -entry, extensible without burning numbers. Tools emit ST; the parser accepts -BEL. Registered in `docs/specs/terminal-escapes.md`, parsed and stripped at the -PTY data boundary (`lib/src/lib/terminal-protocol.ts`), replay-filtered like -the other reports, payload sanitized and size-capped under the same rules -`docs/specs/alert.md` applies to OSC 9/99/777. +A repo declares its tools in a `dormouse.yml` at its root: a name → entry map +whose only required field is the command. -``` -ESC ] 367 ; serve ; {"port":4242,"name":"…","identity":"…","dehydrate":true,"persist":"respawn","v":1} ESC \ -ESC ] 367 ; dehydrate ; {"v":1, …} ESC \ +```yaml +tools: + storybook: + run: pnpm storybook + prespawn_dedupe: [storybook, $PROJECT_ROOT] ``` -- `serve` — the announcement. `port` (host derives - `http://localhost:/`), optional `name` (feeds the existing - title-candidates channel of `docs/specs/terminal-state.md`; priority stays - user pin > announce name > command), optional `identity` (dedupe key, below), - `dehydrate` capability flag, `persist` restart policy (`respawn` default | - `never`), contract version. **Re-emittable, last-write-wins** — a scratch - tool that saves re-announces with its file as identity. -- `dehydrate` — emitted on the graceful-stop signal; captured, size-capped, - stored in the pane's persisted params. -- **No third verb, ever.** Titles are OSC 0/2, progress is OSC 9;4: the - existing escape registry is the rest of the API. The moment a `progress` or - `title` verb exists, tools have grown a protocol and the pitch is false. -- Transport: ssh-transparent (the reason this is an OSC, not a control-socket - call — the socket does not exist over ssh); tmux swallows unknown OSCs - without `allow-passthrough` (tool-author docs, one line). Safe to emit - unconditionally — well-behaved terminals drop unknown OSCs, so no capability - sniffing is needed; checking `DORMOUSE_SURFACE_ID` is an optimization only. -- Before freezing: sweep xterm ctlseqs and the iTerm2/kitty/WezTerm/ConEmu - private ranges to confirm 367 is clean. Runners-up: 3676 (`DORM`), 4242. +- **`run`** — typed into the spawned shell exactly as `dor ensure` types one. +- **`prespawn_dedupe`** — the dedupe key, evaluated before anything spawns (see + [Identity and dedupe](#identity-and-dedupe)). Optional; absence means no + dedupe. +- **`dormouse.yml` holds static facts; OSC 367 carries what changes at + runtime.** A title, or a key that changes when a scratch document is saved, + is [OSC](#osc-367), never a file field. +- **Never give `prespawn_dedupe` a second value shape.** `prespawn_*` is + reserved, and staged additions each take their own field name (rationale). +- **Must reject an unrecognized `$NAME` at parse**, never keep it as a literal + (rationale). Substitutions are a closed set: `$PROJECT_ROOT` (the directory + holding the declaring `dormouse.yml`) and `$CWD` (the caller's resolved PWD); + phase C adds argument substitution. +- **Must reject `$PROJECT_ROOT` in the phase-C user-global file**, where no + project root is defined. +- **Should warn on a repo-local key without `$PROJECT_ROOT`**, naming the file: + it dedupes across every checkout declaring that name. Warning, not error — a + repo-declared machine-wide singleton is legitimate. + +A bare scalar is one element (`prespawn_dedupe: clock`). + +### Identity and dedupe + +**A tool has an identity if and only if it was given one.** No key is derived +from the command, the cwd, or anything else the host can see; an entry with no +`prespawn_dedupe`, and every `dor tool -- `, spawns a fresh Surface +every time (rationale). + +- **Namespacing is host-enforced.** Keys compare within the tool identity the + host resolved from the spawn; the declared list is scope inside that + namespace, and a key's first element is never trusted as a tool name. + Without this the runtime re-key of [OSC 367](#osc-367) is an impersonation + primitive. +- **Dedupe at spawn time only.** A key matching a live Surface means the new + spawn is redundant by construction, so it never starts: the survivor is + revealed and its handle reported with an `ensure`-style reuse note. +- **A runtime re-key never dedupes.** It re-labels its own Surface and nothing + else — killing either side of a late collision would destroy work. +- **Scope is a slot, not a convention.** Keys are lists so parallel worktrees + differ by `$PROJECT_ROOT` rather than by an author remembering to concatenate + one in (rationale). +- **A key match only reveals**, never transferring state, grants, or input; the + worst case for a spoofed key is a wrong pane getting focus. +- **Races**: concurrent spawns serialize on the key; first wins. + +### Trust + +`dormouse.yml` is repo-controlled and its entries execute, so it is inert until +the repo is trusted. The phase-C user-global file needs none of this. + +1. **Keyed on the absolute repo root**, granted once and remembered. Denial is + remembered too, so a hostile repo cannot re-ask every invocation. +2. **Only a gesture in Dormouse's own chrome grants it** — a dialog naming the + repo and the command, never a prompt rendered as terminal output. The + [naked-prompt test](#cli) signals human intent but is not a security + boundary (rationale). Same shape as the local-approval ceremony in + `docs/specs/remote-security-model.md`. +3. **Agents cannot grant trust.** `dor tool ` against an untrusted repo + fails, telling the caller to have a human approve it. +4. **Anything `prespawn_*` is behind the same gate**, since it executes — the + natural implementation order, probe-then-prompt, is backwards. +5. **The phase-C glob table stays user-global and may only name user-global + tools.** Implicit dispatch reaching repo-local entries is the + `dor open README.md`-in-a-malicious-repo attack. +6. **Path-level, never content-hashed.** A `dormouse.yml` that changes under a + trusted root does not re-prompt (rationale). + +### Serving + +A tool's browser appears when Dormouse learns the tool is serving. Two triggers +feed one internal upgrade path; the atom does not care which fired. + +- **The port scan is the primary trigger** and the only one correct under + contention: it reports the port actually **bound**, where an announcement + states intent (rationale). Already shipped for the Dev-Server Chip, scanning + a Session's own process tree. +- **OSC 367 is the disambiguator, never the trigger.** It names *which* of a + multi-port tool's ports to frame, plus ssh transparency, a name, and a + runtime re-key. The hint names the port; the scan supplies the number. +- **Upgrade requires a tool-designated Session with its spawned command still + in the foreground** (see [Security](#security)). + +**Reserved:** a tool's URL is derived, never restored verbatim (see +[Persistence and hosts](#persistence-and-hosts)) — a precondition for +`prespawn_port` in the scope **dor-tools** [Later](#future), where Dormouse +picks a free port and exports +`DORMOUSE_TOOL_PORT`, so `storybook dev -p ${DORMOUSE_TOOL_PORT:-6006}` cannot +collide across worktrees. It supplements the scan rather than replacing it. + +Source of truth (shipped scan): `lib/src/components/wall/use-dev-server-ports.ts`, +`lib/src/components/wall/port-url.ts`, `lib/src/components/wall/connect-port.ts`. ### Lifecycle -**Spawn**: shell-hosted PTY through the `ensure` spawn path -(`dor/src/commands/ensure.ts` semantics: prompt-wait typing, per-shell quoting -via `dor/src/commands/shell-quote.ts`, command-exit tracking). Terminal -front from spawn — startup logs beat any spinner, and a command that never -announces is simply a terminal running a TUI: a complete outcome, not a -degraded one. A "TUI tool" is a registry entry whose command never announces. +**Spawn** — a shell-hosted PTY using the `ensure` spawn path's mechanics +(`dor/src/commands/ensure.ts`: prompt-wait typing, per-shell quoting via +`dor/src/commands/shell-quote.ts`, command-exit tracking) but **not** its +command+cwd matching. Terminal front from spawn; a command that never serves is +a terminal running a TUI, which is a complete outcome. -**Announce** → the same Surface **grows a browser** in place: no replacement, -no ref transfer, no new id — params gain the browser and `surfaceType` flips -by derivation. The pane flips to the browser; the terminal sits behind a -toggle on the header's far-left chip. Accepted: a fast tool flashes its -terminal for ~100ms; the flip animation makes it read as teaching the -terminal-plus-browser pairing. +**Serving** → the Surface **grows a browser in place**: no replacement, no ref +transfer, no new id — params gain the browser and `surfaceType` flips by +derivation. The pane flips to the browser, terminal behind the header's +far-left chip. Accepted: a fast tool flashes its terminal for ~100ms. -**Command exit** → the browser is retired and the pane flips back to the -terminal — a shell prompt above the tool's dying words, the correct debugging -posture. Re-running the command re-announces and revives the browser on the -same Surface. +**Command exit** → the browser retires and the pane flips back to a prompt +above the tool's dying words; re-running revives it on the same Surface. +**Kill** → universal, reaping the process and the browser's resources. -**Kill** → universal; reaps the process and the browser's backing resources. +`surfaceKindFromParams` (`lib/src/components/wall/browser-surface.ts`) is the +params → kind seam; its own comment warns the compiler cannot force the edit, +so a `tool` params shape carrying a `renderMode` classifies as `browser` until +taught otherwise. -### Identity and dedupe +### CLI -Identity is computed by the party that understands it — the tool. The host -cannot know that `README.md`, `./readme.md`, and a symlink are one document, or -that a diagram editor is ephemeral until saved and *becomes* its save-file -afterward. - -- **Scope**: dedupe matches on *(tool name as the host knows it from the - spawn)* × *(identity string from the OSC)*. The payload cannot claim to be a - different tool. Identityless tools are never deduped — scratch semantics. -- **On match**: the new spawn is redundant — graceful-stop it, tear the pane - down through the existing untouched-kill path (no confirmation; untouched by - construction), reveal the survivor, and report the survivor's handle with an - `ensure`-style reuse note. -- **Races**: concurrent spawns serialize at announce; first wins. -- **Containment**: an identity match only ever *reveals* a surface — it never - transfers state, grants, or input. Worst case for a spoofed identity is a - wrong pane getting focus. -- **Blessed pattern**: announce-and-let-Dormouse-dedupe. A tool doing VS - Code-style internal forwarding (second invocation hands off and exits) looks - to Dormouse like a failed tool; warn against it. +- **`dor tool -- `** — designate an arbitrary command as a tool. No + key, always a fresh Surface; distinct from `dor split` because it arms the + [serving](#serving) trigger. +- **`dor tool [args]`** — run a `dormouse.yml` entry with whatever + `prespawn_dedupe` it declares. +- **Takes over the calling pane only when the invocation is the sole command on + the line, the pane is at a prompt, and the pane is not already a tool.** + Typing a command at a prompt runs it there; splitting would be the surprise. + The test is on command shape, so `dor tool storybook --fresh` qualifies. +- **Must split focus-neutrally whenever that is unclear** — a script, an agent, + a compound line, a shell without integration, a busy pane — and return a + handle. +- **A keyed invocation that matches reveals and reports**, in both placements, + so the calling pane never appears to do nothing. +- `dor list`: rows report `kind: tool` with `render_mode`; the location column + shows the announce name, else the command; JSON carries command + cwd + url. -### Dehydrate and rehydrate +### OSC 367 -For tools announcing `dehydrate: true`. Reap on an idle threshold while -`Doored` / `Hidden` — including Surfaces of an inactive Workspace — never on -the minimize itself (reattach must not cost a boot every time), or under -memory pressure. The headline use case is Workspaces, not shutdown: a user can -keep many tools across many Workspaces, and an inactive Workspace full of -dehydratable tools drops to zero processes — relieving exactly the -parked-surface pressure the workspaces rollout projects -(`docs/specs/layout.md` Stage 4; `MAX_PARKED_SURFACES` in -`docs/specs/tiling-engine.md`). +`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, sanitized and size-capped under the rules +`docs/specs/alert.md` applies to OSC 9/99/777. -**This is an in-session mechanism.** The dehydrated payload lives with the -running host. Whether it survives a full host quit/restart follows each host's -session-persistence story (`docs/specs/transport.md`); this spec takes no -position on quit/restore — the Workspace case alone justifies the mechanism. +``` +ESC ] 367 ; serve ; {"port":4242,"name":"…","key":["…"],"dehydrate":true,"persist":"respawn","v":1} ESC \ +ESC ] 367 ; dehydrate ; {"v":1, …} ESC \ +``` -The flow: +- `serve` — refines what the scan found, never mints a tool. `port` names which + port to frame; `name` feeds the title candidates of + `docs/specs/terminal-state.md` (priority stays user pin > announce name > + command); `key` re-keys under the host's namespace; `dehydrate` capability + flag; `persist` (`respawn` default | `never`); contract version. + **Re-emittable, last-write-wins.** +- `dehydrate` — emitted on graceful stop; captured, size-capped, stored in the + pane's persisted params. +- **Never add a third verb.** Titles are OSC 0/2, progress is OSC 9;4; the + existing escape registry is the rest of the API. +- **Safe to emit unconditionally** — well-behaved terminals drop unknown OSCs, + so checking `DORMOUSE_SURFACE_ID` is an optimization only. ssh-transparency is + why this is an OSC and not a control-socket call; tmux needs + `allow-passthrough` (tool-author docs, one line). +- Before freezing: sweep xterm ctlseqs and the iTerm2/kitty/WezTerm/ConEmu + private ranges. Runners-up: 3676 (`DORM`), 4242. -1. Host sends the graceful-stop signal (grace window). -2. Tool emits `367;dehydrate;{json}` on the way out; host captures and - persists it. -3. Rehydrate = respawn the command with `DORMOUSE_DEHYDRATE` in the env, - rendered per-shell by the shell-quote module. +### Dehydrate and rehydrate -Degradation tiers, Lath-restore-token style: dehydrated state → bare args → -error. **Args-only restart is the mandatory floor; the dehydrate payload is -fidelity, never correctness.** The payload is small, versioned JSON — never a -document (the standalone session blob has bloated storage before). A hung tool -cannot block anything: request, grace, kill anyway, fall back to args. Open -question: the Windows graceful-stop (no SIGTERM to console apps; candidates: -an opt-in input sequence, or dehydrate-on-every-announce as the Windows -fallback). +For tools announcing `dehydrate: true`. Reap on an idle threshold while +`Doored` / `Hidden` — including an inactive Workspace's Surfaces — **never on +the minimize itself** (reattach must not cost a boot) or under memory pressure. +The headline case is Workspaces: an inactive one full of dehydratable tools +drops to zero processes, relieving the parked-surface pressure the workspaces +rollout projects (`docs/specs/layout.md` Stage 4; `MAX_PARKED_SURFACES` in +`docs/specs/tiling-engine.md`). -### CLI +**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. -- `dor tool [args]` — launch a registered tool by name. **Fresh - instance every time**; there is no `--key` — identity lives in the OSC. -- `dor open ` — sugar over `dor tool`: glob table → tool name → render - the template with the resolved absolute target → same launch path. Reuse - arrives via the standard identity convention: target-dispatched tools - announce `realpath(target)`. -- **cwd**: the caller's PWD resolves the argument (existing `--cwd` - machinery); the session's cwd is `dirname(target)` (or the target directory), - falling back to caller PWD only when the tool has no path target. Templates - render absolute paths, so the rendered command and cwd are deterministic - functions of the target — reuse and cold restore both become - caller-independent, and relative assets (a markdown image whose src is - `diagram.png`) resolve for the tool itself. -- `dor list`: rows report `kind: tool` with the browser's `render_mode`; the - location column shows the **target**, else the announce name (cwd and - localhost URLs are plumbing); JSON carries target + cwd + url. - -### The table - -User-level config **only** — a project-local table is arbitrary code execution -via `dor open README.md` in a malicious repo. Host-resolved, not CLI-resolved: -one source of truth, reachable by GUI gestures (file drop) as well as the CLI. -Two sections: named tools (name → command template) and glob rules (pattern → -tool name). Entries may dispatch to plain terminal commands (`*.*` → a pager) — -the atom is minted by the announcement, not by the table. - -Hosts: VS Code v1 routes `dor open` to the native editor (an in-pane md/code -viewer competes with the editor, which the native-first principle forbids) and -reports which route it took — an agent in VS Code loses sight of what it -opened, which is accepted for v1 and is the eventual argument for the full -pipeline there. +**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. ### Security -Auto-upgrade on announce is honored **only in tool-pipeline sessions and only -while the spawned command is the foreground process** (command-exit tracking -knows). Everywhere else — ordinary terminals, post-exit — the announcement -lights an inert affordance: a chip in the pane header, the Dev-Server Chip -pattern (the declared upgrade of the port scan), and clicking it is the user -gesture that connects. Output alone never creates surfaces. +Three gates, one per actor: + +1. **Repo-controlled config executes only after a human approves the repo** — + see [Trust](#trust). +2. **Only a tool-designated Session upgrades in place**, designation being the + `dor tool` spawn. Elsewhere the [serving](#serving) trigger is ignored and + the announcement lights the inert Dev-Server Chip, whose click is the + gesture that connects. **Output alone never creates surfaces.** +3. **Upgrade requires the spawned command to still be the foreground process**, + so an exited tool's pane cannot be re-pointed by whatever runs next. **Accepted risk — content-driven announce inside a blessed tool.** A tool rendering hostile bytes (a pager on a malicious file) passes the foreground -gate — the pager *is* the foreground process — so embedded bytes can announce -an attacker-chosen localhost port and re-point the browser at a service -already listening on the user's machine, under the tool's name. This is -accepted, deliberately: the blast radius is the dedupe containment applied to -ports — an announce only ever reveals/frames, it never transfers input -authority, grants, or state; the iframe proxy dials upstream as a fresh client -with no browser cookie authority; and the link-local/cloud-metadata SSRF guard -stands regardless. The residual is a mislabeled view of the user's own local -service, inert without further user gestures. If field reports change this -calculus, the escalations are gesture-gating re-announces that change the -port, or constraining the framed port to one owned by the session's process -tree — the latter is not the default because it would break tools that wrap -double-forking daemons (agent-browser-style), whose port the process-tree scan -cannot see. +gate, so embedded bytes can name an attacker-chosen port and re-point the +browser at a service already listening locally, under the tool's name. The +residual is a mislabeled view of the user's own service, inert without further +gestures (rationale). Escalations if that changes: gesture-gate re-announces +that move the port, or constrain the framed port to the session's process tree +— not the default, since it breaks tools wrapping double-forking daemons. ### Persistence and hosts `PersistedSurfaceType` gains `'tool'`; params -`{command, args, cwd, renderMode, url?, identity?, persist?}` -(`docs/specs/transport.md` owns the persisted shapes; -`lib/src/lib/session-types.ts`). The dehydrated payload is in-session state, -not part of the persisted params (see -[Dehydrate and rehydrate](#dehydrate-and-rehydrate)). Cold restore follows each -host's session-restore story: where sessions restore, `persist: "never"` rows -are dropped silently (a clock, a calculator) and the default respawns from bare -args — the args-only floor is what makes taking no position on quit/restore -safe. Remote: the terminal is a Session and rides protocol-v1 as-is; the -browser inherits the staged browser-surface gap. +`{command, args, cwd, renderMode, key?, persist?}` (`docs/specs/transport.md` +owns the persisted shapes; `lib/src/lib/session-types.ts`). Because `'tool'` is +a new type rather than an edit to an existing one, everything staged after B1 +is additive and no snapshot migration is required. + +**The URL is never persisted.** A tool's port is whatever it bound this time, +so the URL is re-derived from the [scan](#serving) after respawn. A restored +tool is a terminal running its command until it serves again — the same state a +cold spawn passes through. + +The dehydrated payload is in-session state, not persisted params. Cold restore +follows each host's session-restore story: `persist: "never"` rows drop silently +and the default respawns from bare args. Remote: the terminal rides protocol-v1 +as-is; the browser inherits the staged browser-surface gap. + +**`dor tool` is never routed to a native editor** — a verb returning a handle +on one host and a note on another is one command with two types (rationale). +Until the pipeline lands in VS Code it fails there, pointing at the editor; +handing a target to the host's editor is a separate additive verb. ### 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). +- **Does a key match a Surface whose command has exited?** `ensure` stops + matching a dead command because it targets arbitrary shells; a tool Surface + is dedicated, so that ambiguity does not exist. Leaning: reuse and re-run in + place, keeping position and scrollback. +- **Which renderer does a tool get?** B1 gives every tool an `iframe`; B3 needs + `ab-screencast`, and that choice is arguably the repo's rather than the + tool's, making it a `dormouse.yml` field. +- The [OSC 367](#osc-367) collision sweep; the Windows graceful-stop; the + dehydrate idle threshold; whether `persist` belongs in the announce or the + file; the final marketing noun. diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md new file mode 100644 index 00000000..e6e2345a --- /dev/null +++ b/docs/specs/dor-tool.rationale.md @@ -0,0 +1,41 @@ +# Dor Tools — Rationale + +> Informative evidence for [dor-tool.md](dor-tool.md), keyed by its headings; nothing here is normative. Design-stage: these are the arguments behind rules that are not yet implemented. + +## Declaring tools + +**Why `prespawn_*` spends a field name per addition instead of overloading one.** The tempting compaction is a single `prespawn_dedupe` that means a literal key when it is a list and a command to run when it is a string. YAML defeats it: authors habitually collapse a one-element sequence to a scalar, so `prespawn_dedupe: storybook` is exactly as natural a spelling of `["storybook"]` as it is of "run `storybook`". Guessing wrong in that direction *runs the tool* to answer a question about the tool — the same hazard that keeps a probe off any command not written to be probed. Distinct field names cost one word and remove the guess. + +**Why an unknown `$NAME` is an error rather than a literal.** The two failure modes are not symmetric. Forgetting the field entirely is loud: two tools start, fight over a port, and one visibly fails. A `$PROJECTROOT` typo kept as a constant string is silent, and it makes every checkout on the machine share one key — so the second worktree's tool kills the first. Parse-time rejection converts the silent destructive case into a startup error. + +## Identity and dedupe + +**Why no key is derived from the command.** Three independent reasons, any one sufficient: + +- Command strings are not stable keys. `pnpm storybook`, `pnpm run storybook`, and `pnpm storybook --quiet` are three strings for one tool, so a derived key would dedupe depending on spelling — and an agent generating the string will not spell it identically twice. Dedupe that fires unpredictably is worse than dedupe that never fires. +- `dor ensure` already *is* command+cwd idempotency. Absorbing it into `dor tool` would be a second spelling of a shipped command with fuzzier semantics. It would also inherit `ensure`'s hard dependency on OSC 633 shell integration, which fails outright on a shell without it; keeping it off the base path lets `dor tool` work there. +- Declaring a tool to get a short name is a different intention from wanting one instance of it. Coupling them means editing the config silently changes runtime behavior, and a hand-written key documents its own scope to the next reader where an implicit one cannot. + +**Why keys are lists rather than strings.** Parallel worktree development is the case that decides it. A tool-declared identity *string* — the obvious design, and what an earlier draft of this spec specified — has every checkout announcing `storybook`, so Dormouse treats the second worktree's server as a redundant spawn and kills it. A list makes scope a slot that `$PROJECT_ROOT` fills, rather than something an author must remember to concatenate into a string. + +**Why a runtime re-key cannot dedupe.** Spawn-time dedupe is safe because the loser is redundant by construction: it has done nothing yet. That stops being true once a key can change. A scratch document edited for ten minutes and then saved over a path another pane already holds is a genuine collision between two Surfaces that both hold work, and killing either destroys it. Re-labelling is the only resolution that cannot lose data. + +## Trust + +**Why the approval gesture must live in Dormouse's chrome.** The naked-prompt test reads the pane's own OSC 633 command line, which is a good signal of human intent and a poor security boundary: an agent holding the control token can `dor send` keystrokes that are byte-identical to typing. A dialog rendered as terminal output is forgeable the same way. A click in Dormouse's own UI is not reachable from inside a PTY, which is why the remote pairing ceremony uses the same shape. + +**Why trust is path-level rather than content-hashed.** Hash-pinning a `dormouse.yml` re-prompts on every edit and every `git pull` that touches the file. On a repo whose maintainer edits it regularly that is a dialog seen daily, and a dialog seen daily is answered reflexively — the control stops controlling anything. The residual, a trusted repo that later gains a hostile entry, is exposure already accepted from `package.json` scripts, `.vscode/tasks.json`, and git hooks in that same repo. The gate exists for first contact, and path-level trust covers first contact. + +## Serving + +**Why the scan outranks the announcement.** An announcement states intent; the scan states the result, and the two diverge exactly when it matters. Storybook launched with `-p 6006` in a second worktree auto-increments to 6007, so a hardcoded announcement would frame a port belonging to the *other* checkout. Vite under `strictPort: true` (`standalone/vite.config.ts`) does not start at all. The repo's existing answer to contention, `scripts/free-dev-port.mjs`, kills whatever holds the port. A trigger built on the announcement inherits all three problems; one built on the scan inherits none, and works on software nobody patched. + +**What the announcement is still needed for.** Multi-port tools. `pnpm dev:standalone:ab` binds vite, the dev bridge, and the sidecar's control socket, and no scan can guess which one to frame. ssh is the other case: the control socket does not exist across it, and neither does the host's view of the remote process tree. + +## Security + +**Why the content-driven announce risk is accepted.** The blast radius is the containment rule applied to ports: an announce reveals and frames, never transferring input authority, grants, or state. The iframe proxy dials upstream as a fresh client with no browser cookie authority, and the link-local/cloud-metadata SSRF guard stands regardless. Two properties of this design narrow it further than an announce-triggered one: the scan supplies the port, so an announced port that nothing bound frames nothing at all, and a runtime re-key cannot dedupe, so it cannot reach another pane. + +## Persistence and hosts + +**Why `dor tool` is not routed to the VS Code editor, despite native-first.** Every other `dor` verb returns a handle the caller can address afterwards. A verb that returns a handle on standalone and a "told the editor" note on VS Code is one command with two return types: `dor open x.md && dor read surface:N` would work on one host and silently no-op on the other, which is worse for an agent than the command not existing. Native-first governs chrome and theming; Dormouse already renders browser surfaces inside VS Code, as does the built-in Simple Browser. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 97456122..15796af9 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,8 @@ "docs/specs/dor-browser.rationale.md": 650, "docs/specs/dor-cli.md": 5900, "docs/specs/dor-cli.rationale.md": 600, - "docs/specs/dor-tool.md": 2450, + "docs/specs/dor-tool.md": 2800, + "docs/specs/dor-tool.rationale.md": 1100, "docs/specs/glossary.md": 3325, "docs/specs/layout.md": 9150, "docs/specs/layout.rationale.md": 700, From 8b47a63b8ec1b7521c725311de59c4cbe95a8c3c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 18:30:38 -0700 Subject: [PATCH 03/47] feat(host): dormouse.yml parsing, dedupe keys, and the repo-trust record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure half of the tool atom (docs/specs/dor-tool.md -> Declaring tools, Identity and dedupe, Trust). Node-side because the spec makes the registry host-resolved: one source of truth for `dor tool` and any later GUI gesture, and a caller cannot hand the host a command while claiming the file authorized it. The yaml dependency stays in the host bundle. - `tool-registry.ts` parses entries, validates the closed substitution set, and renders keys. An unrecognized `$NAME` is a parse error rather than a literal: a `$PROJECTROOT` typo kept as a constant string dedupes across every worktree on the machine and kills one of them. An unknown `prespawn_*` is an error too — silently dropping a dedupe directive is the destructive failure, where failing to parse is loud. A repo-local key with no `$PROJECT_ROOT` warns. - `tool-trust.ts` walks up for the nearest `dormouse.yml` (its directory is `$PROJECT_ROOT`, free and git-independent) and records per-root decisions. Denials are remembered so a hostile repo cannot re-ask every invocation; a corrupt store starts empty rather than failing every tool. Granting is deliberately absent — only Dormouse's own chrome may grant. Parsing precedes the trust check because parsing is inert and the approval dialog has to name the command it is approving. 33 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XDByKnEGLaNj45bNZjzjeF --- lib/package.json | 3 +- lib/src/host/tool-registry.test.ts | 146 +++++++++++++++++++++ lib/src/host/tool-registry.ts | 195 +++++++++++++++++++++++++++++ lib/src/host/tool-trust.test.ts | 128 +++++++++++++++++++ lib/src/host/tool-trust.ts | 185 +++++++++++++++++++++++++++ pnpm-lock.yaml | 111 ++++++++-------- 6 files changed, 717 insertions(+), 51 deletions(-) create mode 100644 lib/src/host/tool-registry.test.ts create mode 100644 lib/src/host/tool-registry.ts create mode 100644 lib/src/host/tool-trust.test.ts create mode 100644 lib/src/host/tool-trust.ts diff --git a/lib/package.json b/lib/package.json index 9a100954..c6b1d47b 100644 --- a/lib/package.json +++ b/lib/package.json @@ -32,7 +32,8 @@ "react-dom": "^19.2.6", "server-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "yaml": "^2.9.0" }, "devDependencies": { "@storybook/addon-docs": "^10.4.0", diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts new file mode 100644 index 00000000..d2f6f569 --- /dev/null +++ b/lib/src/host/tool-registry.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + ToolFileError, + dedupeKeysEqual, + 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', + dedupeTemplate: ['storybook', '$PROJECT_ROOT'], + }); + }); + + 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', 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(dedupeKeysEqual(a, b)).toBe(false); + }); + + 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('dedupeKeysEqual', () => { + it('matches element-wise', () => { + expect(dedupeKeysEqual(['a', '/r'], ['a', '/r'])).toBe(true); + expect(dedupeKeysEqual(['a', '/r'], ['a', '/s'])).toBe(false); + expect(dedupeKeysEqual(['a'], ['a', '/r'])).toBe(false); + }); + + it('never matches a null key against anything, including another null', () => { + expect(dedupeKeysEqual(null, ['a'])).toBe(false); + expect(dedupeKeysEqual(['a'], null)).toBe(false); + expect(dedupeKeysEqual(null, null)).toBe(false); + }); +}); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts new file mode 100644 index 00000000..c95874e0 --- /dev/null +++ b/lib/src/host/tool-registry.ts @@ -0,0 +1,195 @@ +/** + * `dormouse.yml` parsing and dedupe-key resolution for Dor Tools + * (`docs/specs/dor-tool.md` -> Declaring tools, Identity and dedupe). + * + * Node-side on purpose: the spec makes the registry host-resolved rather than + * CLI-resolved, so one source of truth serves `dor tool` and any later GUI + * gesture, and so a caller cannot hand the host a command while claiming the + * file authorized it. The YAML dependency stays in the host bundle. + * + * Everything here is pure given a file's text; discovery and trust live in + * `tool-lookup.ts`. + */ +import { parse as parseYaml } from 'yaml'; + +/** Where a tool file came from. `$PROJECT_ROOT` exists only for `repo`. */ +export type ToolScope = 'repo' | 'user'; + +export interface ToolEntry { + readonly name: string; + /** Command typed into the spawned shell, exactly as `dor ensure` types one. */ + readonly run: string; + /** + * `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; + /** 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', 'prespawn_dedupe']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Coerce one `prespawn_dedupe` value to its element list. A bare scalar is a + * one-element key, unambiguous because the field has exactly one value shape + * (the reason `prespawn_*` spends a field name per addition). */ +function readDedupeTemplate(value: unknown, where: string): string[] { + const elements = Array.isArray(value) ? value : [value]; + if (elements.length === 0) { + throw new ToolFileError(`${where}: prespawn_dedupe cannot be empty`); + } + return elements.map((element) => { + if (typeof element === 'string') return element; + if (typeof element === 'number' || typeof element === 'boolean') return String(element); + throw new ToolFileError(`${where}: prespawn_dedupe elements must be strings`); + }); +} + +/** Reject unknown `$NAME` tokens, and `$PROJECT_ROOT` outside a repo scope. */ +function validateSubstitutions(template: readonly string[], scope: ToolScope, where: string): void { + for (const element of template) { + for (const token of element.match(SUBSTITUTION_TOKEN) ?? []) { + if (!(SUBSTITUTIONS as readonly string[]).includes(token)) { + throw new ToolFileError( + `${where}: unknown substitution '${token}' (known: ${SUBSTITUTIONS.join(', ')})`, + ); + } + if (token === '$PROJECT_ROOT' && scope !== 'repo') { + throw new ToolFileError(`${where}: $PROJECT_ROOT is only defined for a repo-local dormouse.yml`); + } + } + } +} + +/** + * Parse a tool file. `dir` is the absolute directory holding it and becomes + * `$PROJECT_ROOT` for a repo scope. Throws `ToolFileError` with a + * `: ` 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(); + 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`, + ); + } + } + + tools.set(name, { name, run: run.trim(), 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; + }), + ); +} + +/** + * Compare two resolved keys. Element-wise exact equality; the host namespaces + * by the tool identity it resolved from the spawn, so a key's first element is + * never trusted as a tool name (`docs/specs/dor-tool.md` -> Identity and dedupe). + */ +export function dedupeKeysEqual(a: readonly string[] | null, b: readonly string[] | null): boolean { + if (a === null || b === null) return false; + return a.length === b.length && a.every((element, index) => element === b[index]); +} diff --git a/lib/src/host/tool-trust.test.ts b/lib/src/host/tool-trust.test.ts new file mode 100644 index 00000000..47413330 --- /dev/null +++ b/lib/src/host/tool-trust.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, mkdir, 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 { FileToolTrustStore, MemoryToolTrustStore, findToolFile, lookupTool } from './tool-trust'; + +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 unknown until a decision is recorded, then remembers it across instances', async () => { + const stateDir = join(root, 'state'); + const store = new FileToolTrustStore(stateDir); + expect(await store.get('/repo')).toBe('unknown'); + await store.set('/repo', 'trusted'); + expect(await store.get('/repo')).toBe('trusted'); + expect(await new FileToolTrustStore(stateDir).get('/repo')).toBe('trusted'); + }); + + it('remembers a denial, so a hostile repo cannot re-ask every invocation', async () => { + const store = new FileToolTrustStore(join(root, 'state')); + await store.set('/repo', 'denied'); + expect(await store.get('/repo')).toBe('denied'); + }); + + it('keys on the resolved path', async () => { + const store = new FileToolTrustStore(join(root, 'state')); + await store.set('/repo/../repo', 'trusted'); + expect(await store.get('/repo')).toBe('trusted'); + }); + + it('starts empty on a corrupt file rather than failing every tool', async () => { + const stateDir = join(root, 'state'); + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, 'tool-trust.json'), '{not json'); + expect(await new FileToolTrustStore(stateDir).get('/repo')).toBe('unknown'); + }); +}); + +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())).toEqual({ status: 'no-file' }); + }); + + it('asks for trust before running anything, naming the command', async () => { + await write(); + expect(await lookupTool('storybook', root, new MemoryToolTrustStore())).toMatchObject({ + status: 'untrusted', + projectRoot: root, + name: 'storybook', + run: 'pnpm storybook', + }); + }); + + it('resolves once the repo is trusted', async () => { + await write(); + const trust = new MemoryToolTrustStore(); + await trust.set(root, 'trusted'); + const result = await lookupTool('storybook', root, trust); + expect(result.status).toBe('ok'); + if (result.status !== 'ok') return; + expect(result.file.tools.get('storybook')?.run).toBe('pnpm storybook'); + expect(result.projectRoot).toBe(root); + }); + + it('stays denied once denied', async () => { + await write(); + const trust = new MemoryToolTrustStore(); + await trust.set(root, 'denied'); + expect((await lookupTool('storybook', root, trust)).status).toBe('denied'); + }); + + 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())).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()); + expect(result).toMatchObject({ status: 'error' }); + if (result.status !== 'error') return; + expect(result.message).toMatch(/unknown substitution '\$NOPE'/); + }); +}); diff --git a/lib/src/host/tool-trust.ts b/lib/src/host/tool-trust.ts new file mode 100644 index 00000000..01da541e --- /dev/null +++ b/lib/src/host/tool-trust.ts @@ -0,0 +1,185 @@ +/** + * 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 repo root is trusted. Trust is path-level and never content-hashed: + * re-prompting on every edit and every `git pull` that touches the file trains + * the user to click through it (rationale). Denial is remembered too, so a + * hostile repo cannot re-ask on every invocation. + * + * Granting is *not* implemented here. Only a gesture in Dormouse's own chrome + * may grant trust, because an agent holding the control token can `dor send` + * keystrokes indistinguishable from typing; this module records the decision a + * gesture produced and answers "is it trusted yet?". + */ +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { dirname, join, parse as parsePath, resolve } from 'node:path'; +import { ToolFileError, parseToolFile, type ToolFile } from './tool-registry'; + +export const TOOL_FILE_NAME = 'dormouse.yml'; +const TRUST_FILE_NAME = 'tool-trust.json'; + +export type TrustState = 'trusted' | 'denied' | 'unknown'; + +interface TrustFile { + /** Absolute repo roots, each mapped to the decision a human made there. */ + roots: Record; +} + +function emptyTrust(): TrustFile { + return { roots: {} }; +} + +/** Records the trust decision for a repo root. One small JSON file, written + * temp-then-rename so a crash mid-write cannot leave a truncated file that + * reads as "nothing is trusted". */ +export class FileToolTrustStore { + readonly #dir: string; + readonly #path: string; + #cache: TrustFile | null = null; + + constructor(stateDir: string) { + this.#dir = stateDir; + this.#path = join(stateDir, TRUST_FILE_NAME); + } + + async get(root: string): Promise { + return (await this.#read()).roots[resolve(root)] ?? 'unknown'; + } + + /** Record a decision a human made in Dormouse's chrome. */ + async set(root: string, decision: 'trusted' | 'denied'): Promise { + const current = await this.#read(); + const next: TrustFile = { roots: { ...current.roots, [resolve(root)]: decision } }; + await this.#write(next); + this.#cache = next; + } + + async #read(): Promise { + if (this.#cache) return this.#cache; + try { + const parsed: unknown = JSON.parse(await readFile(this.#path, 'utf-8')); + const roots = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as { roots?: unknown }).roots + : null; + this.#cache = + roots && typeof roots === 'object' && !Array.isArray(roots) + ? { roots: roots as TrustFile['roots'] } + : emptyTrust(); + } catch { + // A missing file is the common case (nothing trusted yet). A corrupt one + // starts empty rather than throwing: failing closed here means every tool + // stops working, and the cost of starting empty is one more approval. + this.#cache = emptyTrust(); + } + return this.#cache; + } + + async #write(state: TrustFile): Promise { + await mkdir(this.#dir, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await chmod(this.#dir, 0o700).catch(() => {}); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); + await rename(tmp, this.#path); + } +} + +/** An in-memory store, for hosts with no state directory and for tests. */ +export class MemoryToolTrustStore { + readonly #roots = new Map(); + + async get(root: string): Promise { + return this.#roots.get(resolve(root)) ?? 'unknown'; + } + + async set(root: string, decision: 'trusted' | 'denied'): Promise { + this.#roots.set(resolve(root), decision); + } +} + +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 = (path) => readFile(path, 'utf-8'), +): Promise<{ path: string; dir: string; text: string } | null> { + let dir = resolve(startDir); + const { root } = parsePath(dir); + // Bounded by the filesystem root; `dirname('/') === '/'` is the terminator. + for (;;) { + const path = join(dir, TOOL_FILE_NAME); + try { + return { path, dir, text: await readTextFile(path) }; + } catch { + // Not here (or unreadable) — keep walking. + } + if (dir === root) return null; + 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 } + | { status: 'denied'; projectRoot: string; path: string } + | { status: 'error'; message: string } + | { status: 'ok'; projectRoot: string; path: string; file: ToolFile; name: string }; + +/** + * 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, +): Promise { + const found = await findToolFile(cwd, readTextFile); + 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(), + }; + } + + const state = await trust.get(found.dir); + if (state === 'denied') return { status: 'denied', projectRoot: found.dir, path: found.path }; + if (state === 'unknown') { + return { + status: 'untrusted', + projectRoot: found.dir, + path: found.path, + name: entry.name, + run: entry.run, + }; + } + return { status: 'ok', projectRoot: found.dir, path: found.path, file, name: entry.name }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b11a063..3a183bb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -44,7 +44,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: specifier: ^10.4.0 version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) @@ -53,7 +53,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) dor: dependencies: @@ -128,19 +128,22 @@ importers: tailwind-variants: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.3) + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': specifier: ^10.4.0 version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -149,7 +152,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) chromatic: specifier: ^17.0.0 version: 17.8.0 @@ -167,10 +170,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) server: dependencies: @@ -247,7 +250,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tauri-apps/cli': specifier: ^2.11.2 version: 2.11.4 @@ -259,7 +262,7 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -277,10 +280,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) standalone/sidecar: dependencies: @@ -299,7 +302,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -311,7 +314,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2(supports-color@7.2.0) @@ -332,10 +335,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) website: dependencies: @@ -363,10 +366,10 @@ importers: devDependencies: '@react-router/dev': specifier: ^8.0.0 - version: 8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.2.18 @@ -381,10 +384,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -4641,6 +4644,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yauzl-promise@4.0.0: resolution: {integrity: sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==} engines: {node: '>=16'} @@ -5304,11 +5312,11 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 @@ -5606,7 +5614,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.8 @@ -5632,7 +5640,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@6.0.3) - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -5856,10 +5864,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/icons': 2.1.0(react@19.2.8) '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 @@ -5875,25 +5883,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.2 rollup: 4.62.2 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -5910,11 +5918,11 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@7.2.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5924,7 +5932,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -5935,11 +5943,11 @@ snapshots: - supports-color - webpack - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.62.2)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5949,7 +5957,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6055,12 +6063,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@tauri-apps/api@2.11.1': {} @@ -6261,10 +6269,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -6283,13 +6291,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -8650,7 +8658,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -8662,11 +8670,12 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.0 - vitest@4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -8683,7 +8692,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -8766,6 +8775,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.0: {} + yauzl-promise@4.0.0: dependencies: '@node-rs/crc32': 1.10.7 From 6cfe6b72e4e88bcad252911a1f227d65c04a448d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 18:34:26 -0700 Subject: [PATCH 04/47] feat(dor): `dor tool` and the tool Surface kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI half of the atom (docs/specs/dor-tool.md -> CLI). Two forms: `dor tool ` runs a dormouse.yml entry, `dor tool -- ` designates any command as a tool with no key at all. - `tool` joins KIND_CAPABILITIES with both capabilities, so `--kind` parsing and its help placeholder pick it up from the one table. - `surface.tool` on the wire. The CLI never reads dormouse.yml: resolution is host-side so a caller cannot hand the host a command while claiming the file authorized it. - A named tool takes no extra arguments yet. Argument passing waits for phase C's substitution, which is where args have 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. - dormouse.yml lint output goes to stderr so `--json` stays parseable. Where the tool lands is decided host-side rather than here: the host already knows the calling pane's OSC 633 command line, which is the signal the spec names, and a CLI-side flag would be forgeable by `dor send`. 136 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XDByKnEGLaNj45bNZjzjeF --- dor/src/cli.ts | 5 + dor/src/commands/tool.ts | 196 ++++++++++++++++++++ dor/src/commands/types.ts | 47 ++++- dor/src/control-client.ts | 6 + dor/src/protocol.ts | 1 + dor/test/cli-output.test.mjs | 113 +++++++++++ dor/test/snapshots/help/dor.md | 5 +- dor/test/snapshots/help/tool.md | 49 +++++ dor/test/snapshots/tool-command.snap | 5 + dor/test/snapshots/tool-empty-tail.snap | 5 + dor/test/snapshots/tool-json.snap | 16 ++ dor/test/snapshots/tool-missing-target.snap | 5 + dor/test/snapshots/tool-name-and-tail.snap | 5 + dor/test/snapshots/tool-name-args.snap | 5 + dor/test/snapshots/tool-named.snap | 5 + dor/test/snapshots/tool-unknown-option.snap | 5 + 16 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 dor/src/commands/tool.ts create mode 100644 dor/test/snapshots/help/tool.md create mode 100644 dor/test/snapshots/tool-command.snap create mode 100644 dor/test/snapshots/tool-empty-tail.snap create mode 100644 dor/test/snapshots/tool-json.snap create mode 100644 dor/test/snapshots/tool-missing-target.snap create mode 100644 dor/test/snapshots/tool-name-and-tail.snap create mode 100644 dor/test/snapshots/tool-name-args.snap create mode 100644 dor/test/snapshots/tool-named.snap create mode 100644 dor/test/snapshots/tool-unknown-option.snap diff --git a/dor/src/cli.ts b/dor/src/cli.ts index bec0c14e..ed6559fa 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -17,6 +17,7 @@ import { readCommand } from './commands/read.js'; import { sendCommand } from './commands/send.js'; import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; +import { toolCommand } from './commands/tool.js'; import { versionCommand } from './commands/version.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; import type { @@ -70,12 +71,15 @@ export type { SurfacePort, SurfaceRenderMode, SurfaceView, + ToolSurfaceRequest, + ToolSurfaceResponse, VersionMetadata, } from './commands/types.js'; const COMMANDS = [ splitCommand, ensureCommand, + toolCommand, versionCommand, skillCommand, sendCommand, @@ -90,6 +94,7 @@ const COMMANDS = [ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, + tool: toolCommand.command, version: versionCommand.command, skill: skillCommand.command, send: sendCommand.command, diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts new file mode 100644 index 00000000..71e716bc --- /dev/null +++ b/dor/src/commands/tool.ts @@ -0,0 +1,196 @@ +/** `dor tool` — run a command as a Dor Tool (`docs/specs/dor-tool.md`). */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ParseResult, + ToolSurfaceResponse, +} from './types.js'; +import { + callerWorkingDirectory, + errorMessage, + renderJson, + requireControlClient, + stringParser, + writeStderr, + writeStdout, +} from './shared.js'; + +interface ToolFlags { + readonly json?: boolean; + readonly minimize?: boolean; + readonly fresh?: boolean; + readonly surface?: string; + readonly cwd?: string; +} + +// A named tool waits on the same shell-integration handshake `dor ensure` does, +// plus a `dormouse.yml` read; both are bounded well under this. +const TOOL_TIMEOUT_MS = 20_000; + +const FLAGS_WITH_VALUES = new Set(['--cwd', '--surface']); +const BOOLEAN_FLAGS = new Set(['--json', '--minimize', '--fresh']); + +/** + * `dor tool` takes either a registered name or a `--` command tail, never both. + * stricli cannot express that, so the shape is checked before it parses — the + * same pre-parse contract `dor ensure` uses. Keep the flag lists above in sync + * with `parameters.flags`. + */ +export function validateToolArgs(args: string[]): ParseResult { + const delimiterIndex = args.indexOf('--'); + const head = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex); + + const positionals: string[] = []; + for (let index = 0; index < head.length; index += 1) { + const arg = head[index]; + if (BOOLEAN_FLAGS.has(arg)) continue; + if (FLAGS_WITH_VALUES.has(arg)) { + const value = head[index + 1]; + if (!value || value.startsWith('-')) return { ok: false, message: `${arg} requires a value` }; + index += 1; + continue; + } + if (arg.startsWith('-')) return { ok: false, message: `unknown option '${arg}'` }; + positionals.push(arg); + } + + if (delimiterIndex === -1) { + if (positionals.length === 0) { + return { ok: false, message: 'dor tool requires a tool name or -- ' }; + } + // 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 takes no arguments (got '${positionals[1]}')` }; + } + return { ok: true, value: undefined }; + } + + // `dor tool -- ` 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]', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] \n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- ...\n', + ], + }, + { + scope: 'command-usage', + findReplace: [ + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path]', + ' dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] \n dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- ...\n', + ], + }, + { + scope: 'command-detail', + remove: ['\nARGUMENTSname'], + }, + ], + command: buildCommand({ + 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 \` runs an entry from the nearest dormouse.yml, walking up from the working directory. \`dor tool -- \` 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 -- \` — 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 the repo in Dormouse itself. An unapproved repo fails with a message rather than prompting in the terminal; approve it in the app and run the command again. Declining is remembered too. + +Where the tool lands depends on the caller. Typed on its own at a shell prompt, it takes over that pane, because typing a command at a prompt is how a terminal works. From a script, from an agent, from a compound command line, or when the pane is busy or already a tool, it splits without taking focus and prints the new surface's handle. + +--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. + +Text output: + created surface:3 "pnpm storybook" + existing surface:3 "pnpm storybook" + +JSON output: + { + "status": "created", + "surface_id": "pane-def", + "surface_ref": "surface:3", + "command": "pnpm storybook", + "cwd": "/Users/me/projects/site", + "minimized": false, + "key": ["storybook", "/Users/me/projects/site"] + }`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, + fresh: { kind: 'boolean', brief: 'Ignore a declared key and always create.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, + cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for the tool file and the command.', optional: true, placeholder: 'path' }, + }, + positional: { + kind: 'array', + minimum: 0, + parameter: { parse: stringParser, brief: 'Registered tool name.', placeholder: 'name' }, + }, + }, + func: runToolCommand, + }), +}; + +async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest: string[]): Promise { + // `--` 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 -- '); + } + + const client = requireControlClient(this.options, TOOL_TIMEOUT_MS); + if (client instanceof Error) return client; + + try { + const response = await client.toolSurface({ + ...(named ? { name: rest[0] } : { command: rest }), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }); + // Lint output is advisory and must not pollute a `--json` parse. + for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); + writeStdout(this, renderToolResponse(response, flags.json === true)); + return undefined; + } catch (error) { + return new Error(errorMessage(error)); + } +} + +function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { + if (json) { + return renderJson({ + status: response.status, + surface_id: response.surfaceId, + surface_ref: response.surfaceRef, + command: response.command, + cwd: response.cwd, + minimized: response.minimized, + key: response.key, + }); + } + return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.command)}\n`; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ceafea49..3d9d523b 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` 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 = { terminal: { terminal: true, browser: false }, browser: { terminal: false, browser: true }, + // A tool is one Session with both: the PTY running the command, and the + // browser it grows once it serves (`docs/specs/dor-tool.md`). Verbs gate on + // the capability they need, so both sides of a row populate. + tool: { terminal: true, browser: true }, }; /** Every kind, derived from the table so `--kind` parsing and its help @@ -142,6 +145,43 @@ export interface EnsureSurfaceResponse { minimized: boolean; } +/** + * `dor tool`. Two forms, differing only in whether the tool has an identity: + * `name` runs a `dormouse.yml` entry with whatever `prespawn_dedupe` it + * declares; `command` designates an arbitrary command as a tool with no key. + * Exactly one is set. Host-resolved on purpose — the CLI never reads the tool + * file, so a caller cannot hand the host a command while claiming the file + * authorized it (`docs/specs/dor-tool.md` -> Trust). + */ +export interface ToolSurfaceRequest { + /** Registered tool name (`dor tool `). */ + name?: string; + /** Raw argv (`dor tool -- `); 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: the redundant spawn never started. */ + status: 'created' | 'existing' | 'adopted'; + surfaceId: string; + surfaceRef: string; + /** The rendered command, as typed into the shell. */ + command: string; + cwd: string; + minimized: boolean; + /** The resolved dedupe key, or null when the tool has no identity. */ + key: string[] | null; + /** Non-fatal `dormouse.yml` lint output, printed to stderr by the CLI. */ + warnings?: string[]; +} + export interface SendSurfaceRequest { surface: string; input: string; @@ -280,6 +320,7 @@ export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; + toolSurface(request: ToolSurfaceRequest): Promise; sendSurface(request: SendSurfaceRequest): Promise; readSurface(request: ReadSurfaceRequest): Promise; awaitSurface(request: AwaitSurfaceRequest): Promise; diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 54d021ae..6fa1158b 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -24,6 +24,8 @@ import type { SendSurfaceResponse, SplitSurfaceRequest, SplitSurfaceResponse, + ToolSurfaceRequest, + ToolSurfaceResponse, } from './commands/types.js'; import { SURFACE_CONTROL_METHODS, type SurfaceControlMethod } from './protocol.js'; import type { DorControlResult } from './protocol.js'; @@ -93,6 +95,10 @@ export class SocketControlClient implements ControlClient { return this.request(SURFACE_CONTROL_METHODS.ensure, request); } + toolSurface(request: ToolSurfaceRequest): Promise { + return this.request(SURFACE_CONTROL_METHODS.tool, request); + } + sendSurface(request: SendSurfaceRequest): Promise { return this.request(SURFACE_CONTROL_METHODS.send, request); } diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 403b8079..c1c38141 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 07e0354e..a650c663 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -139,6 +139,28 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { ...(command ? { command } : {}), }; }, + async toolSurface(request) { + this.requests.push({ method: 'toolSurface', request }); + // Mirror the host: a named tool renders from the (fixture) registry, a + // `--` tail is quoted argv. `storybook` is the keyed entry, so it is the + // one that can come back as an existing match. + const named = typeof request.name === 'string'; + const command = named + ? `pnpm ${request.name}` + : buildShellCommandForKind('posix', request.command); + const keyed = named && request.name === 'storybook' && !request.fresh; + return { + status: keyed ? 'existing' : 'created', + surfaceId: '44444444-4444-4444-8444-444444444444', + surfaceRef: 'surface:4', + command, + cwd: request.cwd, + minimized: request.minimized, + key: keyed ? ['storybook', '/work/site'] : null, + ...(named && request.name === 'noisy' ? { warnings: ['dormouse.yml: tools.noisy: ignoring unknown field \'colour\''] } : {}), + }; + }, + async ensureSurface(request) { this.requests.push({ method: 'ensureSurface', request }); // Mirror the host: quote the argv for the target shell, and key on the @@ -1383,3 +1405,94 @@ test('ensure missing command output', async () => { test('split conflicting direction output', async () => { await snapshot('split-conflicting-direction', await runCli(['split', '--left', '--right'], { client: fixtureClient() })); }); + +test('tool named form text output', async () => { + await snapshot( + 'tool-named', + await runCli(['tool', 'storybook'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool command form text output', async () => { + await snapshot( + 'tool-command', + await runCli(['tool', '--', 'pnpm', 'dev'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool json output carries the resolved key', async () => { + await snapshot( + 'tool-json', + await runCli(['tool', '--json', 'storybook'], { client: fixtureClient(), env: { PWD: '/work/site' } }), + ); +}); + +test('tool sends the name, never a command', async () => { + const client = fixtureClient(); + await runCli(['tool', 'storybook'], { client, env: { PWD: '/work/site' } }); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests, [{ + method: 'toolSurface', + request: { + name: 'storybook', + fresh: false, + minimized: false, + surface: undefined, + cwd: '/work/site', + }, + }]); +}); + +test('tool rejects arguments after a name', async () => { + await snapshot('tool-name-args', await runCli(['tool', 'storybook', 'extra'], { client: fixtureClient() })); +}); + +test('tool -- sends argv as a command, never a name', async () => { + const client = fixtureClient(); + await runCli(['tool', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } }); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests, [{ + method: 'toolSurface', + request: { + command: ['pnpm', 'dev'], + fresh: false, + minimized: false, + surface: undefined, + cwd: '/work/site', + }, + }]); +}); + +test('tool --fresh forwards the opt-out', async () => { + const client = fixtureClient(); + await runCli(['tool', '--fresh', 'storybook'], { client, env: { PWD: '/work/site' } }); + assert.equal(client.requests[0].request.fresh, true); +}); + +test('tool prints dormouse.yml warnings to stderr, keeping --json parseable', async () => { + const result = await runCli(['tool', '--json', 'noisy'], { + client: fixtureClient(), + env: { PWD: '/work/site' }, + }); + assert.match(result.stderr, /ignoring unknown field 'colour'/); + assert.deepEqual(JSON.parse(result.stdout).status, 'created'); +}); + +test('tool with neither a name nor a command tail', async () => { + await snapshot('tool-missing-target', await runCli(['tool'], { client: fixtureClient() })); +}); + +test('tool rejects a name and a command tail together', async () => { + await snapshot( + 'tool-name-and-tail', + await runCli(['tool', 'storybook', '--', 'pnpm', 'dev'], { client: fixtureClient() }), + ); +}); + +test('tool rejects an empty command tail', async () => { + await snapshot('tool-empty-tail', await runCli(['tool', '--'], { client: fixtureClient() })); +}); + +test('tool rejects an unknown option', async () => { + await snapshot('tool-unknown-option', await runCli(['tool', '--nope', 'storybook'], { client: fixtureClient() })); +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1ee6a0e7..4756f0c4 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -6,6 +6,8 @@ Invocation: `dor --help` USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [-- ...] dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- ... + dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] + dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- ... dor version [--json] dor skill [--install] [--json] dor send ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] @@ -14,7 +16,7 @@ USAGE dor kill [--confirm-if-read text|--confirm-dangerously] [--json] dor iframe [--json] [--minimize] [--surface id|ref] dor agent-browser [--key name|--session name|--surface handle] [args...] - dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] + dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser|tool] [--port number] [--ports] [--view paned|zoomed|minimized] dor --help Dormouse bundles the dor CLI into every terminal it launches. @@ -26,6 +28,7 @@ FLAGS COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface is running a command. + tool Run a command as a Dor Tool. version Print the dor CLI version. skill Print the Dormouse agent skill, or install its bootstrap stub. send Send text or key input to a terminal surface. diff --git a/dor/test/snapshots/help/tool.md b/dor/test/snapshots/help/tool.md new file mode 100644 index 00000000..a1395572 --- /dev/null +++ b/dor/test/snapshots/help/tool.md @@ -0,0 +1,49 @@ +# dor tool + +Invocation: `dor tool --help` + +```text +USAGE + dor tool [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] + dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] -- ... + 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 ` runs an entry from the nearest dormouse.yml, walking up from the working directory. `dor tool -- ` 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 -- ` — 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 the repo in Dormouse itself. An unapproved repo fails with a message rather than prompting in the terminal; approve it in the app and run the command again. Declining is remembered too. + +Where the tool lands depends on the caller. Typed on its own at a shell prompt, it takes over that pane, because typing a command at a prompt is how a terminal works. From a script, from an agent, from a compound command line, or when the pane is busy or already a tool, it splits without taking focus and prints the new surface's handle. + +--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. + +Text output: + created surface:3 "pnpm storybook" + existing surface:3 "pnpm storybook" + +JSON output: + { + "status": "created", + "surface_id": "pane-def", + "surface_ref": "surface:3", + "command": "pnpm storybook", + "cwd": "/Users/me/projects/site", + "minimized": false, + "key": ["storybook", "/Users/me/projects/site"] + } + +FLAGS + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--fresh] Ignore a declared key and always create. + [--surface] Surface to split when creating. + [--cwd] Working directory for the tool file and the command. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/tool-command.snap b/dor/test/snapshots/tool-command.snap new file mode 100644 index 00000000..2376a143 --- /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 00000000..e0e608bb --- /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 00000000..f4a8c14a --- /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 00000000..ef6cffb5 --- /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 -- 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 00000000..302e1edd --- /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 00000000..515ced20 --- /dev/null +++ b/dor/test/snapshots/tool-name-args.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor tool 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 00000000..0e4a2de0 --- /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 00000000..77827581 --- /dev/null +++ b/dor/test/snapshots/tool-unknown-option.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unknown option '--nope' From d25ffc7664ce3d2020c718617931b6dd150b7b72 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 18:40:51 -0700 Subject: [PATCH 05/47] =?UTF-8?q?feat(tool):=20the=20tool=20Surface=20?= =?UTF-8?q?=E2=80=94=20host=20plumbing,=20params,=20and=20the=20two-capabi?= =?UTF-8?q?lity=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a `tool` Surface needs to exist and render, short of the control handler that creates one. - `toolControl` on PlatformAdapter: one method, two ops (resolve a name, record a trust decision), plumbed through both hosts — VS Code extension host, Tauri/Rust/sidecar, and the dev:standalone:ab bridge. Absent on hosts with no filesystem, where `dor tool -- ` still works. - `PersistedSurfaceType` gains 'tool'. The compiler caught the persistence seam on its own; `surfaceKindFromParams` is the one it cannot force, and now classifies tools ahead of the browser test — a serving tool also carries a renderMode, so order matters. - `ToolPanel` keeps the terminal and the browser both mounted for the Surface's whole life and flips visibility. Unmounting the terminal would drop the xterm buffer the command is still writing to; unmounting the browser would reload the framed document on every toggle. That invariant is what lets a tool keep one id while its capabilities come and go. - `ToolPaneHeader` delegates to the header for whichever half is forward, so a tool's browser gets the same URL editor, nav, and Display modal a plain browser Surface has, behind a leading toggle chip. lib typecheck clean; 1833 lib tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XDByKnEGLaNj45bNZjzjeF --- lib/src/components/wall/LathHost.tsx | 8 + lib/src/components/wall/ToolPaneHeader.tsx | 49 + lib/src/components/wall/ToolPanel.tsx | 31 + lib/src/components/wall/browser-surface.ts | 25 +- lib/src/components/wall/wall-context.tsx | 4 + lib/src/host/tool-host.test.ts | 112 + lib/src/host/tool-host.ts | 69 + lib/src/lib/platform/tool-types.ts | 31 + lib/src/lib/platform/types.ts | 11 + lib/src/lib/platform/vscode-adapter.ts | 13 +- lib/src/lib/session-types.ts | 4 +- standalone/scripts/build-sidecar-proxy.mjs | 2 + standalone/scripts/dev-agent-browser.mjs | 2 + standalone/sidecar/main.js | 10 + standalone/sidecar/tool-host.cjs | 7607 ++++++++++++++++++++ standalone/src-tauri/src/lib.rs | 19 + standalone/src/browser-sidecar-adapter.ts | 11 + standalone/src/tauri-adapter.ts | 11 + vscode-ext/src/extension.ts | 4 + vscode-ext/src/message-router.ts | 11 + vscode-ext/src/message-types.ts | 10 +- vscode-ext/src/tool-host.ts | 23 + 22 files changed, 8061 insertions(+), 6 deletions(-) create mode 100644 lib/src/components/wall/ToolPaneHeader.tsx create mode 100644 lib/src/components/wall/ToolPanel.tsx create mode 100644 lib/src/host/tool-host.test.ts create mode 100644 lib/src/host/tool-host.ts create mode 100644 lib/src/lib/platform/tool-types.ts create mode 100644 standalone/sidecar/tool-host.cjs create mode 100644 vscode-ext/src/tool-host.ts diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index 35e9c56b..9a89f995 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -27,6 +27,8 @@ import { nowMs, type LathWallEngine } from './lath-wall-engine'; import { type DragController, createDragController } from './lath-drag-controller'; import { TerminalPanel } from './TerminalPanel'; import { BrowserPanel } from './BrowserPanel'; +import { ToolPanel } from './ToolPanel'; +import { ToolPaneHeader } from './ToolPaneHeader'; import { TerminalPaneHeader } from './TerminalPaneHeader'; import { SurfacePaneHeader } from './SurfacePaneHeader'; import { AlertSpeechIndicator } from './AlertSpeechIndicator'; @@ -92,10 +94,14 @@ export type LathComponentsOverride = { const BODY_COMPONENTS: Record> = { 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> = { terminal: TerminalPaneHeader, surface: SurfacePaneHeader, + tool: ToolPaneHeader, }; /** For a terminal Surface the pane id is its session id (docs/specs/layout.md). */ @@ -109,6 +115,8 @@ function TerminalLeafOverlay({ id }: PaneProps) { // one way plus a surface-kind branch in the render path. const OVERLAY_COMPONENTS: Record> = { 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/ToolPaneHeader.tsx b/lib/src/components/wall/ToolPaneHeader.tsx new file mode 100644 index 00000000..1a9b0a2b --- /dev/null +++ b/lib/src/components/wall/ToolPaneHeader.tsx @@ -0,0 +1,49 @@ +/** + * Header for a `tool` Surface (`docs/specs/dor-tool.md` -> Lifecycle). + * + * A leading chip toggles which half is forward, then the header for whichever + * half that is: the terminal's while the tool is booting or pinned back, the + * browser chrome once it serves. Delegating rather than reimplementing keeps + * one header per capability — a tool's browser gets the same URL editor, nav + * buttons, and Display modal a plain browser Surface has. + */ +import { useContext } from 'react'; +import { Terminal, Globe } from '@phosphor-icons/react'; +import { chromeButton } from '../design'; +import { SurfacePaneHeader } from './SurfacePaneHeader'; +import { TerminalPaneHeader } from './TerminalPaneHeader'; +import { isToolParams, toolShowsBrowser } from './browser-surface'; +import { WallActionsContext } from './wall-context'; +import type { PaneProps } from './pane-props'; + +export function ToolPaneHeader(props: PaneProps) { + const actions = useContext(WallActionsContext); + const showBrowser = toolShowsBrowser(props.params); + // A tool that has never served has nothing to toggle to: the chip would + // offer a browser that does not exist yet. + const canToggle = isToolParams(props.params) && typeof (props.params as { url?: unknown }).url === 'string'; + + return ( +
+ {canToggle ? ( + + ) : null} +
+ {showBrowser ? : } +
+
+ ); +} diff --git a/lib/src/components/wall/ToolPanel.tsx b/lib/src/components/wall/ToolPanel.tsx new file mode 100644 index 00000000..7efd0f48 --- /dev/null +++ b/lib/src/components/wall/ToolPanel.tsx @@ -0,0 +1,31 @@ +/** + * The body of a `tool` Surface: one Session with a terminal and, once it + * serves, a browser (`docs/specs/dor-tool.md` -> Lifecycle). + * + * Both halves stay mounted for the Surface's whole life and the flip is + * visibility only. Unmounting the terminal would drop the xterm buffer the + * command is still writing to, and unmounting the browser would reload the + * framed document on every toggle — the invariant that lets a tool keep one id + * while its capabilities come and go. + */ +import { BrowserPanel } from './BrowserPanel'; +import { TerminalPanel } from './TerminalPanel'; +import { toolShowsBrowser } from './browser-surface'; +import type { PaneProps } from './pane-props'; + +export function ToolPanel(props: PaneProps) { + const showBrowser = toolShowsBrowser(props.params); + return ( +
+ {/* `hidden` rather than a conditional: see the module comment. The hidden + half is also `parked`, so a screencast idles instead of decoding + frames nobody is looking at (`useSurfaceVisibility`). */} + + +
+ ); +} diff --git a/lib/src/components/wall/browser-surface.ts b/lib/src/components/wall/browser-surface.ts index 6f5e5b8c..5c78e44c 100644 --- a/lib/src/components/wall/browser-surface.ts +++ b/lib/src/components/wall/browser-surface.ts @@ -12,6 +12,8 @@ type BrowserParamsLike = { renderMode?: unknown; session?: unknown; url?: unknown; + /** Tool only: the header chip pinning the terminal forward past serving. */ + showTerminal?: unknown; }; function asParams(params: unknown): BrowserParamsLike { @@ -31,10 +33,29 @@ export function isAgentBrowserParams(params: unknown): boolean { return p.renderMode === 'ab-screencast' || p.renderMode === 'ab-popout'; } -/** Whether params describe any browser surface (vs a terminal): the unified - * 'browser' type, or anything carrying a renderMode. */ +/** Whether params describe a `tool` Surface — one Session with a terminal and, + * once it serves, a browser (`docs/specs/dor-tool.md`). Checked before the + * browser test below, because a serving tool also carries a `renderMode`. */ +export function isToolParams(params: unknown): boolean { + return asParams(params).surfaceType === 'tool'; +} + +/** Whether a tool is currently showing its browser rather than its terminal. + * False until it serves (no `url` yet), and false while the header's far-left + * chip has the terminal pinned forward. Both halves stay mounted either way — + * the toggle is visibility, never unmount, or the xterm buffer and the framed + * document would be rebuilt on every flip. */ +export function toolShowsBrowser(params: unknown): boolean { + const p = asParams(params); + return isToolParams(params) && typeof p.url === 'string' && p.showTerminal !== true; +} + +/** 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'; } diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index 686f324f..24daa968 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -57,6 +57,9 @@ export interface WallActions { * session and connect the pane (`connect-port.ts`). Fire-and-forget — failures * are logged, and the pane itself shows loading state. */ onConnectPort: (id: string, url: string) => void; + /** Flip which half of a `tool` Surface is forward — the header's leading chip + * (docs/specs/dor-tool.md). Visibility only: both halves stay mounted. */ + onToggleToolTerminal?: (id: string) => void; } export const WallActionsContext = createContext({ @@ -76,6 +79,7 @@ export const WallActionsContext = createContext({ onOpenBrowserPane: () => {}, resolveSurfaceRef: (id: string) => id, onConnectPort: () => {}, + onToggleToolTerminal: () => {}, }); /** Engine-directed writes from a pane/header (title + params). The read side is diff --git a/lib/src/host/tool-host.test.ts b/lib/src/host/tool-host.test.ts new file mode 100644 index 00000000..94021821 --- /dev/null +++ b/lib/src/host/tool-host.test.ts @@ -0,0 +1,112 @@ +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', root: repo, decision: 'trusted' }); + expect(await host.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'ok', + run: 'pnpm storybook', + key: ['storybook', repo], + }); + }); + + it('reports a null key for an entry that declared none', async () => { + const host = createToolHost({ stateDir }); + await host.handle({ op: 'trust', root: repo, decision: 'trusted' }); + 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', root: repo, decision: 'trusted' }); + 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('remembers a denial across lookups', async () => { + const host = createToolHost({ stateDir }); + await host.handle({ op: 'trust', root: repo, decision: 'denied' }); + expect(await host.handle({ op: 'lookup', name: 'storybook', cwd: repo })).toMatchObject({ + status: 'denied', + }); + }); + + it('persists trust to the state directory, surviving a host restart', async () => { + await createToolHost({ stateDir }).handle({ op: 'trust', root: repo, decision: 'trusted' }); + 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', root: repo, decision: 'trusted' }); + 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 00000000..f5ea161b --- /dev/null +++ b/lib/src/host/tool-host.ts @@ -0,0 +1,69 @@ +/** + * 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 { resolveDedupeKey } from './tool-registry'; +import { + FileToolTrustStore, + MemoryToolTrustStore, + lookupTool, + type ToolTrustStore, +} from './tool-trust'; + +export type { ToolControlResult, ToolHostRequest, ToolLookupResult } from '../lib/platform/tool-types'; + +export interface ToolHost { + handle(request: ToolHostRequest): Promise; +} + +/** + * `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') { + await trust.set(request.root, request.decision); + 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.file.tools.get(lookup.name); + if (!entry) { + // lookupTool only reports ok for an entry it found. + return { status: 'error', message: `tool '${request.name}' vanished during resolution` }; + } + try { + return { + status: 'ok', + projectRoot: lookup.projectRoot, + path: lookup.path, + name: entry.name, + run: entry.run, + 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/lib/platform/tool-types.ts b/lib/src/lib/platform/tool-types.ts new file mode 100644 index 00000000..a9274326 --- /dev/null +++ b/lib/src/lib/platform/tool-types.ts @@ -0,0 +1,31 @@ +/** + * 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'; root: string; decision: 'trusted' | 'denied' }; + +/** 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 } + | { status: 'denied'; projectRoot: string; path: string } + | { status: 'error'; message: string } + | { + status: 'ok'; + projectRoot: string; + path: string; + name: string; + run: string; + 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 720bbd19..624d9dd5 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -5,6 +5,9 @@ import type { ShellEntry } from '../shell-defaults'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; +import type { ToolControlResult, ToolHostRequest } from './tool-types'; + +export type { ToolControlResult, ToolHostRequest, ToolLookupResult } from './tool-types'; export interface PtyInfo { id: string; @@ -276,6 +279,14 @@ export interface PlatformAdapter { // host), where the panel falls back to a raw, uninstrumented `