diff --git a/docs/compatible-agents.md b/docs/compatible-agents.md index 9a296fd08..f7cbdfbaf 100644 --- a/docs/compatible-agents.md +++ b/docs/compatible-agents.md @@ -41,7 +41,7 @@ Watching requires shell integration that reports the running command. Terminal n ## Adding an agent -An agent integration normally needs one registry entry, an exit fixture, and a row in the table above; the standalone app and VS Code share the recovery implementation. +Add a registry entry, exit fixture, and table row. Both hosts share recovery. ### Add the definition and fixture @@ -56,9 +56,9 @@ An agent integration normally needs one registry entry, an exit fixture, and a r } ``` -2. Declare the executable names the agent actually installs and the resume option or subcommand it supports. The shared parser handles space/equals separators, terminal escapes, and command reconstruction. IDs must fit its alphanumeric, hyphen, and underscore grammar. If an agent cannot identify the exact conversation on exit, discuss its capture mechanism in an issue first. Do not substitute a “latest conversation” command. +2. Declare the installed executable names and resume option or subcommand. [Detection](#detection) owns parsing and ID rules. If the agent cannot identify the exact conversation on exit, discuss its capture mechanism in an issue first; never substitute a “latest conversation” command. 3. Add a sanitized exit excerpt to [the fixtures](../lib/src/lib/__fixtures__/coding-agents.ts), with the expected rebuilt command, agent version, and operating system. Replace personal paths, account information, and session IDs; preserve relevant wording and terminal escapes. Record real exit output rather than reconstructing a hint from documentation. -4. Add the agent and its command forms to the supported-agents table. Set `watchByDefault` only after checking that the agent becomes quiet when it needs attention. The registry tests require fixture coverage, and the website tests compare this table with the registry. +4. Add the agent and its command forms to the supported-agents table. Set `watchByDefault` only after checking that the agent becomes quiet when it needs attention. Registry tests pin fixture coverage; website tests pin the table. ### Verify the integration diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 892b964a1..a71157a52 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -1,65 +1,41 @@ # Transport and PTY Protocol Spec -> Adapter-agnostic protocol shared by every `PlatformAdapter`: PTY lifecycle, buffering, the webview ↔ platform message protocol, persisted-session types, and the invariants every adapter must honor. Host-specific layering lives in `docs/specs/vscode.md` and `docs/specs/standalone.md`; the phone's adapter in `docs/specs/pocket-app.md`. See `docs/specs/glossary.md` for the Process / Link state vocabulary, `docs/specs/alert.md` for `AlertManager` semantics, and `docs/specs/terminal-state.md` for the semantic events delivered over this transport. +> See `docs/specs/glossary.md` for Session / Pane / Door and Process / Link vocabulary. +> +> Adapter-agnostic protocol shared by every `PlatformAdapter`: PTY lifecycle, buffering, the webview ↔ platform message protocol, persisted-session types, and the invariants every adapter must honor. Host-specific layering lives in `docs/specs/vscode.md` and `docs/specs/standalone.md`; the phone's adapter in `docs/specs/pocket-app.md`. See `docs/specs/alert.md` for `AlertManager` semantics and `docs/specs/terminal-state.md` for semantic events. ## Adapter model -Each adapter wraps a PTY-spawning runtime and a transport channel between webview and host process. Source of truth: `PlatformAdapter` in `lib/src/lib/platform/types.ts`. +**Must expose platform capabilities through `PlatformAdapter`; unsupported optional capabilities are absent.** | Adapter | Host runtime | Transport | |---|---|---| | VS Code extension | extension host (Node.js) | `vscode.Webview.postMessage` ↔ `acquireVsCodeApi().postMessage` | | Standalone (Tauri) | sidecar process | Tauri command/event bridge | | Standalone browser-dev | sidecar + local dev HTTP bridge | fetch commands + Server-Sent Events | -| Pocket (`RemotePtyAdapter`) | the paired laptop's Host | remote protocol-v1 over the relay (`docs/specs/remote-api.md`) | +| Pocket (`RemotePtyAdapter`) | paired laptop’s Burrow | encrypted protocol-v1 over the selected Relay/direct session (`docs/specs/remote-api.md`); one-time is direct-only (`docs/specs/one-time.md`) | | Fake (tests, playground) | in-process | direct calls / event emitter | **A host that cannot do something must say so by absence, never by the UI branching on host identity.** `RemotePtyAdapter` implements only the PTY core (list/data/write/resize/exit) and no-ops or omits the rest. -Optional booleans: +**Must treat absent host-ownership capabilities as false.** Their members, defaults, and consumers are canonical comments on `PlatformAdapter`; behavior belongs to `docs/specs/theme.md` → Where the user picks a theme, `docs/specs/vscode.md` → Shell selection, and `docs/specs/remote-network.md` → Settings → Network. -| Member | Absent reads | Set by | Effect when set | -|---|---|---|---| -| `hostOwnsTheme?` | `false` | `VSCodeAdapter` → `true` | Settings hides its theme picker (`docs/specs/theme.md` → "Where the user picks a theme") | -| `hostOwnsShells?` | `false` | `VSCodeAdapter` → `true` | Settings hides its Shell row for the native QuickPick (`docs/specs/vscode.md` → "Shell selection") | -| `hostOwnsUpdates?` | `false` | `VSCodeAdapter` → `true` | Settings → Network names the Marketplace instead of an update check (`docs/specs/remote-network.md` → "Settings → Network") | +Source of truth: `PlatformAdapter` in `lib/src/lib/platform/types.ts`. ## PTY lifecycle -PTYs are managed by the platform host, not by the webview. The webview **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). - -``` -Platform host (always running while the adapter is active) -├── pty-manager (forks pty-host child process) -│ ├── pty-1 (Process: Live) -│ ├── pty-2 (Process: Live) -│ └── pty-3 (Process: Exited) -│ -├── Webview (e.g. VS Code WebviewView, a standalone window) -│ └── message-router: owns pty-1, pty-2 -│ -└── Secondary webview (a VS Code editor-tab WebviewPanel, another standalone window) - └── message-router: owns pty-3 -``` - -**Every host is multi-webview**, and **a router never takes a PTY another -owns**: ownership keeps one webview's traffic out of another's, and each host -owns the map (`docs/specs/vscode.md` → "Peer surfaces across windows", -`docs/specs/standalone.md` → Routing). +**Must keep PTYs in their platform runtime across webview hide/recreate and isolate each webview’s ownership.** Local host mechanisms belong to `docs/specs/vscode.md` → Webview hosting and `docs/specs/standalone.md` → Routing. The webview resumes over preserved PTYs or restores from a Snapshot. - **Hiding a webview does not kill its PTYs**, and becoming visible again resumes over the still-owned ones ("Reconnection protocol"). - **A naturally exited PTY may stay mounted as an exited pane**; frontend semantic state — CWD, title candidates, last command — is retained until the Session is disposed. - **Must keep explicitly killed PTYs non-resumable.** VS Code tombstones their ids (`Process: Tombstoned`) in `pty-manager.ts` so late child-process output cannot recreate a buffer; the shared `pty-core.js` drops its live record and retains no output. -- **Each host instance gets its own pty-host child process** (e.g. one per VS Code window). - **Must mark live VS Code PTYs exited and notify their owners when the child process exits unexpectedly**, retaining transcripts and already-recorded exits. **Must ignore retired-child output and exit events after replacement**; pinned by `vscode-ext/test/pty-manager.test.ts`. ### PTY buffering -VS Code's `pty-manager` keeps two buffers plus one counter per PTY. **Must cap each buffer at 1,000,000 characters**, dropping oldest chunks and truncating an oversized final chunk; pinned by `vscode-ext/test/pty-manager.test.ts`. +**Must cap each VS Code replay and scrollback buffer at 1,000,000 characters**, evicting oldest chunks and truncating an oversized final chunk. **Must clear replay on first consume and retain host-only scrollback until `kill`/`killAll`**, for repeat resume and recovery. Stream positions follow Universal invariants. -- **replayChunks** — cleared on first consume; used for resume (webview hidden then shown). -- **scrollbackChunks** — never cleared short of `kill`/`killAll`; used for repeat resumes (a re-serving router's replay buffer is already spent) and for recovery capture at teardown. Host-side only — no adapter exposes it to the renderer. -- **receivedChars** — every char ever buffered, never decremented by a trim ("A position in a pane's output is a received count", below). +Source of truth: `bufferData` / `getReplayData` in `vscode-ext/src/pty-manager.ts`, pinned by `vscode-ext/test/pty-manager.test.ts`. ### Paced input @@ -76,16 +52,10 @@ Source of truth: `pacedInputSegments` and `write` in `standalone/sidecar/pty-cor ### Reconnection protocol -``` -1. Webview becomes visible (or panel deserializes) and sends { type: 'dormouse:init' }. -2. Host answers { type: 'pty:list', ptys: [{ id, alive, exitCode, shell }] } for all owned PTYs, - then per PTY { type: 'pty:replay', id, data } and { type: 'alert:state', id, … }. -3. Webview restores terminals from replay data, including each PTY's launch-shell path, which - the rebuilt registry needs for Session-specific clipboard/drop escaping. -4. If the saved session covers those live PTYs, the frontend uses the saved Lath layout when its - leaf set matches and reattaches saved minimized doors; minimized PTYs are registered but stay - doors, not visible panes. -``` +1. The visible or deserialized webview calls `requestInit` (VS Code: `{ type: 'dormouse:init' }`). +2. The host answers `pty:list` (one `PtyInfo` per owned PTY: `id`, `alive`, `exitCode`, `shell`), then `pty:replay` for each PTY with buffered output, then `alert:state` for each. +3. The webview resumes terminals with their launch shells for Session-specific clipboard/drop escaping. +4. A saved layout is reused only when its leaves match the live visible pane set; saved minimized PTYs are registered as Doors. **A collection finishes only on its own answer.** A `requestInit` carries the asking collector's token, and a host serving several windows echoes it on the @@ -177,7 +147,7 @@ Source of truth: the message schema in `vscode-ext/src/message-types.ts` (`Webvi **`dormouse:runWorkbenchCommand` (webview → host) is allowlisted** against `lib/src/lib/vscode-keybindings.ts` before `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. -**Reaching the Burrow is one optional adapter member.** `burrow?: BurrowLink` is present exactly when a PTY-owning process sits behind the webview — standalone's sidecar, VS Code's extension host — and absent on the website. Its four calls are `command`, `respond`, `notify` (argless — the directory is the only thing a peer answers), and `on`. The webview half is `lib/src/host/remote/link-client.ts`, shared by all three adapters so no host settles a command differently: command correlation, a 15 s timeout, and the rule that **an ask is always answered even when nothing matches**. Both ends compile against `lib/src/host/remote/service-protocol.ts`. **Nothing crossing this seam carries authority** (`docs/specs/remote-security-model.md`). +**Reaching the Burrow is one optional adapter member.** `burrow?: BurrowLink` is present exactly when a PTY-owning process sits behind the webview — standalone's sidecar, VS Code's extension host — and absent on the website. The webview half is `lib/src/host/remote/link-client.ts`, shared by all three adapters so no host settles a command differently: command correlation, a 15 s timeout, and the rule that **an ask is always answered even when nothing matches**. Both ends compile against `lib/src/host/remote/service-protocol.ts`; `BurrowLink` in `lib/src/lib/platform/types.ts` owns the call shapes. **Nothing crossing this seam carries authority** (`docs/specs/remote-security-model.md`). Each host maps those calls onto its own transport: @@ -197,7 +167,7 @@ Transport constraints: | --- | --- | --- | | Webview → host | `dormouse:openExternal` | Open a user-confirmed external URI from an OSC 8 hyperlink. **Hosts must revalidate**, rejecting malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:` — `lib/src/lib/external-links.ts`). | | Webview → host | `pty:getOpenPorts` | TCP listening ports of a PTY's shell **and all of its descendant subprocesses**, resolved from the root pid, answered with `pty:openPorts`. `getOpenPortsForPid()` in `standalone/sidecar/pty-core.js` (VS Code loads it through the `lib/pty-core.cjs` shim). | -| Host → webview | `pty:openPorts` | `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. | +| Host → webview | `pty:openPorts` | `OpenPort[]`, de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. | | Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. | | Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. | | Host → webview | `terminal:toolEvents` | Ordered Tool announcements, state, and command-start resets (`docs/specs/dor-tool.md` → OSC 367). | @@ -241,7 +211,7 @@ Source of truth: `ManagedVoicePort` in `lib/src/lib/platform/managed-voice-types **Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` — the default, **omitted from the row** so terminal snapshots stay byte-identical — `'browser'`, or `'tool'`, whose extra `command` and `tool` fields are `docs/specs/dor-tool.md` → Persistence and hosts. It routes restore/resume, and **a pane lacking it reads as `'terminal'`**. `restoreSession` skips terminal restoration for a browser pane rather than minting a stray PTY + xterm per browser pane id, and the resume plan keeps browser panes and minimized browser doors despite their having no live PTY, so the saved layout's leaf set still matches and is not discarded. A browser pane rebuilds from the persisted layout (visible) or `PersistedDoor.params` (minimized) — its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane`. **Must reject a layout whose leaves differ from the visible pane set during restore or resume, and omit visible browser ids from the terminal fallback.** Browser doors retain their independent render params; pinned by `lib/src/lib/session-restore.test.ts` and `lib/src/lib/reconnect.test.ts`. -**Each mounted Workspace publishes its `PersistedSession` to a Window collector**, which orders them by the Workspace store and writes the whole Window through one debounced writer the host installs at boot. **A Workspace with neither a published nor a boot-seeded session is dropped rather than written empty**, so a snapshot taken mid-boot cannot replace a restored Workspace with a blank one. **A Workspace's save compares against its own previous record** — seeded from disk until its Wall publishes — never the Window's active one, or a dead PTY's retained cwd and alert would come from the wrong Workspace. **Reordering, renaming, or switching the active Workspace writes too**: each changes the blob with no Session changing. A `PersistedWorkspace` is a `WorkspaceId`, a `name`, `nameIsAuto`, and that Workspace's `PersistedSession`. **Always write `nameIsAuto`**; lacking it, only a `Workspace ` name is auto. The top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions: the ordered `PersistedWorkspace` list plus the active `WorkspaceId`. **VS Code does not use it** — each webview persists one bare `PersistedSession`, its single Workspace, through its own per-surface state API (`docs/specs/vscode.md`). +**Each mounted Workspace publishes its `PersistedSession` to a Window collector**, which orders them by the Workspace store and writes the whole Window through one debounced writer the host installs at boot. **A Workspace with neither a published nor a boot-seeded session is dropped rather than written empty**, so a snapshot taken mid-boot cannot replace a restored Workspace with a blank one. **A Workspace's save compares against its own previous record** — seeded from disk until its Wall publishes — never the Window's active one, or a dead PTY's retained cwd and alert would come from the wrong Workspace. **Reordering, renaming, or switching the active Workspace writes too**: each changes the blob with no Session changing. **Always write `nameIsAuto`**; lacking it, only a `Workspace ` name is auto. **VS Code does not use it** — each webview persists one bare `PersistedSession`, its single Workspace, through its own per-surface state API (`docs/specs/vscode.md`). **Must publish both Workspace records in one synchronous step with the Surface move's ownership change**, unprobed and behind one Window write (`pagehide` included), fencing saves collected before or during the ownership change and retaining a departed Session's previous cwd/alert in the destination. Source of truth: `publishWorkspaceSessions` / `invalidateWorkspaceSaves` / `moveRetainedSurfaceRecord` in `lib/src/lib/window-session-aggregator.ts`; `serializeNow` / `doSave` in `lib/src/components/wall/use-session-persistence.ts`; `assemblePersistedSession` in `lib/src/lib/session-save.ts`. @@ -261,7 +231,7 @@ Source of truth: `PersistedSession` in `lib/src/lib/session-types.ts`; `surfaceR ### What is persisted -Structure only: panes (id, cwd, title, `untouched`, `surfaceType`, TODO/alert blob), doors and their Lath restore tokens, the Lath layout, and the Workspace's `dor` surface refs and delivery overrides. **Scrollback is never persisted by any writer**, and neither is the recovery command (above). +**Must persist structure only, never scrollback or recovery commands.** The accepted shapes are `PersistedSession` / `PersistedWindow` in `lib/src/lib/session-types.ts`; their behavioral contracts are in Persisted session types. ### Retiring the transcripts already on disk diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 2bcf007f8..e094dcf2f 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -20,9 +20,7 @@ Start on the side of the webview boundary involved, then follow imports: ## What's built -Two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (`dormouse.open`, multiple instances). Both restore across "Developer: Reload Window". PTYs live in the extension host (`pty-manager.ts`), survive panel visibility toggling, and replay buffered output on **resume**. Scrollback is never persisted (`docs/specs/transport.md` → "Persistence policy"); `deactivate()` instead interrupts the live PTYs and records each pane's agent resume invocation for the next cold restore to auto-run (`docs/specs/layout.md` → "Agent resume on cold restore"). - -The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs/layout.md`, `docs/specs/transport.md`). The only VS Code-specific pieces in `lib/`: `lib/src/lib/platform/vscode-adapter.ts` (the postMessage bridge), `lib/src/lib/vscode-message-token.ts`, `lib/src/lib/vscode-keybindings.ts`. +The shared frontend runs in the bottom-panel `WebviewView` and independent editor-tab `WebviewPanel`s. Their ownership, visibility, and restore contracts are in Webview hosting and Serialization and restore. ### Invariants (VS Code-specific) @@ -40,7 +38,7 @@ The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs ### Extension manifest -**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering. The shipped commands are `dormouse.focus`, `dormouse.open`, `dormouse.debugTheme`, `dormouse.newTerminal`, and `dormouse.selectShell`. +**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering. **No `configuration`, no `keybindings`, no context key**: settings live in the in-webview Settings dialog rather than `settings.json`, chords are handled inside the webview, and nothing is `when`-gated on Dormouse state. Context keys are [Future](#context-keys). Source of truth: `vscode-ext/package.json`. @@ -183,7 +181,7 @@ frame-src http://127.0.0.1:* http://localhost:* **That origin is a build-time constant, never a runtime value**: `vscode-ext/scripts/esbuild.mjs` bakes `DORMOUSE_RELAY_ORIGIN` into `dist/extension.js`. The default, the modes, and the build-time guards are `docs/specs/relay.md` → "Relay origin". -`unsafe-inline` for styles covers the theme CSS variables VS Code injects as inline styles on the body element. Scripts stay nonce-gated on a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — **never `Math.random()`**. Vite builds the webview HTML from the `lib` package; at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, swaps Vite's nonce placeholder for the real one, and appends a nonce-gated inline script carrying the boot globals (message token, initial state, selected shell, recovery commands). +`unsafe-inline` for styles covers the theme CSS variables VS Code injects as inline styles on the body element. **Must mint a fresh per-render nonce from 24 CSPRNG bytes, never `Math.random()`**, and nonce-gate the boot globals. `getWebviewHtml` owns asset rewriting, nonce substitution, and boot serialization. **`lib/index.html` keeps `` and `` bare**: both splices match a literal and **throw when it is absent**, an attribute otherwise yielding an unpoliced document. Pinned by "refuses a document whose splice marker was edited away". @@ -204,7 +202,7 @@ Source of truth: `getWebviewHtml` in `vscode-ext/src/webview-html.ts`, `CSP_NONC **The webview's `window` is a shared inbox, so `event.data.type` cannot decide trust** — it is attacker-chosen. The extension host posts there, and so can any framed surface (`dor iframe`, agent-browser; `docs/specs/dor-browser.md`) via `parent.postMessage`, which crosses origin and sandbox boundaries by design; the CSP governs what the document may *load*, never who may *message* it. A forgery is consequential: `dor:controlRequest` becomes a `dormouse:control-request` event `use-dor-control.ts` can turn into a `writePty`, and the `pty:*` family drives what the user sees (rationale). Host-originated messages are therefore authenticated by a **per-boot message token**: -- `getWebviewHtml` mints one token per webview document — 24 CSPRNG bytes, base64url, from the same `randomSecret()` as the CSP nonce — injects it as `globalThis.__DORMOUSE_MESSAGE_TOKEN__` in the same nonce-gated inline script that seeds the other `__DORMOUSE_*` globals, and returns it alongside the HTML. +- **Must mint a fresh message token per document from 24 CSPRNG bytes**, distinct from its CSP nonce, and inject it only through the nonce-gated boot script. - **`serveWebview` is the only way to put a document on a webview**: it mints, assigns `webview.html`, and returns a `WebviewChannel` whose `post()` closes over that document's token. **Minting and serving are one step**, so a token cannot drift from its document; re-serving yields a new token and channel, and nothing keys a token by webview identity, so there is no cleanup. - **Every host → webview send goes through a channel**, making a bypass a type error rather than a convention to remember; only the two serve sites (`setupPanel`, `resolveWebviewView`) still hold a raw webview, and `attachRouter` takes a `WebviewChannel`, not a `vscode.Webview`. `DormouseViewProvider.postMessage` forwards to its stored channel, **returning `false` before the view is served or after it disposes** — the VS Code API's own undelivered signal, already handled by the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path. - `VSCodeAdapter` captures the token **once, at construction**, and both of its `message` listeners — the main dispatcher and the per-request reply listener inside `requestResponse` — call `isHostMessage(event.data, token)` before reading anything else, `type` included. @@ -339,6 +337,8 @@ Once an answer names a `ptyId`, the broker replaces that owner-local id with a s Two UI events *are* addressed: **when a window completes the handshake the broker sends it the current `status` and `one-time` events** — each is emitted only when it changes and once as the service starts, so a window opened after the enrollment or the link would otherwise sit disarmed until reloaded. +**Must bound every peer frame in UTF-8 bytes before parsing**, complete frames and partial tails included. Oversized frames are discarded through their newline without losing adjacent valid frames. + **Socket bind errors reject startup** and are handled as an unavailable peer link; they never leave the listen promise pending or surface as an uncaught extension host error. Source of truth: `vscode-ext/src/peer-link.ts` (sockets, arbitration, `HANDSHAKE_BUDGET_MS`, and the `routes` / `routePtyIds` routing table); `vscode-ext/src/peer-link-protocol.ts` (frame shapes, framing, handshake helpers, `PEER_REPLY_BUDGET_MS`), pinned by `vscode-ext/test/peer-link-protocol.test.ts`; `askBothTiers` in `vscode-ext/src/burrow.ts`; `brokerRequest` and the `peer:*` / `burrow:command` cases in `vscode-ext/src/message-router.ts`; `lib/src/remote/burrow/remote-api.ts`. @@ -365,12 +365,6 @@ types without checking them, so `tsc` runs separately as `pnpm typecheck`, **wir into the package's `test` script** so the root `pnpm test` covers it — that wiring is what protects `deactivate()`, which has no `try`/`catch` (rationale). -The checked program spans two runtimes — `src/` is extension-host Node code but -imports webview modules from `../lib/src/` — so its config carries both DOM and -Node libs, looser than either alone, each side checked precisely by its own -project (`lib/tsconfig.app.json` for the webview). What it reliably catches is -vscode-ext's own code referring to something that no longer exists. - `pnpm dogfood:vscode` uninstalls the legacy `diffplug.mouseterm` extension before packaging and installing the current Dormouse VSIX; the VS Code window must then be reloaded. Day-to-day development uses it, since it runs against your real diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index 39606645a..7bc202019 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -110,3 +110,5 @@ macOS host, hence copying only the declared platform packages. **Why a self-host VSIX needs no update switch of its own.** VS Code treats a VSIX install as a pinned version and leaves it out of Marketplace auto-update (microsoft/vscode#219932, fixed by #219933 in the July 2024 iteration, 1.92; the diff covers the CLI's VSIX path, `code --install-extension`, which `pnpm dogfood:vscode` takes — checked 2026-09). An extension cannot opt itself out of Marketplace updates, and a distinct extension id would collide with the Marketplace build's command, view, and keybinding contributions when both are installed, and would strand the enrollment in another id's `SecretStorage`. **Why the separate typecheck is wired into `test`.** A reference to a deleted function once reached a commit and surfaced only as a runtime throw during `deactivate()`, which — having no `try`/`catch` — skipped every teardown step behind it. `tsc` is the package's only automated check for that class of error. + +**Why the typecheck config carries both DOM and Node libs.** The checked program spans two runtimes — `src/` is extension-host Node code but imports webview modules from `../lib/src/` — so `vscode-ext/tsconfig.json` is looser than either runtime alone; each side is checked precisely by its own project (`lib/tsconfig.app.json` for the webview). What it reliably catches is vscode-ext's own code referring to something that no longer exists. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index fb6f52192..f9fc124de 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -36,9 +36,9 @@ "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2400, "docs/specs/tiling-engine.md": 4450, - "docs/specs/transport.md": 5050, + "docs/specs/transport.md": 4800, "docs/specs/tutorial.md": 2050, - "docs/specs/vscode.md": 7300, + "docs/specs/vscode.md": 7150, "docs/specs/webgl-text.md": 1200, "docs/specs/website-docs.md": 5000 } diff --git a/vscode-ext/src/peer-link-protocol.ts b/vscode-ext/src/peer-link-protocol.ts index 0ca0ab850..852f2d260 100644 --- a/vscode-ext/src/peer-link-protocol.ts +++ b/vscode-ext/src/peer-link-protocol.ts @@ -93,7 +93,9 @@ export function encodeFrame(frame: PeerLinkFrame | PeerLinkHandshake): string { * killing the link. */ export class FrameDecoder { + /** The unterminated frame so far, and its UTF-8 size, counted per chunk. */ #buffer = ''; + #bufferBytes = 0; /** * Set once one frame has outgrown the cap: everything up to the next newline * belongs to that frame and is dropped, and normal accumulation resumes after @@ -103,25 +105,29 @@ export class FrameDecoder { #discarding = false; readonly #maxFrameBytes: number; - /** Bounds a peer that never sends a newline; the default fits a screenful. */ + /** Bounds each UTF-8 frame before JSON parsing; the default fits a screenful. */ constructor(maxFrameBytes = 4 * 1024 * 1024) { this.#maxFrameBytes = maxFrameBytes; } push(chunk: string): unknown[] { - this.#buffer += chunk; const frames: unknown[] = []; - for (;;) { - const newline = this.#buffer.indexOf('\n'); - if (newline === -1) break; - const line = this.#buffer.slice(0, newline); - this.#buffer = this.#buffer.slice(newline + 1); + // Each piece is measured once, as it arrives — re-measuring the whole + // buffer on every chunk would be quadratic in a large frame. + const pieces = chunk.split('\n'); + const tail = pieces.pop()!; + for (const piece of pieces) { + const line = this.#buffer + piece; + const bytes = this.#bufferBytes + Buffer.byteLength(piece, 'utf8'); + this.#buffer = ''; + this.#bufferBytes = 0; if (this.#discarding) { // That was the oversized frame's terminator; the bytes after it are a // frame boundary again. this.#discarding = false; continue; } + if (bytes > this.#maxFrameBytes) continue; if (!line.trim()) continue; try { frames.push(JSON.parse(line)); @@ -130,11 +136,18 @@ export class FrameDecoder { } } // Whatever is left is one unterminated frame. Past the cap it is a frame we - // can never read, so it goes — but the whole frames already taken out of - // the buffer above are real, and dropping them with it would lose traffic - // from a link that is otherwise healthy. - if (this.#buffer.length > this.#maxFrameBytes) this.#discarding = true; - if (this.#discarding) this.#buffer = ''; + // can never read, so it goes — but the whole frames already taken out + // above are real, and dropping them with it would lose traffic from a link + // that is otherwise healthy. + if (!this.#discarding) { + this.#buffer += tail; + this.#bufferBytes += Buffer.byteLength(tail, 'utf8'); + if (this.#bufferBytes > this.#maxFrameBytes) this.#discarding = true; + } + if (this.#discarding) { + this.#buffer = ''; + this.#bufferBytes = 0; + } return frames; } } diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 59a559a34..20a6810b5 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -48,6 +48,20 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { _token: vscode.CancellationToken, ): Promise { this.view = view; + // Registered before the shell-discovery await: a view disposed or replaced + // while it is pending is never served, and a stale view's disposal never + // releases its successor's router. + let disposed = false; + let ownedRouter: vscode.Disposable | undefined; + view.onDidDispose(() => { + disposed = true; + ownedRouter?.dispose(); + if (this.view !== view) return; + log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); + this.routerDisposable = undefined; + this.channel = undefined; + this.view = undefined; + }); if (this.description !== undefined) view.description = this.description; const mediaPath = path.join(this.context.extensionPath, 'media'); @@ -62,6 +76,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { // is cached; this blocks only on a true cold start. if (!this.selectedShell) { const shells = await ptyManager.getAvailableShells(); + if (disposed || this.view !== view) return; const shell = resolveSelectedShell(this.context, shells); this.selectedShell = shell ? { shell: shell.path, args: shell.args } : null; if (shell) { @@ -85,7 +100,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { ); this.routerDisposable?.dispose(); - this.routerDisposable = attachRouter(this.channel, { + this.routerDisposable = ownedRouter = attachRouter(this.channel, { reconnect: true, onSaveState: (state) => { return saveSessionState(this.context, mergeAlertStates(state, getAlertStates())); @@ -100,14 +115,6 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { if (this.view) this.view.badge = workspaceBadge(union); }, }); - - view.onDidDispose(() => { - log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); - this.routerDisposable?.dispose(); - this.routerDisposable = undefined; - this.channel = undefined; - this.view = undefined; - }); } focus(): void { diff --git a/vscode-ext/test/peer-link-protocol.test.ts b/vscode-ext/test/peer-link-protocol.test.ts index 221655aa1..639bf2e40 100644 --- a/vscode-ext/test/peer-link-protocol.test.ts +++ b/vscode-ext/test/peer-link-protocol.test.ts @@ -18,6 +18,42 @@ import { } from '../src/peer-link-protocol'; describe('FrameDecoder', () => { + it('caps complete UTF-8 frames before parsing and preserves surrounding frames', () => { + const good = { kind: 'notify' } as const; + for (const data of ['x'.repeat(100), '\u00e9'.repeat(20)]) { + const encoded = encodeFrame({ kind: 'data', ptyId: 'p', data }); + expect(Buffer.byteLength(encoded.slice(0, -1), 'utf8')).toBeGreaterThan(64); + const decoder = new FrameDecoder(64); + expect(decoder.push(encodeFrame(good) + encoded + encodeFrame(good))).toEqual([good, good]); + } + }); + + it('discards an oversized multibyte partial frame through its newline', () => { + const decoder = new FrameDecoder(64); + const encoded = encodeFrame({ kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) }); + expect(decoder.push(encoded.slice(0, -1))).toEqual([]); + expect(decoder.push('\n' + encodeFrame({ kind: 'notify' }))).toEqual([{ kind: 'notify' }]); + }); + + it('accepts exactly the UTF-8 byte cap and rejects one byte more', () => { + const frame = { kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) } as const; + const encoded = encodeFrame(frame); + const cap = Buffer.byteLength(encoded.slice(0, -1), 'utf8'); + expect(new FrameDecoder(cap).push(encoded)).toEqual([frame]); + expect(new FrameDecoder(cap - 1).push(encoded)).toEqual([]); + }); + + it('counts UTF-8 bytes across a frame delivered one character at a time', () => { + const frame = { kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) } as const; + const encoded = encodeFrame(frame); + const cap = Buffer.byteLength(encoded.slice(0, -1), 'utf8'); + const trickle = (decoder: FrameDecoder) => [...encoded].flatMap((ch) => decoder.push(ch)); + expect(trickle(new FrameDecoder(cap))).toEqual([frame]); + const over = new FrameDecoder(cap - 1); + expect(trickle(over)).toEqual([]); + expect(over.push(encodeFrame({ kind: 'notify' }))).toEqual([{ kind: 'notify' }]); + }); + it('reads one frame per line', () => { const decoder = new FrameDecoder(); const frames = decoder.push( diff --git a/vscode-ext/test/webview-view-provider.test.ts b/vscode-ext/test/webview-view-provider.test.ts new file mode 100644 index 000000000..dfcab4dfd --- /dev/null +++ b/vscode-ext/test/webview-view-provider.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + take: vi.fn(), serve: vi.fn(), attach: vi.fn(), shells: vi.fn(), +})); +vi.mock('../src/session-state', () => ({ + takeRecoveryCommands: mocks.take, getSavedSessionState: () => undefined, + mergeAlertStates: (state: unknown) => state, saveSessionState: vi.fn(), +})); +vi.mock('../src/message-router', () => ({ + attachRouter: mocks.attach, getAlertStates: () => new Map(), +})); +vi.mock('../src/webview-messaging', () => ({ serveWebview: mocks.serve })); +vi.mock('../src/pty-manager', () => ({ getAvailableShells: mocks.shells })); +vi.mock('../src/shell-selection', () => ({ + resolveSelectedShell: (_context: unknown, shells: unknown[]) => shells[0], +})); +import { DormouseViewProvider } from '../src/webview-view-provider'; + +function view() { + let dispose!: () => void; + const value = { webview: {}, onDidDispose: (callback: () => void) => { dispose = callback; } }; + return { value: value as never, dispose: () => dispose() }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +const cmd = [{ name: 'cmd', path: 'cmd.exe', args: [] }]; +beforeEach(() => { + vi.clearAllMocks(); + mocks.take.mockReturnValue({}); + mocks.serve.mockReturnValue({ post: () => Promise.resolve(true) }); + mocks.attach.mockReturnValue({ dispose: vi.fn() }); +}); + +it('does not touch a disposed view after asynchronous shell discovery', async () => { + const ready = deferred(), target = view(); + const description = vi.fn(); + Object.defineProperty(target.value, 'description', { set: description }); + mocks.shells.mockReturnValue(ready.promise); + const host = new DormouseViewProvider({ extensionPath: 'extension' } as never); + const pending = host.resolveWebviewView(target.value, {} as never, {} as never); + target.dispose(); + ready.resolve(cmd); + await pending; + expect(description).not.toHaveBeenCalled(); + expect(mocks.take).not.toHaveBeenCalled(); + expect(mocks.serve).not.toHaveBeenCalled(); + expect(mocks.attach).not.toHaveBeenCalled(); +}); + +it('a late old shell discovery and old disposal cannot replace or dispose a newer view', async () => { + const ready = deferred(), first = view(), second = view(); + const host = new DormouseViewProvider({ extensionPath: 'extension' } as never); + const newRouter = { dispose: vi.fn() }; + mocks.attach.mockReturnValue(newRouter); + mocks.shells.mockReturnValueOnce(ready.promise).mockResolvedValueOnce(cmd); + const old = host.resolveWebviewView(first.value, {} as never, {} as never); + await host.resolveWebviewView(second.value, {} as never, {} as never); + ready.resolve(cmd); + await old; + first.dispose(); + expect(mocks.serve).toHaveBeenCalledTimes(1); + expect(mocks.serve.mock.calls[0][0]).toBe((second.value as { webview: unknown }).webview); + expect(newRouter.dispose).not.toHaveBeenCalled(); + expect(await host.postMessage({ type: 'dormouse:newTerminal' } as never)).toBe(true); + second.dispose(); + expect(newRouter.dispose).toHaveBeenCalledTimes(1); + expect(await host.postMessage({ type: 'dormouse:newTerminal' } as never)).toBe(false); +}); diff --git a/website/scripts/generate-docs.test.js b/website/scripts/generate-docs.test.js index f036005df..880f096f6 100644 --- a/website/scripts/generate-docs.test.js +++ b/website/scripts/generate-docs.test.js @@ -52,12 +52,15 @@ describe('compatible agents', () => { expect(body).not.toContain('If automatic agent startup becomes disruptive'); }); - it('sends the contributor link to the withheld contract on GitHub', () => { + it('sends contributor links to withheld headings on GitHub', () => { expect(data.agents.withheldLinks).toEqual([{ + from: '#detection', + to: `${REPO_BLOB_BASE}/docs/compatible-agents.md#detection`, + }, { from: '#recovery-contract-maintainers', to: `${REPO_BLOB_BASE}/docs/compatible-agents.md#recovery-contract-maintainers`, }]); - expect(generatedHrefs()).toContain(data.agents.withheldLinks[0].to); + expect(generatedHrefs()).toEqual(expect.arrayContaining(data.agents.withheldLinks.map(({ to }) => to))); }); it('keeps the supported-agent table aligned with executable and resume definitions', () => {