diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md
index 863246cb..f99cca0b 100644
--- a/docs/specs/vscode.md
+++ b/docs/specs/vscode.md
@@ -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 `\n `,
diff --git a/vscode-ext/test/vscode-stub.ts b/vscode-ext/test/vscode-stub.ts
index 6aed6a99..06a12409 100644
--- a/vscode-ext/test/vscode-stub.ts
+++ b/vscode-ext/test/vscode-stub.ts
@@ -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 = {
@@ -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 }),
+};
diff --git a/vscode-ext/test/webview-html.test.ts b/vscode-ext/test/webview-html.test.ts
new file mode 100644
index 00000000..1a9bdef6
--- /dev/null
+++ b/vscode-ext/test/webview-html.test.ts
@@ -0,0 +1,137 @@
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { CSP_NONCE_PLACEHOLDER } from '../src/csp-nonce-placeholder';
+import { getWebviewHtml } from '../src/webview-html';
+import { removeDir, tempStorageDir } from './helpers';
+
+/**
+ * `getWebviewHtml` post-processes whatever Vite built into `media/`, so these
+ * tests feed it that file's real shape from a temp directory rather than mocking
+ * the read. Below is verbatim Vite 8 output with `html.cspNonce` set: rolldown
+ * splits its shared runtime into its own chunk and the entry both imports it and
+ * carries a `` for it, and Vite marks every tag plus
+ * the `` that its runtime preload helper reads.
+ */
+const VITE_INDEX_HTML = `
+
+
+
+ Dormouse
+
+
+
+
+
+
+
+
+
+
+`;
+
+const CSP_SOURCE = 'https://file+.vscode-resource.vscode-cdn.net';
+
+const webview = {
+ cspSource: CSP_SOURCE,
+ asWebviewUri: (uri: { fsPath: string }) => `${CSP_SOURCE}${uri.fsPath}`,
+} as never;
+
+/** Shared across the suite: every test only reads through `getWebviewHtml`. */
+let mediaPath: string;
+
+beforeAll(async () => {
+ mediaPath = await tempStorageDir();
+ await writeFile(join(mediaPath, 'index.html'), VITE_INDEX_HTML);
+});
+
+afterAll(async () => {
+ await removeDir(mediaPath);
+});
+
+/** The single nonce the document was served with, read back off its CSP. */
+function nonceOf(html: string): string {
+ const match = /script-src 'nonce-([A-Za-z0-9_-]+)'/.exec(html);
+ if (!match) throw new Error('no script-src nonce in the CSP');
+ return match[1];
+}
+
+describe('getWebviewHtml', () => {
+ it("pairs the nonce with 'strict-dynamic' so split chunks can load", () => {
+ const { html } = getWebviewHtml(webview, mediaPath);
+ // A lazy `import()` carries no nonce — a nonce is not inherited through the
+ // module graph — so without `strict-dynamic` it is blocked, surfacing as a
+ // render error naming a chunk that is present on disk.
+ expect(html).toContain(`script-src 'nonce-${nonceOf(html)}' 'strict-dynamic'`);
+ // `strict-dynamic` widens what a trusted script may load, never what may be
+ // written into the document.
+ expect(/script-src[^;]*'unsafe-inline'/.test(html)).toBe(false);
+ });
+
+ it('carries the real nonce on every tag Vite marked, and leaves no placeholder', () => {
+ const { html } = getWebviewHtml(webview, mediaPath);
+ const nonce = nonceOf(html);
+
+ // Named rather than derived from a predicate: a test that recomputes the
+ // source's own tag-matching rule agrees with it by construction and would
+ // pass even if that rule were wrong. These are the tags whose fetches
+ // `script-src` gates — the entry, the two preloads for the entry's static
+ // imports, and the meta tag Vite's runtime helper reads before injecting a
+ // preload for a lazy chunk.
+ for (const marker of [
+ 'index-AAAAAAAA.js',
+ 'rolldown-runtime-BBBBBBBB.js',
+ 'alert-ring-watch-CCCCCCCC.js',
+ 'property="csp-nonce"',
+ ]) {
+ const tag = new RegExp(`<[^>]*${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*>`).exec(html)?.[0];
+ expect(tag, `no tag found for ${marker}`).toBeDefined();
+ expect(tag, `un-nonced: ${tag}`).toContain(`nonce="${nonce}"`);
+ }
+
+ // The placeholder is a build artifact; letting one reach a browser would
+ // mean a tag whose nonce matches nothing.
+ expect(html).not.toContain(CSP_NONCE_PLACEHOLDER);
+ });
+
+ it('gives each tag exactly one nonce', () => {
+ const { html } = getWebviewHtml(webview, mediaPath);
+ for (const tag of html.match(/<(?:script|link|meta)\b[^>]*>/g) ?? []) {
+ expect((tag.match(/nonce=/g) ?? []).length, `duplicate nonce: ${tag}`).toBeLessThanOrEqual(1);
+ }
+ });
+
+ it('refuses to serve HTML the build never marked', async () => {
+ // A dropped `html.cspNonce` would otherwise yield a document whose every
+ // script is un-nonced against a nonce-gated policy — a blank panel with no
+ // error outside the webview console.
+ const unmarked = await tempStorageDir();
+ try {
+ await writeFile(
+ join(unmarked, 'index.html'),
+ VITE_INDEX_HTML.replaceAll(CSP_NONCE_PLACEHOLDER, ''),
+ );
+ expect(() => getWebviewHtml(webview, unmarked)).toThrow(/cspNonce/);
+ } finally {
+ await removeDir(unmarked);
+ }
+ });
+
+ it('rewrites asset paths onto the webview URI', () => {
+ const { html } = getWebviewHtml(webview, mediaPath);
+ expect(html).not.toContain('"./assets/');
+ expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/index-AAAAAAAA.js`);
+ expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/rolldown-runtime-BBBBBBBB.js`);
+ });
+
+ it('mints a fresh nonce and message token per document', () => {
+ const first = getWebviewHtml(webview, mediaPath);
+ const second = getWebviewHtml(webview, mediaPath);
+ expect(nonceOf(first.html)).not.toBe(nonceOf(second.html));
+ expect(first.messageToken).not.toBe(second.messageToken);
+ // The two secrets are deliberately distinct: one authorizes script
+ // execution, the other authenticates a message sender.
+ expect(first.messageToken).not.toBe(nonceOf(first.html));
+ });
+});
diff --git a/vscode-ext/vite.config.ts b/vscode-ext/vite.config.ts
index 82d0e196..391d9a0b 100644
--- a/vscode-ext/vite.config.ts
+++ b/vscode-ext/vite.config.ts
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
+import { CSP_NONCE_PLACEHOLDER } from "./src/csp-nonce-placeholder";
/**
* Builds the lib frontend for embedding in the VSCode extension webview.
@@ -9,6 +10,16 @@ import path from "path";
*/
export default defineConfig({
plugins: [react(), tailwindcss()],
+ html: {
+ // Vite stamps this placeholder onto every tag it emits that loads a script
+ // or style, and onto a `` that its own runtime
+ // preload helper reads. `webview-html.ts` swaps it for a fresh per-render
+ // nonce when it serves the document — the placeholder must never reach a
+ // browser. Letting Vite mark the tags keeps nonce coverage tied to the
+ // bundler's own output shape instead of to regexes that have to guess it
+ // (docs/specs/vscode.md → "CSP policy").
+ cspNonce: CSP_NONCE_PLACEHOLDER,
+ },
resolve: {
dedupe: ["react", "react-dom"],
},
diff --git a/vscode-ext/vitest.config.mts b/vscode-ext/vitest.config.mts
index 0bcb7ab7..3ceb6d52 100644
--- a/vscode-ext/vitest.config.mts
+++ b/vscode-ext/vitest.config.mts
@@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config';
/**
* Unit tests for the extension host. The `vscode` module only exists inside a
* running VS Code, so it is aliased to a stub; everything under test either
- * imports it as a type (erased) or goes through `log.ts`.
+ * imports it as a type (erased) or touches the small surface the stub carries
+ * (the `log.ts` output channel, and `Uri.file` for `webview-html.ts`).
*
* Modules that genuinely need the real editor — commands, webview hosting — are
* not covered here and would need `@vscode/test-electron`.