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
18 changes: 14 additions & 4 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,16 @@ Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (

That allowlist is still a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into `dist/extension.js`, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`), and `assertConnectSrcBaked` fails the build if the define did not reach the bundle — a lost define would otherwise surface only as a Host silently using the shipped default. `lib/src/host/remote/connect-src.ts` reads it through `bakedConnectSrc()`, as a `declare const` rather than an import, so the value is a literal in the bundle and nothing at runtime can move it. A selfhoster widens it for their own build with `DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode` — the same variable and the same per-build opt-in as the standalone binary (`docs/specs/server.md` → "Where a Host may reach a relay server").

`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.
`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 every tag that loads a script, and injects initial state via a nonce-gated inline script.

**A nonce alone does not survive code splitting.** Vite splits the webview bundle, and `script-src` gates each way a chunk loads separately. Two mechanisms cover them, and the split is not negotiable — a nonce is **not** inherited through the module graph, and `'strict-dynamic'` does not vouch for a parser-started fetch:

- **Vite stamps the nonce** onto every tag it emits, via `html.cspNonce` in `vscode-ext/vite.config.ts` (the placeholder is `CSP_NONCE_PLACEHOLDER`, shared by the config and `webview-html.ts` from `vscode-ext/src/csp-nonce-placeholder.ts`). That covers the entry `<script>`, the `<link rel="modulepreload">` tags for its static imports, and the `<meta property="csp-nonce">` that Vite's own runtime preload helper reads before injecting a preload for a lazy chunk. Vite walks its output with a real HTML parser, so coverage follows the bundler's emitted shape rather than a regex's guess at it. `getWebviewHtml` then swaps the placeholder for that document's real nonce, and **throws if the placeholder is absent** — an unmarked build would otherwise serve un-nonced scripts against a nonce-gated policy, which looks exactly like a blank panel.
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight.
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight. What it widens is unbounded by origin: a script that already runs here may load one from anywhere, and since `default-src 'none'` leaves every other directive pinned to `webview.cspSource` or loopback, this is now the only request to an arbitrary external origin the policy permits. The trade is deliberate — `'strict-dynamic'` still blocks a parser-inserted `<script>` carrying no nonce, which a `webview.cspSource` allowlist in `script-src` would have let run.


Get any of it wrong and the failure is remote from its cause: a blank panel, or a render error naming a chunk that is sitting on disk. In both cases the only direct evidence is a CSP violation in the webview console (**Developer: Open Webview Developer Tools**) — nothing reaches an extension-host log, and the extension itself activates normally.

The class arrived with a build-tool upgrade (rolldown began splitting out its shared runtime) and can return the same way, so `vscode-ext/test/webview-html.test.ts` pins it against a fixture of real Vite output: `'strict-dynamic'` is present, `script-src` never gains `'unsafe-inline'`, each named script-loading tag carries the real nonce, no placeholder survives, no tag carries two nonces, and an unmarked document is refused.

### Webview message authentication

Expand Down Expand Up @@ -373,20 +382,21 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration;

### Testing the extension host

`vscode-ext` runs vitest (`pnpm --filter dormouse test`, which typechecks first). The `vscode` module only exists inside a running editor, so `vitest.config.mts` aliases it to a stub providing just the output channel `log.ts` opens — most modules worth testing import `vscode` as `import type`, which erases.
`vscode-ext` runs vitest (`pnpm --filter dormouse test`, which typechecks first). The `vscode` module only exists inside a running editor, so `vitest.config.mts` aliases it to a stub. Most modules worth testing import `vscode` as `import type`, which erases; the stub covers what is left, which is the output channel `log.ts` opens and the `Uri.file` that `webview-html.ts` calls at runtime. Nothing else is stubbed on purpose — a test that reaches further should fail loudly rather than pass against a fake that quietly does nothing.

The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. Six files, all under `vscode-ext/test/`:
Mostly these are the tests that need real I/O, since the pure halves already live in `lib`. Seven files, all under `vscode-ext/test/`:

- **`peer-link.test.ts`** stands up a broker and a client over a real socket: bind-as-lease (first binder wins, idempotent re-announce, taking over a socket whose broker died without unlinking, re-binding when the reclaimed socket is unlinked out from under it, a reclaimed bind answering no role until it is verified, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies, an accept-time server error logged rather than thrown, and the permanent stand-down when the shared token can be neither read nor created), the handshake (the three frames over a raw socket with the token never on the wire, a wrong-token proof dropped, a proof replayed from another connection rejected, and a squatter that took the path being served nothing), the socket directory being kept private, cross-window directory and surface ops, provider-local handles for colliding PTY ids, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, what a disconnect does to in-flight terminals, forwarded commands, and requests still outstanding against it, and that a client whose socket died reports *unsettled* before its `close` lands, so it agrees with `forwardCommand`.
- **`peer-link-protocol.test.ts`** is that link's socket-free half: frame shapes and framing (splits, oversized frames, malformed lines), the PTY routing table, the handshake proof primitives, and the guard that keeps `PEER_REPLY_BUDGET_MS` strictly larger than the `ASK_BUDGET_MS` fan-out it contains.
- **`remote-host.test.ts`** covers the VS Code half of the service: `VsCodeHostStateStore` round-tripping through the stubbed `SecretStorage`/`globalState` (including reading what the webview-resident Host left behind, re-reading after a cross-window change, and serializing ACL snapshots), the enroll bootstrap, commands held while the contention settles and refused at once when it can never settle, the read-only commands answered exactly as a real un-enrolled `RemoteHostService` answers them, contending when another window enrolls, command forwarding and answering, the status event a joining window is greeted with, the relay-socket factory's `ws` fallback, and the provider's streaming, duplicate restored-id owner binding, asking, and directory invalidation.
- **`message-router.test.ts`** covers the in-window fan-out with the link and the service stubbed out: one answer counted per webview however many it sends, and a late answer for a settled request marking the directory stale instead of being dropped.
- **`processed-pty-streams.test.ts`** covers the window's one keyed registry: exactly one listener pair however many attachments exist, none at all with none, per-PTY fan-out, and teardown on exit.
- **`webview-html.test.ts`** is the exception to the real-I/O rule above: its subject is a pure string transform, and it is here because the thing it guards cannot be checked anywhere else. It feeds `getWebviewHtml` a fixture of real Vite output from a temp directory and pins the CSP contract — `'strict-dynamic'` present, `script-src` never gaining `'unsafe-inline'`, each named script-loading tag carrying the real nonce, no placeholder surviving, no tag carrying two, and an unmarked document refused. See "CSP policy": every failure in that contract is invisible outside the webview console, so a unit test is the only place it can go red early.
- **`helpers.ts`** holds what the socket suites need — a throwaway `globalStorageUri`, the mirrored socket-path derivation, a poll-with-deadline, `freshModule`, and `fakeWindow`, one window as the link sees it.

Separate module instances come from `vi.resetModules()` plus a dynamic import, which is what makes one process able to play two windows.

Not covered: anything needing the real editor — command registration, webview hosting, the theme observer. Those would need `@vscode/test-electron`.
Not covered: anything needing the real editor — command registration, webview *hosting* (as opposed to the document `webview-html.ts` builds, covered above), the theme observer. Those would need `@vscode/test-electron`.

### Build and development

Expand Down
14 changes: 14 additions & 0 deletions vscode-ext/src/csp-nonce-placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* The build-time stand-in for the webview's CSP nonce.
*
* `vite.config.ts` hands this to Vite as `html.cspNonce`, so Vite stamps it onto
* every script/style tag it emits and onto the `<meta property="csp-nonce">` its
* runtime preload helper reads. `webview-html.ts` then replaces it with a fresh
* per-render nonce as it serves the document.
*
* It lives in its own module because both ends must agree on the exact string
* and neither can import the other: `vite.config.ts` runs in a plain Node build
* with no `vscode` module to resolve, which importing `webview-html.ts` would
* demand. Keep this file free of imports for the same reason.
*/
export const CSP_NONCE_PLACEHOLDER = '__DORMOUSE_CSP_NONCE__';
35 changes: 29 additions & 6 deletions vscode-ext/src/webview-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path';
import * as fs from 'fs';

import { randomBytes } from 'crypto';
import { CSP_NONCE_PLACEHOLDER } from './csp-nonce-placeholder';
import { HOST_MESSAGE_TOKEN_GLOBAL } from '../../lib/src/lib/vscode-message-token';
import { RECOVERY_COMMANDS_GLOBAL } from '../../lib/src/lib/vscode-recovery-global';

Expand Down Expand Up @@ -48,7 +49,13 @@ export function getWebviewHtml(
const csp = [
`default-src 'none'`,
`style-src ${webview.cspSource} 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`,
// The nonce is the root of trust; `strict-dynamic` extends it to what the
// entry chunk then loads. A nonce is not inherited through the module graph,
// so without it Vite's split chunks — a static import of the shared runtime,
// a lazy `import()` — are blocked. `strict-dynamic` also makes host-source
// expressions inert, so `webview.cspSource` beside it would be dead weight;
// inline scripts stay blocked, since nothing here grants `unsafe-inline`.
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`font-src ${webview.cspSource}`,
`img-src ${webview.cspSource} data: blob:`,
// ws: entries cover the agent-browser stream relay (frames + input for
Expand All @@ -69,12 +76,28 @@ export function getWebviewHtml(
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`,
);

// Add nonce to existing script tags (from the built index.html)
html = html.replace(/<script /g, `<script nonce="${nonce}" `);
html = html.replace(/<script>/g, `<script nonce="${nonce}">`);
// Vite marks its own output — every script/style tag plus the
// `<meta property="csp-nonce">` its runtime preload helper reads — with the
// placeholder, using a real HTML parser. So there is no tag-matching to do
// here, and nonce coverage tracks whatever shape the bundler emits instead of
// a regex's guess at it (docs/specs/vscode.md → "CSP policy").
//
// Serving an unmarked document would leave every script un-nonced against a
// nonce-gated policy, and the only symptom is a blank panel — the silent
// failure this placeholder exists to end. Same reasoning as
// `assertConnectSrcBaked` in `scripts/esbuild.mjs`: a lost build-time
// substitution must not look recoverable at runtime.
if (!html.includes(CSP_NONCE_PLACEHOLDER)) {
throw new Error(
`Webview HTML at ${indexPath} carries no ${CSP_NONCE_PLACEHOLDER}. ` +
'The build dropped `html.cspNonce` (vscode-ext/vite.config.ts); rebuild with `pnpm build:vscode`.',
);
}
html = html.replaceAll(CSP_NONCE_PLACEHOLDER, nonce);

// Inject the inline state script AFTER the nonce replacements so it doesn't
// get a duplicate nonce attribute from the regex above.
// The inline state script is ours, not Vite's, so it carries no placeholder —
// nonce it directly. Injected AFTER the swap so its nonce cannot be
// substituted a second time.
html = html.replace(
'</head>',
` <script nonce="${nonce}">globalThis.${HOST_MESSAGE_TOKEN_GLOBAL} = ${serializeForInlineScript(messageToken)};\nglobalThis.__DORMOUSE_HOST_STATE__ = ${serializeForInlineScript(initialState)};\nglobalThis.__DORMOUSE_SELECTED_SHELL__ = ${serializeForInlineScript(selectedShell ?? null)};\nglobalThis.${RECOVERY_COMMANDS_GLOBAL} = ${serializeForInlineScript(recoveryCommands ?? null)};</script>\n </head>`,
Expand Down
16 changes: 13 additions & 3 deletions vscode-ext/test/vscode-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
*
* The extension host modules worth unit-testing barely touch the API — most
* import it as `import type`, which erases. What is left is the output channel
* `log.ts` opens, so that is all this provides. Anything else is deliberately
* absent: a test that reaches further should fail loudly rather than pass
* against a fake that quietly does nothing.
* `log.ts` opens and the `Uri.file` that `webview-html.ts` calls, so that is all
* this provides. Anything else is deliberately absent: a test that reaches
* further should fail loudly rather than pass against a fake that quietly does
* nothing.
*/

export const window = {
Expand All @@ -14,3 +15,12 @@ export const window = {
dispose: () => {},
}),
};

/**
* `getWebviewHtml` resolves the media directory through `Uri.file` before
* handing it to `asWebviewUri`, which is the only thing that reads it back. A
* plain `fsPath` carrier is the whole contract.
*/
export const Uri = {
file: (fsPath: string) => ({ fsPath }),
};
Loading