diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 4f54243c9..81ca183a6 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -47,6 +47,9 @@ it prints; test listeners and self-test fixtures need no further investigation. The lint scans all tracked JavaScript and TypeScript. Search the same files for `createServer`, `.listen(`, `serve(` and `WebSocket` too, because a new API or a host built at runtime can escape its patterns. +The Local-file viewer subsection adds a tokenized file grant: read +`dor/src/file-viewer.ts` and `dor/src/file-viewer-loopback-guard.ts`, including +its static asset discovery, descriptor lifetime, and every request gate. For the rest of `docs/specs/security-local.md`, read each section's owner first — `docs/specs/terminal-escapes.md`, `docs/specs/dor-browser.md`, diff --git a/AGENTS.md b/AGENTS.md index 8debb99b2..87b268ab7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/theme.md`** — The two-layer CSS variable strategy, consumed-token resolver, terminal color contract, theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI on every Dormouse terminal's `PATH`: bundling and env contract, `spawnAndCapture` rules, control-socket plumbing, the Surface handle model, the command set. - **`docs/specs/dor-browser.md`** — The browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, the iframe proxy and CSP boundaries. -- **`docs/specs/dor-tool.md`** — Dor Tools: the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs and OSC 367 contract. Designation, trust, serving, and persistence are built behind the Tools flag. +- **`docs/specs/dor-tool.md`** — Dor Tools: the `tool` Surface — a terminal and a browser on one Session spine — its capability-gated verbs, OSC 367 contract, designation, trust, serving, and persistence. - **`docs/specs/vscode.md`** — VS Code host: webview hosting, webview ↔ Workspace mapping, persistence ordering, theme integration, CSP, the build/dogfood pipeline. - **`docs/specs/standalone.md`** — Tauri host: the Rust ↔ Node-sidecar bridge, boot sequence, AppBar, persistence, shutdown ordering, the build/dev workflow. - **`docs/specs/auto-update.md`** — Standalone auto-update: check → approved download → install-on-quit, the Baseboard notice, Windows sidecar teardown, per-platform quit behavior. diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index adda68c00..e32cadee6 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -455,13 +455,16 @@ Header rewriting: | request | `Accept-Encoding` | deleted, so HTML comes back identity for rewriting | | request | `Cookie` | dropped, including WebSocket handshakes | | response | `Set-Cookie` | dropped, including successful and refused WebSocket handshakes | -| response | `X-Frame-Options`, `Content-Security-Policy`, `Content-Security-Policy-Report-Only` | with validated chain, replaced **whole** by `frame-ancestors 'self' ` (rationale) | +| response | `X-Frame-Options`, CSP headers | with validated chain, replaced by `frame-ancestors 'self' `; opted-in CSP policies remain alongside it (rationale) | +| response | `X-Dormouse-Preserve-CSP: 1` | consumed; preserves upstream CSP headers and meta policies | | response | hop-by-hop (RFC 7230 §6.1) | dropped | | response | `Location` | upstream origin rewritten back to the proxy origin, so a redirect stays inside the proxy | -| response body | `` | removed, like the header | +| response body | `` | removed unless the response opts into CSP preservation | **Must update this table whenever header rewriting changes.** +**Must preserve enforced and report-only CSP verbatim when the upstream response sends `X-Dormouse-Preserve-CSP: 1`.** Add the validated ancestor policy separately, for every MIME type; preserve meta policies during HTML instrumentation. Never infer this opt-in from request headers. Additional upstream restrictions may prevent framing or shim execution. (rationale) + **One dedicated `127.0.0.1:0` server per grant, with no token in the path** — the origin itself is the grant boundary (rationale). Grants have a sliding idle TTL and a hard cap; **a request refused by the `Host` check must not refresh the @@ -556,7 +559,7 @@ Security boundaries: - no user script is injected, - link-local/cloud-metadata ranges are blocked, - every other user-supplied `http://` target is trusted as the user's command, - at the cost of the upstream's own XSS policy inside the frame. + at the cost of the upstream's own XSS policy unless it opts into preservation. **Must replace framing controls with exactly `frame-ancestors 'self' `.** `'self'` permits same-grant nesting; foreign diff --git a/docs/specs/dor-browser.rationale.md b/docs/specs/dor-browser.rationale.md index 78107085f..07ccb6a9c 100644 --- a/docs/specs/dor-browser.rationale.md +++ b/docs/specs/dor-browser.rationale.md @@ -84,6 +84,8 @@ A post-open blank-tab sweep can become such a query when a later relaunch, expli **Why CSP is dropped whole rather than per-directive.** The injected shim is an inline script, so a surviving `script-src` blocks it as surely as `frame-ancestors` blocks the frame; salvaging the remaining directives would leave a frame that looks instrumented and silently is not. +The built-in local-file viewer supplies its own content boundary and permits the inline shim, so removing its CSP would expand active documents' resource access. Its response opts into preservation without new renderer or host-bridge state. The proxy adds an independent ancestor policy: CSP policies intersect, so no directive parser or partial reconstruction can accidentally weaken the upstream. An opt-in upstream with stricter framing or script restrictions keeps those restrictions even if the shim cannot run. + **Why a grant gets its own origin instead of a path token.** A dedicated origin keeps root-relative resources and client-side routers working with no body URL rewriting; a path token would have to survive every link, redirect and `fetch` the page makes. ## Iframe Shim diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 929f4a78e..4768c3448 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -63,6 +63,8 @@ and Tauri's `resource_dir()` hands out a verbatim prefix (rationale). misparses LF-only batch files (rationale), and staging copies bytes verbatim. `.gitattributes` pins it (`*.cmd text eol=crlf`; the POSIX launcher `eol=lf`). +**Must keep browser-shared CLI modules free of Node runtime dependencies**, even though the CLI package uses Node types. `dor/test/browser-shared.test.mjs` bundles their dependency graphs for the browser. (rationale) + ### Git Bash PATH survival **On Windows the `PATH` prepend must survive Git Bash / MSYS login:** the PTY @@ -645,7 +647,7 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d ## Dor Tools -**Must route `dor tool` and `dor open` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. +**Must route `dor tool` and `dor open` through the Tool launch contract**, including approval, explicit-key reuse, and placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `openCommand` in `dor/src/commands/open.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. diff --git a/docs/specs/dor-cli.rationale.md b/docs/specs/dor-cli.rationale.md index 9fe138570..8e5306c22 100644 --- a/docs/specs/dor-cli.rationale.md +++ b/docs/specs/dor-cli.rationale.md @@ -4,6 +4,8 @@ ## Bundling And PATH +The file viewer needs Node types, but the renderer imports CLI protocol and shell helpers as values. A browser-platform bundle check rejects Node imports in those helpers and their dependencies without splitting the CLI into separate TypeScript projects. + **What a missing `ELECTRON_RUN_AS_NODE` looks like.** Under VS Code `DORMOUSE_NODE` is the editor's Electron binary — Node only when that variable is set, and terminals routinely strip it from the ambient env. Without it Electron launches its GUI, ignores the script, and exits 0: no error, no output, success exit code, reading as "the command did nothing" rather than as a launcher bug. **Why the standalone's bundled node is GUI-subsystem.** A console-subsystem node pops a stray terminal window every time Rust spawns the sidecar, so the bundled binary is patched to the GUI subsystem — and that same patch leaves it no console to inherit. diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 45a35a64a..a08dede08 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -2,7 +2,6 @@ > See `docs/specs/glossary.md` for Surface / Session / Pane / Door vocabulary. > Owns tool designation, configuration, trust workflow, serving, and command lifecycle. Browser chrome belongs to `docs/specs/dor-browser.md`; notes and closure belong to `docs/specs/notepad.md`; helpers belong to `docs/specs/terminal-context.md`. -> Status: implemented behind `dormouse.flags.tools`, off by default. Unbuilt design is under [Future](#future). ## Files @@ -12,11 +11,11 @@ - `lib/src/components/wall/use-tool-serving.ts` — port discovery and browser lifetime. - `lib/src/components/wall/ToolPanel.tsx` — terminal/browser composition. -## Capability gating +## Availability -**Must gate tool creation on `isToolsEnabled`.** The flag disables new designation; existing Tools retain serving and exit cleanup. Inert announcement parsing and capability predicates remain active. Capability semantics belong to `docs/specs/glossary.md` → Panes and Surfaces; CLI reporting belongs to `docs/specs/dor-cli.md` → `dor list`. +**Must make `dor tool` and `dor open` available without a feature flag or Settings opt-in.** Project execution follows [Trust](#trust). -Source of truth: `isToolsEnabled` in `lib/src/lib/feature-flags.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`; `useToolServing` in `lib/src/components/wall/use-tool-serving.ts`. +Source of truth: `surface.tool` in `lib/src/components/wall/use-dor-control.ts`; `lib/src/components/Wall.test.tsx`. ## The tool capability set @@ -89,7 +88,7 @@ Approval layout follows `docs/specs/layout.md` → Pane body. **Must validate a bounded regular, non-symlink grant receipt for the requested key.** Missing, corrupt, or mismatched records grant nothing; a filename alone is never approval. -**Must keep implicit file dispatch user-global and limited to user-global Tools.** Reserved: any future repo `prespawn_*` execution uses the same approval; see scope **dor-tools** under [Future](#future). +**Must keep implicit file dispatch user-global and limited to user-global Tools or the built-in viewer.** Reserved: any future repo `prespawn_*` execution uses the same approval; see scope **dor-tools** under [Future](#future). Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrustStore` / `lookupTool` in `lib/src/host/tool-trust.ts`; `resolveUpstreamUrl` in `lib/src/host/git-upstream.ts`; `ToolApproval` in `lib/src/components/wall/ToolApproval.tsx`; `resolveToolApproval` in `lib/src/components/Wall.tsx`; `toolPendingFromParams` in `lib/src/components/wall/browser-surface.ts`. Tests: `lib/src/host/tool-trust.test.ts`, `lib/src/components/Wall.test.tsx`, `lib/src/components/wall/tool-surface.test.ts`. @@ -105,7 +104,7 @@ Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrust | Anonymous command | Uses `auto` | - **Must poll unbound Tools every 1.5 seconds while their command runs.** Reset settle memory and retire browser resources when the observed command-run id changes, even when the command text is unchanged; an initial observation preserves an imported live binding. (rationale) -- **Must let a changed announced port override a committed conflict or browser**, but only after a matching scan. An unchanged announcement never undoes URL-bar navigation. (rationale) +- **Must let a changed announced port or path override a committed conflict or browser**, but only after a matching scan. An unchanged announcement never undoes URL-bar navigation. (rationale) - **Must stop ordinary port scans once a browser or conflict is committed.** An unannounced additional port appearing after settle is not detected. - **Must display the browser destination before awaiting agent-browser startup**, clearing the existing session/stream binding during a reopen as well. Keep the session-less renderer inert and block Workspace transfer until the binding arrives. Close any browser session whose Tool disappeared or changed command during startup. - **Must reuse an existing browser session and its binary path when an announcement changes its destination.** @@ -132,6 +131,8 @@ Source of truth: `useToolServing` in `lib/src/components/wall/use-tool-serving.t **Must show the full terminal before serving and after command exit.** A serving Tool shows its browser, and Terminal Context reveals the same primary terminal (`docs/specs/terminal-context.md` → Tool context). Keep the browser mounted behind context, and keep the hidden terminal sized with `visibility` and `inert`, never `display: none`. Pending approval mounts neither capability. +**Must hide Tools in inactive Workspaces and minimized leaves without unmounting.** + Notepad follows `docs/specs/notepad.md` → Notepad UI. Tool context follows `docs/specs/terminal-context.md` → Tool context. Source of truth: `TerminalPane` in `lib/src/components/TerminalPane.tsx`; `focusSession` in `lib/src/lib/terminal-lifecycle.ts`; `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`; `ToolPaneHeader` in `lib/src/components/wall/ToolPaneHeader.tsx`; `toolLeafMeta` / `shouldParkOnMinimize` in `lib/src/components/wall/lath-wall-engine.ts`; `closeSurface` in `lib/src/components/Wall.tsx`. Tests: `lib/src/components/wall/ToolPanel.test.tsx`, `lib/src/components/Wall.test.tsx`, `lib/src/components/TerminalPane.test.tsx`, `lib/src/lib/terminal-registry.alert.test.ts`. @@ -148,27 +149,39 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot **Must accept exactly one existing local regular file for `dor open`**, resolved by the `$TARGET` rules in [Declaring tools](#declaring-tools). -**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. +**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a handler. Every association must name an argument-list Tool in that same user file or `builtin:file`. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. **Must match patterns without `/` against the canonical filename, and patterns with `/` against both paths relative to the canonical CWD and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. Use the supplied CWD if canonicalization fails; matching never changes the Tool's run directory or `$CWD`. A miss names the user config path and suggests `--tool`. (rationale) **Must pass the canonical file path as the selected Tool's one input.** Reuse follows [Identity and dedupe](#identity-and-dedupe), `$TARGET` in the key providing per-file identity; placement follows [Take-over](#take-over). -Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` in `lib/src/host/tool-open.ts`; `parseToolFile` in `lib/src/host/tool-registry.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`. Tests: `lib/src/host/tool-open.test.ts`, `dor/test/cli-output.test.mjs`, `lib/src/components/Wall.test.tsx`. +**Must reject declared Tool names beginning with `builtin:` in either configuration scope.** Built-in handler names cannot be shadowed. + +**Must use `builtin:file` for supported files when no user rule matches.** An explicit unknown handler or malformed user configuration fails without fallback. Selecting `builtin:file` for an unsupported format reports that limitation and suggests a user Tool. Built-in identity is the canonical file path in its own scope, separate from user and project Tools. + +**Must prefer known extensions over filename-based text fallbacks; source extensions remain escaped previews.** + +**Must run the built-in viewer as a Tool-owned `dor` process**, serving HTML, images, PDF/media, and escaped text/source previews. Markdown is source text; custom viewers may render it. Text previews and HTML/CSS dependency inspection are limited to 8 MiB per file. Text/source previews grant only their opened file and skip dependency inspection. (rationale) Oversized HTML and referenced CSS still stream without dependency inspection. The grant contains at most 256 files: the opened document and statically referenced relative HTML/CSS assets within its directory tree; exceeding that bound fails the open without serving a partial grant. Never expand the grant through root-relative, external, or dynamic references; requests can read only granted paths. + +**Must retain the viewer's opened file descriptors until the Tool exits.** Refresh reads those files again, but atomic replacements and changes to the dependency graph require restarting the viewer. Cold restore runs the saved file command with a fresh URL capability; Workspace movement keeps the live binding. The listener's authority is `docs/specs/security-local.md` → Local-file viewer. + +Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` in `lib/src/host/tool-open.ts`; `parseToolFile` in `lib/src/host/tool-registry.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`; `fileViewerFormat` in `dor/src/file-viewer-format.ts`; `startFileViewer` / `runFileViewer` in `dor/src/file-viewer.ts`. Tests: `lib/src/host/tool-open.test.ts`, `dor/test/cli-output.test.mjs`, `lib/src/components/Wall.test.tsx`, `dor/test/file-viewer.test.mjs`. ## Take-over -**Must run a standalone `dor tool` invocation in its calling pane when every takeover condition holds.** Otherwise use the ordinary split path. Trust approval and keyed reuse take precedence. (rationale) +**Must run a standalone `dor tool` or `dor open` invocation in its calling pane when every takeover condition holds.** Otherwise use the ordinary split path. Trust approval and keyed reuse take precedence. (rationale) | Condition | Required state | | --- | --- | -| Verb | `dor tool`; `dor open` never transforms a plain terminal, though a keyed match in its own Tool pane reruns there | -| Caller | Visible pane of the active Workspace; integrated plain terminal; not closing or dying | +| Verb | `dor tool` or `dor open` | +| Caller | Visible pane of the active Workspace; integrated plain terminal (not an existing Tool); not closing or dying | | Command line | OSC 633 reports the invocation alone; compound shell syntax rejects takeover | | Directory | Resolved Tool CWD equals the caller's reported CWD | | Placement | Neither `--surface` nor `--minimize` supplied | | Helper | No existing auxiliary helper; preserve it by splitting | +**Must retain Tool designation after its command exits.** Takeover is one-shot per Surface: a later invocation from that prompt splits unless keyed reuse finds a match; the same keyed Tool reruns in place through the handshake below. + **Must answer `takeover` before waiting for the calling shell's prompt**, then transform and type the command. The answer promises placement, not successful command startup. - **Must leave the caller unchanged on prompt timeout or cancellation**, and recheck transfer/closing state, pane membership, CWD, kind, and helper presence after the wait. A helper opened during the handshake prevents transformation. **Must complete an accepted takeover after switching Workspaces** without changing the active Workspace. (rationale) @@ -187,6 +200,7 @@ Source of truth: `toolTakesOverCaller` / `toolRerunsInCaller` / `callerStillPlac **Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` is the only implemented verb. The escape registry is `docs/specs/terminal-escapes.md`. - **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` and `parseToolAnnounce` own the field shapes and validation limits. +- **Must treat an optional serve `path` as a path/query on the discovered port, never as another authority.** Accept at most 2,048 characters starting with one `/`, with no backslash, ASCII whitespace/control, or DEL; invalid paths are ignored and the default is `/`. The port still must belong to the designated Session's process tree. Live binding memory includes the path; durable saves omit it. - **Must forward parsed announcements and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement; a later serve in the same chunk survives. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` scoped to the owning webview, with null clearing the hint. The fake adapter applies locally. - **Must reconstruct announcements and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert. - Reserved: **Must retain `name`, `dehydrate`, and `persist` as inert parsed fields**, serving the announced-name and D1/D2 items under [Future](#future). Neither `persist: never` nor a `dehydrate` verb changes current persistence. @@ -220,8 +234,6 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav **Scope: dor-tools** — remaining design, in implementation order. -- **C — local-file presentation.** The loopback file/viewer endpoint a local - file needs (the iframe proxy instruments only `http://` upstreams). - **D1 — reaping without cooperation.** Idle-threshold reap + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, no Windows question. diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index 657c29c38..ab75c7b41 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -42,6 +42,15 @@ A hardcoded Storybook port can disagree with the port it obtains under contentio The September 2026 integration reuses Terminal Context for the Tool's primary terminal. The auxiliary helper's automatic refresh, Reset, and Promote semantics do not describe a serving command, whose Session also owns the browser and remote terminal identity. Sharing the presentation avoids introducing a second navigation mechanism or a second shell. +## Opening local files + +A CSS source preview escapes its contents, so its URLs cannot load assets. Scanning those references adds unused authority and can reject a small source file at the asset limit. CSS loaded by HTML is active, so its dependencies still enter the bounded grant. + +Keeping the built-in viewer in the Tool's process tree reuses port discovery, kill, restart, and Workspace transfer. An OSC path carries the per-run URL capability without saving that secret in the restart command. Holding the selected file descriptors bounds what the server can read after launch; it trades automatic replacement-file refresh for a grant whose contents cannot widen through path replacement. + + +The VS Code host supports Node 18, which lacks native glob matching. Bundled picomatch keeps association behavior the same across hosts. Patterns with separators test both the CWD-relative and canonical absolute path: files above the CWD otherwise start with `../` and can miss patterns intended to cover an absolute directory. Canonicalization also gives symlink aliases one matching identity. + ## Take-over User input queued before injection can complete ahead of the Tool. A changed completion id alone releases the queue too early; matching the command and its start directory distinguishes the requested launch while accepting a Tool that finishes between polls. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index fcd6c420d..9f57bf98c 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -132,6 +132,16 @@ Source of truth: the shared rule and predicates — `isLoopbackHost`, `isOwnOrig `isForeignOrigin` — in `lib/src/host/loopback-guard.ts`; `startDevVite` in `standalone/scripts/dev-run.mjs`. +### Local-file viewer + +**FAIL IF** `dor/src/file-viewer.ts` serves any request without the fresh 256-bit URL capability, its own case-insensitive loopback `Host`, an absent or same-listener `Origin`, and a GET/HEAD method. Compare capability prefixes by SHA-256 then `timingSafeEqual`, including malformed lengths. `allowsFileViewerRequest` in `dor/src/file-viewer-loopback-guard.ts` gates every route. Never grant CORS access to foreign origins, cache responses, or send the capability as a referrer. + +**FAIL IF** the local-file viewer exposes directory listings, arbitrary path reads, writes, or a file outside its opened-document grant. Grant construction permits only regular files, rejects symlinks escaping the canonical document directory, bounds static dependency discovery, and retains descriptors so later path replacement cannot widen the grant. Viewer resource loads are restricted by CSP to its own origin plus inline scripts/styles and data images, including through the iframe proxy; escaped text previews execute no document markup. The viewer opts into the proxy's upstream-policy preservation (`docs/specs/dor-browser.md` → Iframe Renderer). + +**Must not describe the viewer CSP as confining active documents' navigation.** HTML/SVG scripts can navigate their frame to external URLs, including with granted contents; the resource policy is not a no-egress boundary. (rationale) + +Source of truth: `startFileViewer` in `dor/src/file-viewer.ts`; `allowsFileViewerRequest` in `dor/src/file-viewer-loopback-guard.ts`; `sanitizeResponseHeaders` in `lib/src/host/iframe-proxy.ts`. Tests: `dor/test/file-viewer.test.mjs`, `lib/src/host/file-viewer-proxy.test.ts`. + ## Persisted state The attacker is another local account reading disk; what the remote stack leaves diff --git a/docs/specs/security-local.rationale.md b/docs/specs/security-local.rationale.md index 4922fb22c..521cf3b01 100644 --- a/docs/specs/security-local.rationale.md +++ b/docs/specs/security-local.rationale.md @@ -174,3 +174,7 @@ neither sets a mode, and `restrict_to_owner` is never called on it. The socket path arrives via the sidecar's stderr, which Rust appends verbatim. What the snapshot tests cover. `restrict_to_owner_leaves_one_owner_only_ace` is Windows-only and asserts `SE_DACL_PROTECTED`, one ACE, and the SID. `session_write_tightens_directory_and_existing_temp_file` exercises the unix writer against deliberately loose modes. The failure regression injects rejection at each permission stage, verifying the old snapshot survives and no replacement bytes reach disk. The single-ACE property depends on `FILE_ALL_ACCESS` rather than `GENERIC_ALL`, which would split into two ACEs. + +## Local-file viewer + +The viewer allows inline and granted scripts for interactive local reports. CSP fetch directives constrain resource requests, but do not prevent a script assigning an external URL to its own frame. `form-action` constrains form submissions, not arbitrary navigation. Preserving the policy through the proxy repairs the resource-load boundary; it does not establish that active documents cannot send granted contents outside the machine. [CSP3 navigation checks](https://www.w3.org/TR/CSP3/) and its multiple-policy rules distinguish these mechanisms. diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 8a7a6b532..0575646d6 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -61,7 +61,7 @@ Replay (`pty:replay`) is the raw stream requiring re-parse: **the webview runs a | `OSC 633 ; E ; [; ] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 633 ; P ; Cwd= ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 777 ; notify ; ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#terminal-reports) | -| `OSC 367 ; serve ; <json> ST` | Dor Tool announcement: names which bound port to frame, plus a reserved name and runtime re-key | [dor-tool.md](dor-tool.md#osc-367) | +| `OSC 367 ; serve ; <json> ST` | Dor Tool announcement: selects a bound port and optional same-origin path, plus a reserved name and runtime re-key | [dor-tool.md](dor-tool.md#osc-367) | | `OSC 367 ; <any other verb> ST` | Reserved for the staged `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) | | `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 1337 ; File=...:<data> ST` / `MultipartFile=...` / `FilePart=...` / `FileEnd` | iTerm2 inline image protocol (IIP); passed through to ImageAddon. | [Inline graphics](#inline-graphics) | diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index f2d4ebac1..c2781fbf3 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -122,12 +122,12 @@ Source of truth: `RestoreToken` / `restore` in `lib/src/lib/lath/ops.ts`. A **parked** leaf is mounted by the adapter but absent from the split tree: its DOM survives while it lays out nothing, paints nothing, and takes no input. It exists for Surfaces whose state lives *in the DOM* — an `<iframe>`'s document, a screencast canvas — where a plain remove turns reattach into a reload. -**Detaching and parking are separate things.** Every minimize doors, terminal or browser, because the store stays the authority for a Doored Surface's live title and params; only `{ park: true }` also keeps the DOM. +**Detaching and parking are separate things.** Every minimize doors, regardless of Surface kind, because the store stays the authority for a Doored Surface's live title and params; only `{ park: true }` also keeps the DOM. | Store op | Tree | Meta | DOM | | --- | --- | --- | --- | | `doorLeaf(id)` | out | kept | unmounted | -| `doorLeaf(id, { park: true })` | out | kept | **mounted** — browser Surfaces only | +| `doorLeaf(id, { park: true })` | out | kept | **mounted** — browser and Tool Surfaces | | `addDoor(id, meta)` | never in | registered | none — a Surface **born minimized**, with no pane to detach (`dor split` / `dor ensure` targeting another Door) | | `removeLeaf(id)` | out | destroyed | unmounted — a kill | | `forgetLeaf(id)` | — | destroyed | unmounted if parked — destroys a Door | @@ -136,8 +136,8 @@ A **parked** leaf is mounted by the adapter but absent from the split tree: its - **One `leafMeta` map holds every leaf the Wall owns**, laid out or Doored; `parked` is pure render state (`Map<id, Rect | null>`) naming the subset that keeps its DOM. Detachment is a fact about the *tree*, so **no Door record carries a metadata copy that can go stale** — `setTitle` / `updateParams` / `setMeta` reach a Doored leaf by the same single path as a visible one, and every reader goes through `lath.getMeta(id)` (rationale). `serializeLayout` filters `leafMeta` to the tree's own leaves; a Door persists as its own row. - **The store holds a parked leaf's last rect, never the adapter** — `registerEl(null)` is a ref detach, not an unmount (rationale). `doorLeaf({ park: true })` captures the rect in the commit that removes the leaf from the tree, `admit` replays it into the animator on re-admission (Animation → Enter), and LathHost renders parked ids there behind `visibility: hidden; pointer-events: none` and `data-lath-parked`, so the guest never sees a zero-extent viewport (rationale). A leaf parked before the Wall reports geometry falls back to the whole wall rect. - **Parked is a visibility signal, not just a layout fact** — it reaches the body as `PaneProps.parked` (Pane props contract), so a minimized `ab-screencast` stays mounted, releases viewer resources, and retains its daemon session. -- **Who parks**: `shouldParkOnMinimize` — browser Surfaces, not terminals, whose persistent xterm instance remounts without replay ([glossary.md → View](glossary.md#view)). -- **Never evict parked browser DOM to enforce a count limit.** Parked documents remain mounted until reattachment or Surface destruction; retention is unbounded. Tests: `lib/src/components/wall/LathHost.test.tsx`. (rationale) Parking budgets **minimized browser Surfaces only**: a hidden Workspace parks nothing — its leaves stay mounted and merely stop painting (`docs/specs/layout.md` → Workspaces). +- **Must park browser and Tool Surfaces** via `shouldParkOnMinimize`, unlike terminals, whose persistent xterm instance remounts without replay ([glossary.md → View](glossary.md#view)). +- **Never evict parked browser or Tool DOM to enforce a count limit.** Parked documents remain mounted until reattachment or Surface destruction; retention is unbounded. Tests: `lib/src/components/wall/LathHost.test.tsx`. (rationale) Parking budgets **minimized browser and Tool Surfaces only**: a hidden Workspace parks nothing — its leaves stay mounted and merely stop painting (`docs/specs/layout.md` → Workspaces). - **Hydration.** A restored session's Doors have no store entry yet, so `seed` puts the persisted rows' meta into `leafMeta` beside the tree's leaves (`leafMetaFromPersistedDoor`) — the only place a Door's wire row is read for metadata. The runtime record is `{ id, token }`. Source of truth: `parked` / `doorLeaf` / `addDoor` / `forgetLeaf` / `parkedIds` in `lib/src/components/wall/lath-wall-store.ts`; `shouldParkOnMinimize` / `leafMetaFromPersistedDoor` in `lib/src/components/wall/lath-wall-engine.ts`; `minimizePane` in `lib/src/components/Wall.tsx`; the parked render branch in `lib/src/components/wall/LathHost.tsx`. @@ -190,7 +190,7 @@ Source of truth: `createAnimator` in `lib/src/lib/lath/animator.ts`; the animato - **Read side**: `PaneProps` — `{ id, title, params, parked? }`, supplied by LathHost straight from `leafMeta`, parked leaves included; a meta commit re-renders the leaf, so params stay live either way. - **Write side**: `PaneWriteContext` (`{ setTitle(id, t), updateParams(id, patch) }`), provided by the Wall over the store (`lath.store.setTitle` / `lath.store.updateParams`); the `wsPort`-refresh and render-swap flows route through the same seam. The value is stable per mount; the `AgentBrowserPanel` controller sink captures it once. -- **Visibility**: a mounted leaf is engine-visible unless **parked**, so `parked` is the one non-meta pane prop and absent means "not parked" — right for anything rendered outside LathHost. `useSurfaceVisibility(parked)` folds it with document visibility and the Wall's Workspace being the visible one (`docs/specs/layout.md` → "Workspaces"), so a backgrounded window, a hidden Workspace, and a minimized browser Surface all gate streaming while the session stays alive. +- **Visibility**: a mounted leaf is engine-visible unless **parked**, so `parked` is the one non-meta pane prop and absent means "not parked" — right for anything rendered outside LathHost. `useSurfaceVisibility(parked)` folds it with document visibility and the Wall's Workspace being the visible one (`docs/specs/layout.md` → "Workspaces"), so a backgrounded window, a hidden Workspace, and a minimized browser or Tool Surface all gate streaming while the session stays alive. - **Terminal sizing**: `TerminalResizeContext` gates fitting; `docs/specs/layout.md` → "Animations" owns the rule. - `use-pane-chrome` registers the pane's root element in `PaneElementsContext`, for the overlays to measure, and nothing else — there is no CSS spawn-animation to trigger. diff --git a/dor/package.json b/dor/package.json index 2f584b801..258245704 100644 --- a/dor/package.json +++ b/dor/package.json @@ -17,6 +17,7 @@ "test": "pnpm run build && node --test test/*.test.mjs" }, "devDependencies": { + "@types/node": "^24.13.4", "esbuild": "^0.28.0", "typescript": "^6.0.3" }, diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 1d4646578..ed15a123b 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -22,6 +22,8 @@ import { openCommand } from './commands/open.js'; import { versionCommand } from './commands/version.js'; import { workspaceCommand } from './commands/workspace.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; +import { VIEW_FILE_ARGV } from './file-viewer-format.js'; +import { runFileViewer } from './file-viewer.js'; import type { CliEnv, CliOptions, @@ -194,6 +196,12 @@ export async function runCli(rawArgv: string[], options: CliOptions = {}): Promi if (argv[0] === 'agent-browser' && !isAgentBrowserHelpInvocation(argv)) { return runAgentBrowserCli(argv.slice(1), options); } + // `dor __view-file <file>` is the built-in viewer's private entry + // (docs/specs/dor-tool.md -> Opening local files). Its server outlives this + // call; the announcement is the only output. + if (argv[0] === VIEW_FILE_ARGV && argv.length === 2) { + return { stdout: await runFileViewer(argv[1]), stderr: '', exitCode: 0 }; + } const helpTarget = getHelpTarget(argv); const [commandName, ...args] = rewriteHelpArgv(argv); diff --git a/dor/src/commands/open-target.ts b/dor/src/commands/open-target.ts index 08e240747..333002559 100644 --- a/dor/src/commands/open-target.ts +++ b/dor/src/commands/open-target.ts @@ -4,10 +4,6 @@ import { errorMessage, workspaceParam } from './shared.js'; import type { ControlClient, ParseResult } from './types.js'; -declare const URL: { - new(input: string): { href: string; protocol: string }; -}; - // A bare `:port` (optionally trailed by a path/query/hash) — localhost sugar. const BARE_PORT = /^:\d{1,5}(?:[/?#].*)?$/; // A schemeless host:port (optional path): `localhost:5173`, `box.ts.net:3000`, diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts index df0c0674e..d4f1803b5 100644 --- a/dor/src/commands/open.ts +++ b/dor/src/commands/open.ts @@ -19,13 +19,13 @@ export const openCommand: Command = { brief: 'Open a local file with a Dor Tool.', fullDescription: `Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. -The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. +The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, PDF, and media files. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. -The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The built-in HTML viewer serves statically referenced relative assets within the document directory tree; root-relative and external resources are unavailable. Text previews are capped at 8 MiB. The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. -Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output.`, +Placement follows dor tool: typed alone at a prompt in a visible, integrated plain terminal in the requested directory, opening takes over that pane, preserving its terminal and scrollback. Agent/script invocations, compound lines, a pane with a helper, --minimize, --surface, or --cwd elsewhere split without taking focus. The pane remains a Tool after its command exits: opening a different file from that prompt splits unless keyed reuse finds an existing Tool; the same keyed file reruns in place. A matching Tool elsewhere is reused. The command prints the Surface handle; --json prints structured output.`, }, parameters: { flags: { @@ -35,7 +35,7 @@ Opening creates a focus-neutral split or reveals an existing Tool, never taking surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, workspace: workspaceFlag, cwd: { kind: 'parsed', parse: stringParser, brief: 'Directory for resolving the file.', optional: true, placeholder: 'path' }, - tool: { kind: 'parsed', parse: stringParser, brief: 'Use this user-global Tool.', optional: true, placeholder: 'name' }, + tool: { kind: 'parsed', parse: stringParser, brief: 'Use a user Tool or builtin:file.', optional: true, placeholder: 'name' }, }, positional: { kind: 'tuple', parameters: [{ parse: stringParser, brief: 'Local file to open.', placeholder: 'file' }] }, }, diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts index 9b54e81a7..c2081ea7f 100644 --- a/dor/src/commands/tool.ts +++ b/dor/src/commands/tool.ts @@ -113,7 +113,7 @@ A project dormouse.yml is repo-controlled and its entries execute, so it is iner Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read. -Where the tool lands: typed alone at a prompt in a visible, integrated plain terminal whose directory is the tool's, it takes over that pane — no split, same surface, same scrollback — and reports "takeover". Anything else — an agent's invocation, a compound line, a pane with a helper, --minimize, --surface, --cwd elsewhere — splits without taking focus and prints the new surface's handle. The handle prints before the command starts, since dor has to exit before its own shell is free to run it. +Where the tool lands: typed alone at a prompt in a visible, integrated plain terminal whose directory is the tool's, it takes over that pane — no split, same surface, same scrollback — and reports "takeover". Anything else — an agent's invocation, a compound line, a pane with a helper, --minimize, --surface, --cwd elsewhere — splits without taking focus and prints the new surface's handle. The pane remains a Tool after its command exits: another invocation from that prompt splits unless it matches a keyed Tool; the same keyed Tool reruns in place. The handle prints before the command starts, since dor has to exit before its own shell is free to run it. --cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. diff --git a/dor/src/dor.ts b/dor/src/dor.ts index dfc58b746..d0bae24cb 100644 --- a/dor/src/dor.ts +++ b/dor/src/dor.ts @@ -2,23 +2,6 @@ import { runCli } from './cli.js'; -type ProcessLike = { - argv: string[]; - env: Record<string, string | undefined>; - exitCode?: number; - stdout: { write(chunk: string): void }; - stderr: { write(chunk: string): void }; - stdin: { - setEncoding?(encoding: string): void; - on(event: 'data', listener: (chunk: string) => void): void; - on(event: 'end', listener: () => void): void; - on(event: 'error', listener: (error: Error) => void): void; - resume?(): void; - }; -}; - -declare const process: ProcessLike; - runCli(process.argv.slice(2), { env: process.env, readStdin }).then( (result) => { process.stdout.write(result.stdout); @@ -34,10 +17,10 @@ runCli(process.argv.slice(2), { env: process.env, readStdin }).then( function readStdin(): Promise<string> { return new Promise((resolve, reject) => { const chunks: string[] = []; - process.stdin.setEncoding?.('utf8'); - process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => chunks.push(String(chunk))); process.stdin.on('end', () => resolve(chunks.join(''))); process.stdin.on('error', reject); - process.stdin.resume?.(); + process.stdin.resume(); }); } diff --git a/dor/src/file-viewer-format.ts b/dor/src/file-viewer-format.ts new file mode 100644 index 000000000..c6d700a2a --- /dev/null +++ b/dor/src/file-viewer-format.ts @@ -0,0 +1,27 @@ +/** Formats supported by the built-in local viewer. Unknown files need a user + * Tool association rather than being guessed to be text. */ +const MIME: Record<string, string> = { + html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', + pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', + css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', + json: 'application/json', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', + mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', +}; +const TEXT = new Set(['txt', 'md', 'mdx', 'log', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'xml', + 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'sh', 'ps1', 'sql', 'ini', 'conf']); + +/** The handler name an `open` rule or `--tool` uses to select the viewer, and + * the private `dor` argv verb that runs it. The lib host's `resolveOpenTool` + * shares both through the `dor/*` alias; this module stays free of Node APIs. */ +export const BUILTIN_FILE_TOOL = 'builtin:file'; +export const VIEW_FILE_ARGV = '__view-file'; + +export function fileViewerFormat(path: string): { mime: string; text: boolean } | null { + const name = path.replace(/\\/g, '/').split('/').pop()!.toLowerCase(); + const ext = name.includes('.') ? name.split('.').pop()! : ''; + const knownMime = Object.prototype.hasOwnProperty.call(MIME, ext) ? MIME[ext] : undefined; + const text = TEXT.has(ext) || (!knownMime && /^(readme|license|licence|makefile|dockerfile|\.gitignore|\.env)(\..*)?$/.test(name)); + const mime = knownMime ?? (text ? 'text/plain; charset=utf-8' : null); + return mime ? { mime, text } : null; +} diff --git a/dor/src/file-viewer-loopback-guard.ts b/dor/src/file-viewer-loopback-guard.ts new file mode 100644 index 000000000..0db2f6c81 --- /dev/null +++ b/dor/src/file-viewer-loopback-guard.ts @@ -0,0 +1,18 @@ +import type { IncomingMessage } from 'node:http'; +import { createHash, timingSafeEqual } from 'node:crypto'; + +const sha256 = (value: string) => createHash('sha256').update(value).digest(); + +/** This listener owns its URLs: a per-process 256-bit capability authorizes + * each read. Host and Origin checks also reject rebinding and foreign scripts. + * See docs/specs/security-local.md -> Local-file viewer. The Host/Origin rule is + * the one `lib/src/host/loopback-guard.ts` states for every loopback listener; + * `dor` cannot import `lib`, so it is restated here with case-insensitive Host comparison. */ +export function allowsFileViewerRequest(req: IncomingMessage, port: number, prefix: string): boolean { + const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`]; + return (req.method === 'GET' || req.method === 'HEAD') + && origins.includes(`http://${(req.headers.host ?? '').toLowerCase()}`) + && (!req.headers.origin || origins.includes(req.headers.origin)) + // Hash both sides so malformed lengths cannot make timingSafeEqual throw. + && timingSafeEqual(sha256(req.url?.slice(0, prefix.length) ?? ''), sha256(prefix)); +} diff --git a/dor/src/file-viewer.ts b/dor/src/file-viewer.ts new file mode 100644 index 000000000..e99478169 --- /dev/null +++ b/dor/src/file-viewer.ts @@ -0,0 +1,195 @@ +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import type { ServerResponse } from 'node:http'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileViewerFormat } from './file-viewer-format.js'; +import { allowsFileViewerRequest } from './file-viewer-loopback-guard.js'; + +const TEXT_LIMIT = 8 * 1024 * 1024; +const ASSET_LIMIT = 256; +const CHUNK = 64 * 1024; +type Resource = { file: FileHandle; mime: string }; +/** A bound on the grant itself: fatal even when reached through an optional asset. */ +class ViewerLimitError extends Error {} +const HTML_ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => HTML_ESCAPES[c]!); + +async function textSize(file: FileHandle): Promise<number> { + const { size } = await file.stat(); + if (size > TEXT_LIMIT) throw new ViewerLimitError('text preview exceeds 8 MiB; configure a Tool for this file'); + return size; +} + +async function readText(file: FileHandle): Promise<string> { + const size = await textSize(file); + const bytes = Buffer.allocUnsafe(size + 1); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, offset); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > size) throw new Error('file changed while preparing preview; open it again'); + return bytes.subarray(0, offset).toString('utf8'); +} + +/** Static local dependencies only. No directory browsing, arbitrary fetch API, + * or external URL loading. Relative CSS dependencies are followed recursively. */ +function references(text: string, html: boolean): string[] { + const refs: string[] = []; + if (html) { + for (const tag of text.matchAll(/<(?:img|script|link|source|video|audio|iframe|embed|object)\b[^>]*>/gi)) { + for (const attr of tag[0].matchAll(/\b(?:src|href|poster|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) { + refs.push(attr[1] ?? attr[2] ?? attr[3]); + } + } + } + for (const match of text.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]*))\s*\)|@import\s+["']([^"']+)["']/gi)) { + refs.push(match[1] ?? match[2] ?? match[3] ?? match[4]); + } + return refs; +} + +function finish(res: ServerResponse, status: number, message = ''): void { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(message); +} + +/** One Tool process owns one file grant and its file descriptors. Restarting + * creates a fresh capability; only the file argument is persisted by Dormouse. */ +export async function startFileViewer(input: string): Promise<{ port: number; path: string; close(): Promise<void> }> { + // Loaded on demand: this module is bundled into every `dor` invocation, and + // these two builtins cost more to load than everything else the CLI touches. + const [{ open, realpath }, { createServer }] = await Promise.all([import('node:fs/promises'), import('node:http')]); + const target = await realpath(input); + const format = fileViewerFormat(target); + if (!format) throw new Error('unsupported file format; configure a user Tool association'); + // A source preview escapes the document; none of its references load. + const inspectDependencies = !format.text; + const root = dirname(target); + const prefix = `/${randomBytes(32).toString('hex')}/`; + const resources = new Map<string, Resource>(); + const scanned = new Set<string>(); + const closeFiles = async () => { await Promise.all([...resources.values()].map(r => r.file.close())); }; + const outsideRoot = (path: string) => { + const rel = relative(root, path); + return isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`); + }; + + async function register(path: string, required: boolean): Promise<Resource | undefined> { + try { + const route = `file/${relative(root, path).split(sep).join('/')}`; + if (resources.has(route)) return resources.get(route); + const canonical = await realpath(path); + if (outsideRoot(canonical)) return; + if (resources.size >= ASSET_LIMIT) throw new ViewerLimitError('local preview exceeds 256 referenced files'); + const type = fileViewerFormat(canonical); + if (!type) return; + const file = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + try { if (!(await file.stat()).isFile()) throw new Error('not a regular file'); } + catch (error) { await file.close(); throw error; } + const resource = { file, mime: type.mime }; + resources.set(route, resource); // the grant owns the descriptor from here + const html = type.mime.startsWith('text/html'); + if (inspectDependencies && (html || type.mime.startsWith('text/css')) && !scanned.has(canonical)) { + scanned.add(canonical); + // Inspection is optional: large or changing HTML/CSS can still stream. + const contents = await readText(file).catch(() => ''); + for (const ref of references(contents, html)) { + if (!ref || ref.startsWith('/') || ref.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(ref) || ref.includes('\\')) continue; + let local: string; + try { local = decodeURIComponent(ref.split(/[?#]/, 1)[0]); } catch { continue; } + const asset = resolve(dirname(path), local); + if (outsideRoot(asset)) continue; + await register(asset, false); + } + } + return resource; + } catch (error) { + if (required || error instanceof ViewerLimitError) throw error; + return; // A missing/broken relative asset stays unavailable; never broaden the grant. + } + } + + try { + const main = await register(target, true); + if (!main) throw new Error('not a supported regular file'); + if (format.text) await textSize(main.file); // fail oversized text before announcing + let port = 0; + const server = createServer((req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + // The iframe proxy must retain this policy on every MIME type. + res.setHeader('X-Dormouse-Preserve-CSP', '1'); + res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'self'; base-uri 'self'; form-action 'none'"); + if (!allowsFileViewerRequest(req, port, prefix)) { finish(res, 403); return; } + void (async () => { + let route: string; + try { route = decodeURIComponent(new URL(req.url!, 'http://localhost').pathname.slice(prefix.length)); } + catch { finish(res, 400); return; } + if (route.includes('\\') || route.split('/').some(part => part === '..' || part === '.')) { finish(res, 403); return; } + if (route === 'view' && format.text) { + const text = await readText(main.file); + const body = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(basename(target))}
${escapeHtml(text)}
`; + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(body) }); + res.end(req.method === 'HEAD' ? undefined : body); + return; + } + const resource = resources.get(route); + if (!resource) { finish(res, 404); return; } + const size = (await resource.file.stat()).size; + let start = 0; + let end = size - 1; + const range = req.headers.range; + if (range) { + const match = /^bytes=(\d*)-(\d*)$/.exec(range); + if (!match || (!match[1] && !match[2])) { res.setHeader('Content-Range', `bytes */${size}`); finish(res, 416); return; } + start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2])); + end = match[1] && match[2] ? Math.min(Number(match[2]), end) : end; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start < 0 || start >= size) { + res.setHeader('Content-Range', `bytes */${size}`); finish(res, 416); return; + } + res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`); + } + res.writeHead(range ? 206 : 200, { 'Content-Type': resource.mime, 'Content-Length': Math.max(0, end - start + 1), 'Accept-Ranges': 'bytes' }); + if (req.method === 'HEAD' || size === 0) { res.end(); return; } + // Positional reads let simultaneous range requests share a descriptor, + // and a disconnected response must not destroy the grant's shared handle. + // Each chunk is a fresh buffer because res.write queues it without copying. + for (let offset = start; offset <= end && !res.destroyed;) { + const chunk = Buffer.allocUnsafe(Math.min(CHUNK, end - offset + 1)); + const { bytesRead } = await resource.file.read(chunk, 0, chunk.length, offset); + if (!bytesRead) { res.destroy(); return; } + offset += bytesRead; + if (!res.write(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)) && !res.destroyed) await new Promise(done => { + const complete = () => { res.off('drain', complete); res.off('close', complete); done(); }; + res.once('drain', complete); + res.once('close', complete); + }); + } + res.end(); + })().catch(() => { if (res.headersSent) res.destroy(); else finish(res, 500, 'File preview unavailable'); }); + }); + await new Promise((yes, no) => { server.once('error', no); server.listen(0, '127.0.0.1', yes); }); + port = (server.address() as { port: number }).port; + let closing: Promise | undefined; + return { port, path: `${prefix}${format.text ? 'view' : `file/${encodeURIComponent(basename(target))}`}`, + close: () => closing ??= new Promise((yes, no) => { + server.close(error => { void closeFiles().then(() => error ? no(error) : yes(), no); }); + server.closeAllConnections(); + }), + }; + } catch (error) { await closeFiles(); throw error; } +} + +/** The `dor __view-file ` entry: starts the viewer, which outlives the + * call, and returns the OSC 367 announcement for the caller to print. */ +export async function runFileViewer(file: string): Promise { + const viewer = await startFileViewer(file); + const stop = () => { void viewer.close().then(() => { process.exitCode = 0; }); }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + return `\x1b]367;serve;${JSON.stringify({ port: viewer.port, path: viewer.path, v: 1 })}\x07`; +} diff --git a/dor/src/node-runtime.d.ts b/dor/src/node-runtime.d.ts deleted file mode 100644 index 500a86779..000000000 --- a/dor/src/node-runtime.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -declare module 'node:net' { - export interface Socket { - setEncoding(encoding: string): this; - write(data: string): boolean; - destroy(): this; - on(event: 'connect', listener: () => void): this; - on(event: 'data', listener: (chunk: string) => void): this; - on(event: 'error', listener: (error: Error) => void): this; - on(event: 'end', listener: () => void): this; - } - - export function createConnection(options: { path: string }): Socket; -} - -declare module 'node:crypto' { - // Opaque stand-in for Buffer: this package ships without @types/node (see the - // hand-written shims around it), and nothing here needs more than "the thing - // digest() returns, which timingSafeEqual accepts". - export interface BinaryDigest { - readonly length: number; - } - - export interface Hash { - update(data: string): Hash; - digest(): BinaryDigest; - digest(encoding: 'hex'): string; - } - - export function createHash(algorithm: string): Hash; - export function createHmac(algorithm: string, key: string): Hash; - export function randomBytes(size: number): { toString(encoding: 'hex'): string }; - export function timingSafeEqual(a: BinaryDigest, b: BinaryDigest): boolean; -} - -declare module 'node:fs' { - export function existsSync(path: string): boolean; - export function readFileSync(path: string, encoding: 'utf8'): string; - export function writeFileSync(path: string, data: string): void; -} - -declare module 'node:path' { - export function resolve(...segments: string[]): string; -} - -declare const process: { - platform: string; - cwd(): string; -}; - -declare function setTimeout(callback: () => void, ms?: number): number; -declare function clearTimeout(timeoutId: number): void; diff --git a/dor/test/browser-shared.test.mjs b/dor/test/browser-shared.test.mjs new file mode 100644 index 000000000..9c0f2250f --- /dev/null +++ b/dor/test/browser-shared.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +// These CLI entrypoints also serve the renderer (the format helper serves the +// host). Node types in the CLI must not admit a runtime Node dependency here. +test('shared CLI modules bundle for a browser without Node shims', async () => { + await build({ + entryPoints: ['commands/types', 'commands/shell-quote', 'protocol', 'file-viewer-format'] + .map(name => fileURLToPath(new URL(`../src/${name}.ts`, import.meta.url))), + outdir: 'browser-shared-test', + bundle: true, + platform: 'browser', + format: 'esm', + treeShaking: false, + write: false, + logLevel: 'silent', + }); +}); diff --git a/dor/test/file-viewer.test.mjs b/dor/test/file-viewer.test.mjs new file mode 100644 index 000000000..478d45b96 --- /dev/null +++ b/dor/test/file-viewer.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { request } from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, test } from 'node:test'; +import { startFileViewer } from '../dist/file-viewer.js'; +import { fileViewerFormat } from '../dist/file-viewer-format.js'; + +let root; +const viewers = []; +beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'dor-viewer-'))); }); +afterEach(async () => { + await Promise.all(viewers.splice(0).map(v => v.close())); + await rm(root, { recursive: true, force: true }); +}); +async function start(name, contents) { + const file = join(root, name); + await writeFile(file, contents); + const viewer = await startFileViewer(file); + viewers.push(viewer); + return viewer; +} +async function get(viewer, path = viewer.path, headers = {}, method = 'GET') { + return new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port: viewer.port, path, headers, method }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() })); + }); + req.on('error', reject); + req.end(); + }); +} +const asset = (viewer, path) => viewer.path.replace(/\/file\/.*$/, `/file/${path}`); + +test('known formats override source-name heuristics without treating prototype keys as formats', () => { + for (const [name, mime] of [['README.pdf', 'application/pdf'], ['readme.png', 'image/png'], ['LICENSE.html', 'text/html; charset=utf-8']]) { + assert.deepEqual(fileViewerFormat(name), { mime, text: false }); + } + for (const name of ['README', 'Dockerfile.dev', 'README.md', '.gitignore']) { + assert.deepEqual(fileViewerFormat(name), { mime: 'text/plain; charset=utf-8', text: true }); + } + assert.deepEqual(fileViewerFormat('README.css'), { mime: 'text/css; charset=utf-8', text: true }); + assert.equal(fileViewerFormat('file.constructor'), null); +}); + +test('renders text as escaped content and requires the per-run token on every method', async () => { + const viewer = await start('README.md', ' & hello'); + const good = await get(viewer); + assert.equal(good.status, 200); + assert.match(good.body, /<script>bad\(\)<\/script> & hello/); + assert.equal(good.headers['referrer-policy'], 'no-referrer'); + assert.equal(good.headers['cache-control'], 'no-store'); + for (const path of ['/', '/wrong/view', viewer.path.replace(/\/[a-f0-9]{64}\//, '/')]) { + assert.equal((await get(viewer, path)).status, 403); + } + assert.equal((await get(viewer, viewer.path, { Host: `evil.test:${viewer.port}` })).status, 403); + assert.equal((await get(viewer, viewer.path, { Host: `LOCALHOST:${viewer.port}` })).status, 200); + const prefix = viewer.path.split('/')[1]; + for (const token of [prefix.slice(1), prefix + '0', `${prefix[0] === '0' ? '1' : '0'}${prefix.slice(1)}`, `${prefix.slice(0, -1)}${prefix.at(-1) === '0' ? '1' : '0'}`]) { + assert.equal((await get(viewer, viewer.path.replace(prefix, token))).status, 403); + } + assert.equal((await get(viewer, viewer.path, { Origin: 'https://evil.test' })).status, 403); + assert.equal((await get(viewer, viewer.path, {}, 'POST')).status, 403); + assert.equal((await get(viewer, viewer.path, {}, 'HEAD')).body, ''); + const second = await startFileViewer(join(root, 'README.md')); + viewers.push(second); + assert.notEqual(second.path, viewer.path); + assert.equal((await get(second, viewer.path)).status, 403); +}); + +test('serves only the HTML document and its bounded relative dependency graph', async () => { + await mkdir(join(root, 'assets')); + await writeFile(join(root, 'assets', 'style.css'), '@import "more.css"; body { background: url(pic.svg) }'); + await writeFile(join(root, 'assets', 'more.css'), 'body { color: red }'); + await writeFile(join(root, 'assets', 'pic.svg'), ''); + await writeFile(join(root, 'unreferenced.txt'), 'private sibling'); + const viewer = await start('LICENSE.html', '

Preview

'); + const response = await get(viewer); + assert.equal(response.status, 200); + assert.equal(response.headers['content-type'], 'text/html; charset=utf-8'); + assert.match(response.body, /

Preview<\/h1>/); + for (const path of ['assets/style.css', 'assets/more.css', 'assets/pic.svg']) assert.equal((await get(viewer, asset(viewer, path))).status, 200); + assert.equal((await get(viewer, asset(viewer, 'unreferenced.txt'))).status, 404); + assert.notEqual((await get(viewer, asset(viewer, '%2e%2e/unreferenced.txt'))).status, 200); + assert.equal((await get(viewer, asset(viewer, '%E0%A4%A'))).status, 400); + assert.equal((await get(viewer, asset(viewer, 'assets/style.css'))).status, 200); // repeated streams retain the grant +}); + +test('rejects parent-directory references and symlinks escaping the document directory', { skip: process.platform === 'win32' }, async () => { + await mkdir(join(root, 'page')); + await writeFile(join(root, 'secret.txt'), 'secret'); + await symlink(join(root, 'secret.txt'), join(root, 'page', 'linked.txt')); + const viewer = await start('page/index.html', ''); + assert.equal((await get(viewer, asset(viewer, 'linked.txt'))).status, 404); + assert.notEqual((await get(viewer, asset(viewer, '../secret.txt'))).status, 200); +}); + +test('supports byte ranges and HEAD for native PDF/image presentation', async () => { + const viewer = await start('README.pdf', '%PDF-1.7 example bytes'); + assert.equal((await get(viewer)).headers['content-type'], 'application/pdf'); + const range = await get(viewer, viewer.path, { Range: 'bytes=0-3' }); + assert.equal(range.status, 206); + assert.equal(range.body, '%PDF'); + assert.equal((await get(viewer, viewer.path, { Range: 'bytes=-5' })).body, 'bytes'); + assert.equal((await get(viewer, viewer.path, { Range: 'bytes=999-1000' })).status, 416); + assert.equal((await get(viewer, viewer.path, { Range: 'bytes=0-1,4-6' })).status, 416); + assert.equal((await get(viewer, viewer.path, {}, 'HEAD')).body, ''); +}); + +test('fails unsupported formats and oversized text before starting a viewer', async () => { + await writeFile(join(root, 'unknown.bin'), 'binary'); + await assert.rejects(startFileViewer(join(root, 'unknown.bin')), /unsupported/); + await writeFile(join(root, 'large.txt'), Buffer.alloc(8 * 1024 * 1024 + 1)); + await assert.rejects(startFileViewer(join(root, 'large.txt')), /8 MiB/); +}); + +test('streams oversized HTML and referenced CSS without scanning their dependencies', async () => { + const large = ' '.repeat(8 * 1024 * 1024 + 1); + await writeFile(join(root, 'hidden.svg'), ''); + const html = await start('large.html', `${large}`); + const htmlResponse = await get(html); + assert.equal(htmlResponse.status, 200); + assert.equal(htmlResponse.body.length, large.length + ''.length); + assert.equal((await get(html, asset(html, 'hidden.svg'))).status, 404); + + await writeFile(join(root, 'large.css'), `body { background: url(hidden.svg) }${large}`); + const withCss = await start('index.html', ''); + const cssResponse = await get(withCss, asset(withCss, 'large.css')); + assert.equal(cssResponse.status, 200); + assert.ok(cssResponse.body.length > 8 * 1024 * 1024); + assert.equal((await get(withCss, asset(withCss, 'hidden.svg'))).status, 404); + await assert.rejects(startFileViewer(join(root, 'large.css')), /8 MiB/); // a direct CSS text preview stays capped +}); + +test('previews CSS source without granting dependencies but still bounds HTML-referenced CSS', async () => { + const names = Array.from({ length: 256 }, (_, i) => `image${i}.svg`); + await Promise.all(names.map(name => writeFile(join(root, name), ''))); + const css = names.map(name => `body { background: url("${name}") }`).join('\n'); + const viewer = await start('source.css', css); + const response = await get(viewer); + assert.equal(response.status, 200); + assert.match(response.body, /url\("image255.svg"\)/); + const prefix = viewer.path.slice(0, -'view'.length); + assert.equal((await get(viewer, `${prefix}file/image0.svg`)).status, 404); + assert.equal((await get(viewer, `${prefix}file/source.css`)).status, 200); + + // The same CSS is an active stylesheet when reached through HTML. Its + // dependencies still count against that viewer's grant and abort the open. + await writeFile(join(root, 'index.html'), ''); + await assert.rejects(startFileViewer(join(root, 'index.html')), /256 referenced files/); +}); + +test('bounds the asset graph and keeps a grant on the opened file after path replacement', async () => { + const viewer = await start('original.txt', 'original content'); + await rm(join(root, 'original.txt')); + await writeFile(join(root, 'original.txt'), 'replacement content'); + assert.match((await get(viewer)).body, /original content/); + const names = Array.from({ length: 256 }, (_, i) => `style${i}.css`); + await Promise.all(names.map(name => writeFile(join(root, name), ''))); + const html = join(root, 'many.html'); + await writeFile(html, names.map(name => ``).join('')); + await assert.rejects(startFileViewer(html), /256 referenced files/); +}); + +test('the bundled private entry announces its port and path, then exits on termination', { timeout: 10_000 }, async () => { + const file = join(root, 'cli.txt'); + await writeFile(file, 'cli preview'); + const child = spawn(process.execPath, [fileURLToPath(new URL('../dist/dor.js', import.meta.url)), '__view-file', file], { stdio: ['ignore', 'pipe', 'pipe'] }); + try { + let output = ''; + const announce = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', code => reject(new Error(`viewer exited early: ${code}`))); + child.stdout.on('data', chunk => { + output += chunk; + const match = /\x1b\]367;serve;(\{[^\x07]*\})\x07/.exec(output); + if (match) resolve(JSON.parse(match[1])); + }); + }); + assert.equal((await get(announce)).status, 200); + const exited = once(child, 'exit'); + child.kill('SIGTERM'); + await exited; + await assert.rejects(get(announce)); + } finally { child.kill('SIGKILL'); } +}); diff --git a/dor/test/snapshots/help/open.md b/dor/test/snapshots/help/open.md index c6db5e99c..a5a1881a0 100644 --- a/dor/test/snapshots/help/open.md +++ b/dor/test/snapshots/help/open.md @@ -9,13 +9,13 @@ USAGE Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. -The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. +The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, PDF, and media files. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. -The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The built-in HTML viewer serves statically referenced relative assets within the document directory tree; root-relative and external resources are unavailable. Text previews are capped at 8 MiB. The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. -Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output. +Placement follows dor tool: typed alone at a prompt in a visible, integrated plain terminal in the requested directory, opening takes over that pane, preserving its terminal and scrollback. Agent/script invocations, compound lines, a pane with a helper, --minimize, --surface, or --cwd elsewhere split without taking focus. The pane remains a Tool after its command exits: opening a different file from that prompt splits unless keyed reuse finds an existing Tool; the same keyed file reruns in place. A matching Tool elsewhere is reused. The command prints the Surface handle; --json prints structured output. FLAGS [--json] Print JSON output. @@ -24,7 +24,7 @@ FLAGS [--surface] Surface to split when creating. [--workspace] Workspace to act in, instead of the caller's. [--cwd] Directory for resolving the file. - [--tool] Use this user-global Tool. + [--tool] Use a user Tool or builtin:file. -h --help Print help information and exit -- All subsequent inputs should be interpreted as arguments diff --git a/dor/test/snapshots/help/tool.md b/dor/test/snapshots/help/tool.md index e90c62cb2..e8c99656d 100644 --- a/dor/test/snapshots/help/tool.md +++ b/dor/test/snapshots/help/tool.md @@ -20,7 +20,7 @@ A project dormouse.yml is repo-controlled and its entries execute, so it is iner Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read. -Where the tool lands: typed alone at a prompt in a visible, integrated plain terminal whose directory is the tool's, it takes over that pane — no split, same surface, same scrollback — and reports "takeover". Anything else — an agent's invocation, a compound line, a pane with a helper, --minimize, --surface, --cwd elsewhere — splits without taking focus and prints the new surface's handle. The handle prints before the command starts, since dor has to exit before its own shell is free to run it. +Where the tool lands: typed alone at a prompt in a visible, integrated plain terminal whose directory is the tool's, it takes over that pane — no split, same surface, same scrollback — and reports "takeover". Anything else — an agent's invocation, a compound line, a pane with a helper, --minimize, --surface, --cwd elsewhere — splits without taking focus and prints the new surface's handle. The pane remains a Tool after its command exits: another invocation from that prompt splits unless it matches a keyed Tool; the same keyed Tool reruns in place. The handle prints before the command starts, since dor has to exit before its own shell is free to run it. --cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from. diff --git a/dor/tsconfig.json b/dor/tsconfig.json index 15395d7eb..5284c140e 100644 --- a/dor/tsconfig.json +++ b/dor/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], + "types": ["node"], "module": "ES2022", "moduleResolution": "bundler", "outDir": "dist", diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index a2adc36da..f8c336dfd 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -21,7 +21,6 @@ import type { PersistedSession } from '../lib/session-types'; import * as terminalRegistry from '../lib/terminal-registry'; import { UNNAMED_PANEL_TITLE } from '../lib/terminal-registry'; import { pendingShellOpts } from '../lib/terminal-store'; -import { setToolsEnabled } from '../lib/feature-flags'; import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; import { addPlainNote, beginClosing, clearAllNotepads, getNotes, setOpenNotepadId } from '../lib/notepad/notepad-store'; import type { NotepadArchiveV1 } from '../lib/notepad/types'; @@ -1478,10 +1477,14 @@ describe('Wall on the Lath engine', () => { } }); - it('dispatches open through the user host and splits even when the caller could be taken over', async () => { - setToolsEnabled(true); + it.each([ + { line: 'dor open a.md', explicitSurface: false, status: 'takeover' }, + { line: 'claude', explicitSurface: false, status: 'created' }, + { line: 'dor open --surface surface:self a.md', explicitSurface: true, status: 'created' }, + ])('dispatches open through the user host with $status placement for $line', async ({ line, explicitSurface, status }) => { const controller = new AbortController(); let toolId: string | undefined; + const typed: string[] = []; vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); const toolControl = vi.fn(async () => ({ status: 'ok' as const, scope: 'user' as const, projectRoot: '/config', path: '/config/dormouse.yml', name: 'viewer', run: ['view', '/repo/a.md'], @@ -1490,21 +1493,32 @@ describe('Wall on the Lath engine', () => { try { await act(async () => root.render()); await flush(); + act(() => fake.spawnPty('pane-a')); + fake.setInputHandler('pane-a', data => typed.push(data)); terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); terminalRegistry.applyTerminalSemanticEvents('pane-a', [ - { type: 'commandLine', commandLine: 'dor open a.md' }, + { type: 'commandLine', commandLine: line }, { type: 'commandStart', source: 'osc633_boundaries' }, ]); const respond = vi.fn(); await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { file: 'a.md', cwd: '/repo' }, signal: controller.signal, respond, + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { file: 'a.md', cwd: '/repo', ...(explicitSurface ? { surface: 'pane-a' } : {}) }, signal: controller.signal, respond, } }))); await waitUntil(() => respond.mock.calls.length > 0); expect(toolControl).toHaveBeenCalledWith({ op: 'open', target: 'a.md', cwd: '/repo', tool: undefined }); - expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'created' }) })); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status }) })); toolId = respond.mock.calls[0][0].result.surfaceId; - expect(toolId).not.toBe('pane-a'); - expect(leafCount()).toBe(2); + expect(typed).toEqual([]); + if (status === 'takeover') { + expect(toolId).toBe('pane-a'); + expect(leafCount()).toBe(1); + act(() => promptBack('pane-a')); + await waitUntil(() => typed.length > 0); + expect(typed).toEqual(['view /repo/a.md\r']); + } else { + expect(toolId).not.toBe('pane-a'); + expect(leafCount()).toBe(2); + } // This fixture stubs TerminalPane, so report the staged command's startup // explicitly before disposing the Wall and its shared launch queue wait. act(() => { @@ -1514,17 +1528,64 @@ describe('Wall on the Lath engine', () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); } finally { await act(async () => controller.abort()); + fake.clearInputHandler('pane-a'); if (toolId) { pendingShellOpts.delete(toolId); act(() => terminalRegistry.removeTerminalPaneState(toolId!)); } act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); + } + }); + + it('reuses a builtin viewer by canonical target without matching same-named project or user Tools', async () => { + vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); + const target = '/repo/docs/readme.md'; + const command = `dor __view-file ${target}`; + const toolControl = vi.fn(async (request: { op: string; global?: boolean }) => ({ + status: 'ok' as const, + scope: request.op === 'open' ? 'builtin' as const : request.global ? 'user' as const : undefined, + projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'file', + run: ['dor', '__view-file', target], key: [target], + render: 'iframe' as const, port: 'announced' as const, warnings: [], + })); + Object.assign(fake, { toolControl }); + const ids: string[] = []; + const requestTool = async (params: Record) => { + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { cwd: '/repo', ...params }, respond, + } }))); + await waitUntil(() => respond.mock.calls.length > 0); + const response = respond.mock.calls[0][0]; + expect(response.ok).toBe(true); + return response.result as { status: string; surfaceId: string }; + }; + try { + await act(async () => root.render()); + await flush(); + for (const params of [{ name: 'file' }, { name: 'file', global: true }, { file: 'docs/readme.md' }]) { + const result = await requestTool(params); + expect(result.status).toBe('created'); + ids.push(result.surfaceId); + act(() => { + terminalRegistry.seedTerminalManualCwd(result.surfaceId, '/repo'); + reportRunning(result.surfaceId, command); + }); + } + expect(new Set(ids).size).toBe(3); + expect(leafCount()).toBe(4); + + // Host resolution gives both spellings the same canonical document key. + const reused = await requestTool({ file: './docs/../docs/readme.md' }); + expect(toolControl).toHaveBeenLastCalledWith({ op: 'open', target: './docs/../docs/readme.md', cwd: '/repo', tool: undefined }); + expect(reused).toMatchObject({ status: 'existing', surfaceId: ids[2] }); + expect(leafCount()).toBe(4); + } finally { + act(() => ids.forEach(id => terminalRegistry.removeTerminalPaneState(id))); } }); it('retries failed post-grant lookup without recording permission again', async () => { - setToolsEnabled(true); let calls = 0; const toolControl = vi.fn(async (request: { op: string }) => { if (request.op === 'trust') return { status: 'trust-recorded' }; @@ -1569,12 +1630,10 @@ describe('Wall on the Lath engine', () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); } finally { if (id) { pendingShellOpts.delete(id); act(() => terminalRegistry.removeTerminalPaneState(id!)); } - setToolsEnabled(false); } }); it.each(['error', 'missing', 'throws'] as const)('keeps approval choices and stops before lookup when recording trust %s', async failure => { - setToolsEnabled(true); const toolControl = vi.fn(async (request: { op: string }) => { if (request.op === 'trust') { if (failure === 'throws') throw new Error('Permission storage is unavailable'); @@ -1583,29 +1642,26 @@ describe('Wall on the Lath engine', () => { return { status: 'untrusted', projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'viewer', run: 'view', upstreamUrl: 'https://example.com/repo.git' }; }); Object.assign(fake, { toolControl }); - try { - await act(async () => root.render()); - await flush(); - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, params: { name: 'viewer', cwd: '/repo' }, respond, - } }))); - const id = respond.mock.calls[0][0].result.surfaceId; - const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; - await act(async () => allow.click()); - await flush(); - expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); - expect(container.querySelector('[role="alert"]')?.textContent).toContain(failure === 'missing' ? 'could not be saved' : 'Permission storage is unavailable'); - expect(container.textContent).toContain('Always allow for upstream'); - expect(container.textContent).toContain('Always allow for folder'); - expect(container.textContent).not.toContain('Permission is saved'); - expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); - expect(pendingShellOpts.has(id)).toBe(false); - } finally { setToolsEnabled(false); } + await act(async () => root.render()); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'viewer', cwd: '/repo' }, respond, + } }))); + const id = respond.mock.calls[0][0].result.surfaceId; + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + await flush(); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); + expect(container.querySelector('[role="alert"]')?.textContent).toContain(failure === 'missing' ? 'could not be saved' : 'Permission storage is unavailable'); + expect(container.textContent).toContain('Always allow for upstream'); + expect(container.textContent).toContain('Always allow for folder'); + expect(container.textContent).not.toContain('Permission is saved'); + expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); + expect(pendingShellOpts.has(id)).toBe(false); }); it('keeps pending file inputs distinct and quotes argv after approval', async () => { - setToolsEnabled(true); let trusted = false; const toolControl = vi.fn(async (request: { op: string; args?: string[] }) => { if (request.op === 'trust') { trusted = true; return { status: 'trust-recorded' as const }; } @@ -1614,40 +1670,37 @@ describe('Wall on the Lath engine', () => { : { ...common, status: 'untrusted' as const, upstreamUrl: null }; }); Object.assign(fake, { toolControl }); - try { - await act(async () => root.render()); - await flush(); - const ids: string[] = []; - for (const [cwd, target] of [['/repo', 'a b;$(bad).md'], ['/repo', 'second.md'], ['/repo', 'a b;$(bad).md'], ['/repo/subdir', 'a b;$(bad).md']]) { - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, params: { name: 'viewer', cwd, args: [target] }, respond, - } }))); - ids.push(respond.mock.calls[0][0].result.surfaceId); - } - expect(ids[0]).not.toBe(ids[1]); - expect(ids[2]).toBe(ids[0]); - expect(ids[3]).not.toBe(ids[0]); - const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; - await act(async () => allow.click()); - await flush(); - expect(toolControl).toHaveBeenLastCalledWith({ op: 'lookup', name: 'viewer', cwd: '/repo', args: ['a b;$(bad).md'] }); - expect(pendingShellOpts.get(ids[0])?.command).toBe("view 'a b;$(bad).md'"); - act(() => { - terminalRegistry.seedTerminalManualCwd(ids[0], '/repo'); - reportRunning(ids[0], "view 'a b;$(bad).md'"); - }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); - await act(async () => window.dispatchEvent(new Event('pagehide'))); - await flush(); - expect((fake.getState() as PersistedSession).panes.find(pane => pane.id === ids[0])?.tool?.argv).toEqual(['view', 'a b;$(bad).md']); - ids.forEach(id => pendingShellOpts.delete(id)); - act(() => ids.forEach(id => terminalRegistry.removeTerminalPaneState(id))); - } finally { setToolsEnabled(false); } + await act(async () => root.render()); + await flush(); + const ids: string[] = []; + for (const [cwd, target] of [['/repo', 'a b;$(bad).md'], ['/repo', 'second.md'], ['/repo', 'a b;$(bad).md'], ['/repo/subdir', 'a b;$(bad).md']]) { + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'viewer', cwd, args: [target] }, respond, + } }))); + ids.push(respond.mock.calls[0][0].result.surfaceId); + } + expect(ids[0]).not.toBe(ids[1]); + expect(ids[2]).toBe(ids[0]); + expect(ids[3]).not.toBe(ids[0]); + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + await flush(); + expect(toolControl).toHaveBeenLastCalledWith({ op: 'lookup', name: 'viewer', cwd: '/repo', args: ['a b;$(bad).md'] }); + expect(pendingShellOpts.get(ids[0])?.command).toBe("view 'a b;$(bad).md'"); + act(() => { + terminalRegistry.seedTerminalManualCwd(ids[0], '/repo'); + reportRunning(ids[0], "view 'a b;$(bad).md'"); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); + await act(async () => window.dispatchEvent(new Event('pagehide'))); + await flush(); + expect((fake.getState() as PersistedSession).panes.find(pane => pane.id === ids[0])?.tool?.argv).toEqual(['view', 'a b;$(bad).md']); + ids.forEach(id => pendingShellOpts.delete(id)); + act(() => ids.forEach(id => terminalRegistry.removeTerminalPaneState(id))); }); it.each([false, true])('serializes a newly created Tool until startup, including completion before waiting (%s)', async finishesBeforeWait => { - setToolsEnabled(true); const controller = new AbortController(); const command = 'pnpm storybook'; let id: string | undefined; @@ -1699,7 +1752,6 @@ describe('Wall on the Lath engine', () => { pendingShellOpts.delete(id); act(() => terminalRegistry.removeTerminalPaneState(id!)); } - setToolsEnabled(false); } }); @@ -1709,7 +1761,6 @@ describe('Wall on the Lath engine', () => { { fresh: false, archiveFails: false, idle: true }, { fresh: false, archiveFails: true, idle: true }, ])('serializes approval key reuse and preserves fresh/notes: %j', async ({ fresh, archiveFails, idle }) => { - setToolsEnabled(true); const ids: string[] = []; const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; const idleState = createTerminalPaneState({ cwd }); @@ -1787,12 +1838,10 @@ describe('Wall on the Lath engine', () => { } finally { vi.useRealTimers(); ids.forEach(id => pendingShellOpts.delete(id)); - setToolsEnabled(false); } }); it('moves the selection ring from a terminal to a pending Tool approval', async () => { - setToolsEnabled(true); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const left = this.dataset.lathLeaf === 'pane-a' ? 100 : 500; return { x: left, y: 40, left, top: 40, right: left + 300, bottom: 240, width: 300, height: 200, toJSON() {} }; @@ -1802,102 +1851,87 @@ describe('Wall on the Lath engine', () => { name: 'storybook', run: 'pnpm storybook', upstreamUrl: null, })); const ring = () => container.querySelector('[data-ring="outline"]')?.closest('svg')?.parentElement; - try { - await act(async () => { root.render(); }); - await flush(); - expect(ring()?.style.left).toBe('96px'); - await act(async () => { - window.dispatchEvent(new CustomEvent('dormouse:control-request', { - detail: { - method: SURFACE_CONTROL_METHODS.tool, - params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, - respond: vi.fn(), - }, - })); - }); - await flush(); - expect(container.textContent).toContain('Always allow for folder'); - const approvalHeader = container.querySelector('[data-lath-leaf]:not([data-lath-leaf="pane-a"]) .lath-leaf-header > div'); - expect(approvalHeader).not.toBeNull(); - await act(async () => { approvalHeader!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); - await flush(); - expect(ring()?.style.left).toBe('496px'); - } finally { - setToolsEnabled(false); - } + await act(async () => { root.render(); }); + await flush(); + expect(ring()?.style.left).toBe('96px'); + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: vi.fn(), + }, + })); + }); + await flush(); + expect(container.textContent).toContain('Always allow for folder'); + const approvalHeader = container.querySelector('[data-lath-leaf]:not([data-lath-leaf="pane-a"]) .lath-leaf-header > div'); + expect(approvalHeader).not.toBeNull(); + await act(async () => { approvalHeader!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + await flush(); + expect(ring()?.style.left).toBe('496px'); }); it.each(['', ' \t\n'])('shows a useful fallback for a blank grant failure (%j)', async message => { - setToolsEnabled(true); const toolControl = vi.fn(async (request: { op: string }) => request.op === 'trust' ? { status: 'error', message } : { status: 'untrusted', projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }); Object.assign(fake, { toolControl }); - try { - await act(async () => root.render()); - await flush(); - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, - } }))); - const id = respond.mock.calls[0][0].result.surfaceId; - const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; - await act(async () => allow.click()); - expect(container.querySelector('[role="alert"]')?.textContent).toBe('The Tool permission could not be saved. Try allowing it again.'); - expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); - expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); - } finally { - setToolsEnabled(false); - } + await act(async () => root.render()); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, + } }))); + const id = respond.mock.calls[0][0].result.surfaceId; + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + expect(container.querySelector('[role="alert"]')?.textContent).toBe('The Tool permission could not be saved. Try allowing it again.'); + expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(1); }); it.each(['read error', 'unknown tool'])('retains a failed post-grant lookup with retry and quiet stale completion (%s)', async failure => { - setToolsEnabled(true); const untrusted = { status: 'untrusted', projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }; const failed = failure === 'read error' - ? { status: 'error', message: 'configuration temporarily unreadable' } - : { status: 'unknown-tool', projectRoot: '/repo', path: '/repo/dormouse.yml', names: [] }; + ? { status: 'error', message: 'configuration temporarily unreadable' } + : { status: 'unknown-tool', projectRoot: '/repo', path: '/repo/dormouse.yml', names: [] }; const toolControl = vi.fn(async (request: { op: string }) => request.op === 'trust' - ? { status: 'trust-recorded' } : toolControl.mock.calls.length === 1 ? untrusted : failed); + ? { status: 'trust-recorded' } : toolControl.mock.calls.length === 1 ? untrusted : failed); Object.assign(fake, { toolControl }); - try { - await act(async () => root.render()); - await flush(); - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, - } }))); - const id = respond.mock.calls[0][0].result.surfaceId; - const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; - await act(async () => allow.click()); - await flush(); - expect(container.querySelector('[role="alert"]')?.textContent).toBe(failure === 'read error' - ? 'configuration temporarily unreadable' : 'The Tool is no longer available. Check its configuration and try again.'); - expect(container.querySelector(`[data-lath-leaf="${id}"]`)).not.toBeNull(); - expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); - expect(pendingShellOpts.has(id)).toBe(false); - - const retry = Promise.withResolvers(); - toolControl.mockImplementation(async request => request.op === 'trust' ? { status: 'trust-recorded' } : retry.promise); - await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Retry')!.click()); - expect(container.querySelector('[role="alert"]')).toBeNull(); - expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(3); - expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(1); - const decline = [...container.querySelectorAll('button')].find(button => button.textContent === 'Close')!; - await act(async () => decline.click()); - await flush(); - await act(async () => retry.resolve(failed)); - await flush(); - expect(container.querySelector(`[data-lath-leaf="${id}"]`)).toBeNull(); - expect(container.querySelector('[role="alert"]')).toBeNull(); - expect(pendingShellOpts.has(id)).toBe(false); - } finally { - setToolsEnabled(false); - } + await act(async () => root.render()); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, params: { name: 'storybook', cwd: '/repo' }, respond, + } }))); + const id = respond.mock.calls[0][0].result.surfaceId; + const allow = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Always allow for folder'))!; + await act(async () => allow.click()); + await flush(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe(failure === 'read error' + ? 'configuration temporarily unreadable' : 'The Tool is no longer available. Check its configuration and try again.'); + expect(container.querySelector(`[data-lath-leaf="${id}"]`)).not.toBeNull(); + expect(container.querySelector(`[data-session-id="${id}"]`)).toBeNull(); + expect(pendingShellOpts.has(id)).toBe(false); + + const retry = Promise.withResolvers(); + toolControl.mockImplementation(async request => request.op === 'trust' ? { status: 'trust-recorded' } : retry.promise); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Retry')!.click()); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'lookup')).toHaveLength(3); + expect(toolControl.mock.calls.filter(([request]) => request.op === 'trust')).toHaveLength(1); + const decline = [...container.querySelectorAll('button')].find(button => button.textContent === 'Close')!; + await act(async () => decline.click()); + await flush(); + await act(async () => retry.resolve(failed)); + await flush(); + expect(container.querySelector(`[data-lath-leaf="${id}"]`)).toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(pendingShellOpts.has(id)).toBe(false); }); it.each([true, false])('keeps a tool deferred until trust succeeds (%s), lookup and shell staging finish', async grantSucceeds => { - setToolsEnabled(true); let toolId: string | undefined; vi.spyOn(terminalRegistry, 'getTerminalPaneState').mockImplementation(id => { const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; @@ -2027,12 +2061,10 @@ describe('Wall on the Lath engine', () => { } finally { vi.useRealTimers(); if (toolId) pendingShellOpts.delete(toolId); - setToolsEnabled(false); } }); it.each([false, true])('starts an approved tool before deferred minimize and never resurrects approval after a PTY creation error (%s)', async failAfterSpawn => { - setToolsEnabled(true); let toolId: string | undefined; vi.spyOn(terminalRegistry, 'getTerminalPaneState').mockImplementation(id => { const cwd = { path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } as const; @@ -2108,12 +2140,10 @@ describe('Wall on the Lath engine', () => { } finally { if (toolId && fake.hasPty(toolId)) act(() => fake.killPty(toolId)); getTerminalSpy.mockRestore(); - setToolsEnabled(false); } }); it('reveals a pending approval created against a minimized reference', async () => { - setToolsEnabled(true); (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => ({ status: 'untrusted' as const, projectRoot: '/repo', @@ -2123,49 +2153,44 @@ describe('Wall on the Lath engine', () => { upstreamUrl: null, })); - try { - await act(async () => { - root.render( - , - ); - }); - await flush(); + await act(async () => { + root.render( + , + ); + }); + await flush(); - let response: { ok: boolean; result?: { surfaceId: string; minimized: boolean } } | undefined; - await act(async () => { - window.dispatchEvent(new CustomEvent('dormouse:control-request', { - detail: { - method: SURFACE_CONTROL_METHODS.tool, - params: { - name: 'storybook', - cwd: '/repo', - surface: 'surface:2', - minimized: false, - fresh: false, - }, - respond: (result: typeof response) => { response = result; }, + let response: { ok: boolean; result?: { surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { + name: 'storybook', + cwd: '/repo', + surface: 'surface:2', + minimized: false, + fresh: false, }, - })); - }); - await flush(); + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); - expect(response).toMatchObject({ ok: true, result: { minimized: false } }); - const toolId = response!.result!.surfaceId; - expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); - expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)?.hasAttribute('data-lath-parked')).toBe(false); - expect(container.textContent).toContain('Always allow for folder'); - } finally { - setToolsEnabled(false); - } + expect(response).toMatchObject({ ok: true, result: { minimized: false } }); + const toolId = response!.result!.surfaceId; + expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)?.hasAttribute('data-lath-parked')).toBe(false); + expect(container.textContent).toContain('Always allow for folder'); }); it('reports a reused pending tool as visible after reattaching it', async () => { - setToolsEnabled(true); const toolId = 'pending-tool-door'; (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => ({ status: 'untrusted' as const, @@ -2176,65 +2201,60 @@ describe('Wall on the Lath engine', () => { upstreamUrl: null, })); - try { - await act(async () => { - root.render( - { + root.render( + , - ); - }); - await flush(); - expect(container.querySelector(`[data-door-id="${toolId}"]`)).not.toBeNull(); + }, + }]} + initialMode="command" + showBaseboard + />, + ); + }); + await flush(); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).not.toBeNull(); - let response: { ok: boolean; result?: { status: string; surfaceId: string; minimized: boolean } } | undefined; - await act(async () => { - window.dispatchEvent(new CustomEvent('dormouse:control-request', { - detail: { - method: SURFACE_CONTROL_METHODS.tool, - params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, - respond: (result: typeof response) => { response = result; }, - }, - })); - }); - await flush(); + let response: { ok: boolean; result?: { status: string; surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); - expect(response).toMatchObject({ - ok: true, - result: { status: 'pending', surfaceId: toolId, minimized: false }, - }); - expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); - expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); - } finally { - setToolsEnabled(false); - } + expect(response).toMatchObject({ + ok: true, + result: { status: 'pending', surfaceId: toolId, minimized: false }, + }); + expect(container.querySelector(`[data-door-id="${toolId}"]`)).toBeNull(); + expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); }); it.each([true, false])('accepts a newly completed keyed Tool restart without mistaking old or unrelated completions (%s)', async completesDuringWrite => { - setToolsEnabled(true); const id = 'short-tool'; const command = 'pnpm storybook'; const controller = new AbortController(); @@ -2283,12 +2303,10 @@ describe('Wall on the Lath engine', () => { await act(async () => { controller.abort(); await new Promise(resolve => setTimeout(resolve, 125)); }); fake.clearInputHandler(id); act(() => terminalRegistry.removeTerminalPaneState(id)); - setToolsEnabled(false); } }); it('reports a reused minimized tool as visible after reattaching it', async () => { - setToolsEnabled(true); const toolId = 'tool-door'; terminalRegistry.applyTerminalSemanticEvents(toolId, [ { type: 'commandLine', commandLine: 'pnpm storybook' }, @@ -2344,7 +2362,6 @@ describe('Wall on the Lath engine', () => { expect(container.querySelector(`[data-lath-leaf="${toolId}"]`)).not.toBeNull(); } finally { act(() => terminalRegistry.removeTerminalPaneState(toolId)); - setToolsEnabled(false); } }); @@ -2353,7 +2370,6 @@ describe('Wall on the Lath engine', () => { // point — `dor` is the pane's foreground process when the host answers, so the // command may only be typed once its own shell is back at a prompt. it.each(['cancelled', 'helper opened', 'cwd changed', 'closing'] as const)('abandons takeover if the caller becomes %s while returning to its prompt', async (change) => { - setToolsEnabled(true); const controller = new AbortController(); const typed: string[] = []; let releaseClosing: (() => void) | undefined; @@ -2386,32 +2402,27 @@ describe('Wall on the Lath engine', () => { } finally { controller.abort(); releaseClosing?.(); fake.clearInputHandler('pane-a'); act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); } }); it('rejects anonymous Tool argv containing terminal editing controls before launching', async () => { - setToolsEnabled(true); - try { - await act(async () => root.render()); - await flush(); - const write = vi.spyOn(fake, 'writePty'); - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', - params: { command: ['view', '/tmp/\x15printf unwanted\n#'], cwd: '/repo' }, respond, - } }))); - expect(respond).toHaveBeenCalledWith({ ok: false, error: 'tool arguments cannot contain terminal control characters' }); - expect(write).not.toHaveBeenCalled(); - expect(leafCount()).toBe(1); - } finally { setToolsEnabled(false); } + await act(async () => root.render()); + await flush(); + const write = vi.spyOn(fake, 'writePty'); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', + params: { command: ['view', '/tmp/\x15printf unwanted\n#'], cwd: '/repo' }, respond, + } }))); + expect(respond).toHaveBeenCalledWith({ ok: false, error: 'tool arguments cannot contain terminal control characters' }); + expect(write).not.toHaveBeenCalled(); + expect(leafCount()).toBe(1); }); it.each([ { kind: 'powershell' as const, defaultShell: '/bin/bash', command: "& 'program path' 'it''s.txt'" }, { kind: 'posix' as const, defaultShell: 'pwsh.exe', command: "'program path' 'it'\\''s.txt'" }, ])('quotes takeover and keyed rerun for the existing $kind Session after changing defaults', async ({ kind, defaultShell, command }) => { - setToolsEnabled(true); const controller = new AbortController(); const typed: string[] = []; vi.spyOn(terminalRegistry, 'getTerminalShellKind').mockImplementation(id => id === 'pane-a' ? kind : null); @@ -2452,12 +2463,10 @@ describe('Wall on the Lath engine', () => { await act(async () => { controller.abort(); await new Promise(resolve => setTimeout(resolve, 125)); }); fake.clearInputHandler('pane-a'); act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); } }); it('holds the takeover queue through unrelated completions until the typed Tool completes', async () => { - setToolsEnabled(true); const id = 'pane-a'; const command = 'pnpm storybook'; const controller = new AbortController(); @@ -2513,12 +2522,10 @@ describe('Wall on the Lath engine', () => { await act(async () => { controller.abort(); await new Promise(resolve => setTimeout(resolve, 125)); }); fake.clearInputHandler(id); act(() => terminalRegistry.removeTerminalPaneState(id)); - setToolsEnabled(false); } }); it('takes over the calling pane when `dor tool` is typed alone at a prompt', async () => { - setToolsEnabled(true); const typed: string[] = []; (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => okToolLookup(['/repo'])); @@ -2637,13 +2644,11 @@ describe('Wall on the Lath engine', () => { } finally { fake.clearInputHandler('pane-a'); act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); } }); it.each(['agent', 'helper'] as const)('splits instead of taking over a caller with an existing %s', async (reason) => { if (reason === 'helper') vi.spyOn(helpers, 'getHelper').mockImplementation(id => id === 'pane-a' ? { id: 'helper-a', parentId: 'pane-a', command: '', status: 'off' } : undefined); - setToolsEnabled(true); const typed: string[] = []; const controller = new AbortController(); (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => okToolLookup(null)); @@ -2699,12 +2704,10 @@ describe('Wall on the Lath engine', () => { } fake.clearInputHandler('pane-a'); act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); } }); it('rejects a non-integrated shell before offering tool approval', async () => { - setToolsEnabled(true); terminalRegistry.setDefaultShellOpts({ shell: 'C:\\Windows\\System32\\cmd.exe' }); const toolControl = vi.fn(async () => ({ status: 'untrusted' as const, @@ -2740,7 +2743,6 @@ describe('Wall on the Lath engine', () => { expect(leafCount()).toBe(1); } finally { terminalRegistry.setDefaultShellOpts(null); - setToolsEnabled(false); } }); diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index 12f3b12b1..9ba9d284c 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -9,7 +9,6 @@ import { StrictMode, act } from 'react'; import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; -import { setToolsEnabled } from '../lib/feature-flags'; import { WorkspaceWindow } from './WorkspaceWindow'; import { closeWorkspaceWithSurfaces } from './wall/workspace-lifecycle'; import * as terminalRegistry from '../lib/terminal-registry'; @@ -442,7 +441,6 @@ it.each([ ['transferring', false], ['closed', false], ] as const)('respects Workspace lifecycle after takeover acceptance: %s', async (change, launches) => { - setToolsEnabled(true); const controller = new AbortController(); const typed: string[] = []; const first = getActiveWorkspaceId(); @@ -489,41 +487,37 @@ it.each([ setWorkspaceTransferPending(first, false); fake.clearInputHandler('pane-a'); act(() => terminalRegistry.removeTerminalPaneState('pane-a')); - setToolsEnabled(false); } }); it('routes Tools to the requested Workspace and never launches after lookup races closure', async () => { - setToolsEnabled(true); const lookup = { status: 'untrusted' as const, projectRoot: '/repo', path: '/repo/dormouse.yml', name: 'storybook', run: 'pnpm storybook', upstreamUrl: null }; const gate = Promise.withResolvers(); const toolControl = vi.fn().mockResolvedValueOnce(lookup).mockImplementationOnce(() => gate.promise); Object.assign(fake, { toolControl }); - try { - await render(); - const first = getActiveWorkspaceId(); - await act(async () => { createWorkspace({ id: 'ws-2' }); }); - await flush(); - const respond = vi.fn(); - await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { - requestId: 'tool-route', surfaceId: 'pane-a', method: SURFACE_CONTROL_METHODS.tool, - params: { workspace: 'workspace:2', name: 'storybook', cwd: '/repo' }, respond, - } }))); - await flush(); - expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'pending' }) })); - expect(leafIdsIn(first)).toEqual(['pane-a']); - expect(leafIdsIn('ws-2')).toHaveLength(2); - expect(getActiveWorkspaceId()).toBe('ws-2'); - - const handle = getWallHandle('ws-2')!; - const late = vi.fn(); - act(() => handle.handleDorControl({ requestId: 'late-tool', method: SURFACE_CONTROL_METHODS.tool, - params: { name: 'storybook', cwd: '/repo' }, respond: late })); - await flush(); - await act(async () => { await handle.closeAll('discard'); }); - await act(async () => gate.resolve(lookup)); - await flush(); - expect(late).toHaveBeenCalledWith({ ok: false, error: 'this workspace is closing' }); - expect(handle.surfaceIds()).toEqual([]); - } finally { setToolsEnabled(false); } + await render(); + const first = getActiveWorkspaceId(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + requestId: 'tool-route', surfaceId: 'pane-a', method: SURFACE_CONTROL_METHODS.tool, + params: { workspace: 'workspace:2', name: 'storybook', cwd: '/repo' }, respond, + } }))); + await flush(); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'pending' }) })); + expect(leafIdsIn(first)).toEqual(['pane-a']); + expect(leafIdsIn('ws-2')).toHaveLength(2); + expect(getActiveWorkspaceId()).toBe('ws-2'); + + const handle = getWallHandle('ws-2')!; + const late = vi.fn(); + act(() => handle.handleDorControl({ requestId: 'late-tool', method: SURFACE_CONTROL_METHODS.tool, + params: { name: 'storybook', cwd: '/repo' }, respond: late })); + await flush(); + await act(async () => { await handle.closeAll('discard'); }); + await act(async () => gate.resolve(lookup)); + await flush(); + expect(late).toHaveBeenCalledWith({ ok: false, error: 'this workspace is closing' }); + expect(handle.surfaceIds()).toEqual([]); }); diff --git a/lib/src/components/wall/ToolPanel.test.tsx b/lib/src/components/wall/ToolPanel.test.tsx index c0f5f724f..7db6140a4 100644 --- a/lib/src/components/wall/ToolPanel.test.tsx +++ b/lib/src/components/wall/ToolPanel.test.tsx @@ -4,6 +4,8 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ToolPanel } from './ToolPanel'; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.mock('./TerminalPanel', () => ({ TerminalPanel: () =>
terminal
, })); @@ -53,6 +55,22 @@ describe('ToolPanel', () => { expect(container.querySelector('[data-testid="browser"]')).not.toBeNull(); }); + it.each([ + ['terminal', booting], + ['iframe', serving], + ['agent-browser', { ...serving, renderMode: 'ab-screencast' }], + ])('hides the %s face with its Workspace or parked leaf and restores only the foreground face', (_face, params) => { + show(params); + const terminal = half('terminal'); + const browser = half('browser'); + container.style.visibility = 'hidden'; + expect(getComputedStyle(terminal).visibility).toBe('hidden'); + expect(getComputedStyle(browser).visibility).toBe('hidden'); + container.style.visibility = 'visible'; + expect(getComputedStyle('url' in params ? browser : terminal).visibility).toBe('visible'); + expect(getComputedStyle('url' in params ? terminal : browser).visibility).toBe('hidden'); + }); + it('hides with visibility, never display', () => { // A display:none container measures zero, so the fit addon would resize the // PTY to a degenerate size and reflow the output of the command still @@ -66,14 +84,14 @@ describe('ToolPanel', () => { it('shows the terminal and hides the browser before the tool serves', () => { show(booting); - expect(half('terminal').style.visibility).toBe('visible'); + expect(getComputedStyle(half('terminal')).visibility).toBe('visible'); expect(half('browser').style.visibility).toBe('hidden'); }); it('shows the browser once serving', () => { show(serving); expect(half('terminal').style.visibility).toBe('hidden'); - expect(half('browser').style.visibility).toBe('visible'); + expect(getComputedStyle(half('browser')).visibility).toBe('visible'); }); it('parks the browser while it is hidden, so a screencast stops decoding', () => { diff --git a/lib/src/components/wall/ToolPanel.tsx b/lib/src/components/wall/ToolPanel.tsx index f9dbdea39..ef545d13a 100644 --- a/lib/src/components/wall/ToolPanel.tsx +++ b/lib/src/components/wall/ToolPanel.tsx @@ -19,7 +19,9 @@ function Half({ shown, children }: { shown: boolean; children: React.ReactNode } return (