From 1a79e56914b8fd56e59faa5e4305d78912f233f9 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Sun, 9 Aug 2026 14:29:33 +0200 Subject: [PATCH 1/2] fix(egress): let sw/web-fetch await denylist seed hydration on a cold SW start The denylist seed hydrates async at service-worker boot, and webFetch's getDenylist does a synchronous readiness check. A sw/web-fetch request (Notebook module fetch, VM egress) that raced a cold start was refused with DenylistPolicyUnavailableError instead of waiting - the packaged page boot job hit exactly this on main's #370 merge run (the same tree passed on the PR branch and locally). The engine route now awaits an injected awaitDenylistPolicy gate (requireDenylistPolicy(await denylistReady)) before any fetch work, so the race becomes a short wait while a genuinely failed hydration still refuses before any egress. The sync check inside getDenylist stays as the last-resort chokepoint for every other direct caller. Signed-off-by: Jonathan Bursztyn --- extension/background/routes/engine.js | 9 ++++++- extension/background/service-worker.js | 8 +++++- tests/background/routes-engine.test.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/extension/background/routes/engine.js b/extension/background/routes/engine.js index b5bcfa3b..8ebd55b1 100644 --- a/extension/background/routes/engine.js +++ b/extension/background/routes/engine.js @@ -19,7 +19,7 @@ export const makeEngineRoutes = (deps) => { openEnvelope, inspectEnvelope, exportFilename, ArtifactTooLargeError, EnvelopeFormatError, EnvelopeIntegrityError, settingsStore, DWEB_ENABLED, applyWebExtract, withDwebPublication, withAppLifecycle, - listOffscreenContexts, scriptRuns, isOffscreenSender, + listOffscreenContexts, scriptRuns, isOffscreenSender, awaitDenylistPolicy, } = deps; /** @type {Map} */ @@ -52,6 +52,13 @@ export const makeEngineRoutes = (deps) => { if (typeof url !== 'string' || url.length === 0) { return { ok: false, error: 'url-required' }; } + // why: the denylist seed hydrates ASYNC at SW boot, and webFetch's sync + // readiness check refuses a request that merely raced a cold start (the + // packaged-page probe hit exactly this in CI). Waiting for the one-time + // hydration here turns the race into a short delay; a genuinely failed + // load still rejects, so the boundary stays fail-closed. + try { await awaitDenylistPolicy?.(); } + catch (e) { return { ok: false, error: /** @type {{ message?: string }} */ (e)?.message ?? String(e) }; } /** @type {AbortController | null} */ let runController = null; /** @type {AbortSignal | null} */ diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index f3191881..e17cfb72 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -1148,6 +1148,12 @@ const denylistReady = loadDenylist() error: `denylist_hydration_failed: ${error instanceof Error ? error.message : String(error)}`, }; }); +// why: sw/web-fetch (Notebook module fetches, VM egress) can arrive while the +// seed is still hydrating on a cold SW start. This gate lets the engine route +// await the one-time load instead of refusing a request that merely raced +// boot; the sync check inside webFetch's getDenylist stays as the last-resort +// chokepoint for every other direct caller. +const awaitDenylistPolicy = async () => { requireDenylistPolicy(await denylistReady); }; // ── the denylist's NETWORK-level backstop ────────────────────────────────── // @@ -7279,7 +7285,7 @@ browser.runtime.onMessage.addListener(/** @type {any} */ (makeDispatcher({ openEnvelope, inspectEnvelope, exportFilename, ArtifactTooLargeError, EnvelopeFormatError, EnvelopeIntegrityError, settingsStore, DWEB_ENABLED, applyWebExtract, withDwebPublication, withAppLifecycle, - listOffscreenContexts, scriptRuns, isOffscreenSender, + listOffscreenContexts, scriptRuns, isOffscreenSender, awaitDenylistPolicy, }), ...systemMessageRoutes, // denylistNetGuard: an edit changes what the network backstop blocks, so the diff --git a/tests/background/routes-engine.test.ts b/tests/background/routes-engine.test.ts index c3e9d5b7..a30b96ad 100644 --- a/tests/background/routes-engine.test.ts +++ b/tests/background/routes-engine.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'bun:test'; import { makeEngineRoutes } from '../../extension/background/routes/engine.js'; import { listOffscreenContexts } from '../../extension/background/offscreen-contexts.js'; +import { requireDenylistPolicy } from '../../extension/background/denylist-store.js'; class ArtifactTooLargeError extends Error {} class EnvelopeFormatError extends Error {} @@ -80,6 +81,39 @@ describe('sw/web-fetch', () => { expect(res.error).toContain('body too large'); }); + // The denylist seed hydrates async at SW boot. A fetch racing a cold start + // must WAIT for the one-time load (not refuse), and a genuinely failed load + // must still refuse before any egress. The injected gate mirrors the SW's + // composition exactly: requireDenylistPolicy(await denylistReady). + test('a fetch racing seed hydration waits for the load instead of refusing', async () => { + const order: string[] = []; + let releaseHydration: (value: { ok: boolean }) => void = () => {}; + const denylistReady = new Promise<{ ok: boolean }>((resolve) => { releaseHydration = resolve; }); + const r = makeEngineRoutes(baseDeps({ + awaitDenylistPolicy: async () => { requireDenylistPolicy(await denylistReady); }, + vmHttpFetch: async () => { order.push('fetch'); return { ok: true, status: 200, headers: {}, bodyB64: btoa('hello') }; }, + })); + const pending = r['sw/web-fetch']({ url: 'https://x' }); + await Promise.resolve(); + order.push('hydrated'); + releaseHydration({ ok: true }); + const res = await pending; + expect(res.ok).toBe(true); + expect(order).toEqual(['hydrated', 'fetch']); + }); + test('a failed seed hydration refuses the fetch before any egress', async () => { + let fetched = false; + const r = makeEngineRoutes(baseDeps({ + awaitDenylistPolicy: async () => { requireDenylistPolicy(await Promise.resolve({ ok: false })); }, + vmHttpFetch: async () => { fetched = true; return { ok: true }; }, + })); + expect(await r['sw/web-fetch']({ url: 'https://x' })).toEqual({ + ok: false, + error: 'The sensitive-origin policy is unavailable. Tool execution is paused.', + }); + expect(fetched).toBe(false); + }); + // Design 02, 2a: the Notebook tab's code-mode bridge widened this route with // an `extract` post-step (the SAME shared/fetch-extract.js step the headless // host applies locally). The SW composes it as deps.applyWebExtract; the From bef55890402f9133314169721fc5e8429b2bb243 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Sun, 9 Aug 2026 15:14:43 +0200 Subject: [PATCH 2/2] test(firefox): gate the stop probe's abort on fixture-confirmed request arrival The remote-fetch stop probe slept a fixed 150ms after starting the run, then aborted - on a slow runner the abort landed before the resolver had even issued the slow fetch, so the fixture saw 0 requests instead of the in-flight cancel the test pins (CI hit this on the #372 branch run). The fixture now serves a status endpoint reporting how many slow-module requests have arrived, and the probe polls it through the same audited sw/web-fetch relay the resolver uses, aborting only once the request is on the wire. Single injected block (geckodriver gives each script a fresh sandbox, so cross-call state is not an option), and elapsedMs now measures abort to settled - the prompt-stop property - instead of including run setup. Signed-off-by: Jonathan Bursztyn --- scripts/firefox/run-runtime-tests.mjs | 35 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/firefox/run-runtime-tests.mjs b/scripts/firefox/run-runtime-tests.mjs index 8319b1b0..9320fbe7 100644 --- a/scripts/firefox/run-runtime-tests.mjs +++ b/scripts/firefox/run-runtime-tests.mjs @@ -44,6 +44,7 @@ const MODULE_IMPORT_PROBE_PATH = '/__firefox-module-import-probe.js'; const REMOTE_MODULE_ROOT_PATH = '/__firefox-remote-module.js'; const REMOTE_MODULE_CHILD_PATH = '/__firefox-remote-child.js'; const REMOTE_MODULE_SLOW_PATH = '/__firefox-remote-slow.js'; +const REMOTE_MODULE_SLOW_STATUS_PATH = '/__firefox-remote-slow-status.json'; const DNR_PUBLIC_HOST = 'guard.peerd.test'; const DNR_FRAME_HOST = 'frame.peerd.test'; const DNR_FIXTURE_PATH = '/__firefox-dnr-fixture'; @@ -396,6 +397,12 @@ const startProviderServer = async () => { const providerRequestHandler = (request, response) => { const host = String(request.headers.host ?? '').split(':')[0].toLowerCase(); const requestUrl = new URL(request.url ?? '/', `https://${host || 'localhost'}`); + if (host === DNR_PUBLIC_HOST && request.method === 'GET' + && requestUrl.pathname === REMOTE_MODULE_SLOW_STATUS_PATH) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ requests: slowModuleRequests })); + return; + } if (host === DNR_PUBLIC_HOST && request.method === 'GET' && requestUrl.pathname === REMOTE_MODULE_SLOW_PATH) { slowModuleRequests += 1; @@ -3506,31 +3513,49 @@ return { 'Firefox Preview shows the compute-only receipt without generated URLs', JSON.stringify(outcome)); const slowUrl = `https://${DNR_PUBLIC_HOST}:${providerServer.tlsPort}${REMOTE_MODULE_SLOW_PATH}`; + const slowStatusUrl = `https://${DNR_PUBLIC_HOST}:${providerServer.tlsPort}${REMOTE_MODULE_SLOW_STATUS_PATH}`; + // why poll the fixture: a fixed pre-abort sleep raced the resolver on slow + // runners. The abort could land before the slow fetch was even issued (the + // fixture saw 0 requests, not the in-flight cancel this pins). The fixture's + // status endpoint is the arrival signal, read through the same audited + // sw/web-fetch relay the resolver uses, so the abort is only sent once the + // slow request is on the wire. elapsedMs measures abort to settled, the + // prompt-stop property. const stoppedFetch = await driver.executeAsync(` - const [id, url] = arguments; + const [id, url, statusUrl] = arguments; const done = arguments[arguments.length - 1]; (async () => { const browser = (await import('/vendor/browser-polyfill.js')).default; const tab = await browser.tabs.getCurrent(); const runId = 'firefox-remote-fetch-stop'; - const startedAt = Date.now(); const evaluation = browser.tabs.sendMessage(tab.id, { type: 'js/eval', notebookId: id, runId, code: 'import ' + JSON.stringify(url) + '; return false;', timeoutMs: 20_000, }); - await new Promise((resolveWait) => setTimeout(resolveWait, 150)); + let sawRequest = false; + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + const statusResponse = await browser.runtime.sendMessage({ + type: 'sw/web-fetch', url: statusUrl, noCache: true, + }); + const body = statusResponse?.bodyB64 ? JSON.parse(atob(statusResponse.bodyB64)) : null; + if ((body?.requests ?? 0) > 0) { sawRequest = true; break; } + } catch { /* fixture not reachable yet; keep polling */ } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + const abortSentAt = Date.now(); const abort = await browser.tabs.sendMessage(tab.id, { type: 'js/abort', notebookId: id, runId, }); const run = await evaluation; await new Promise((resolveWait) => setTimeout(resolveWait, 300)); done({ - abort, run, elapsedMs: Date.now() - startedAt, + sawRequest, abort, run, elapsedMs: Date.now() - abortSentAt, status: document.getElementById('run-status')?.textContent ?? '', output: document.getElementById('console-output')?.textContent ?? '', }); })().catch((error) => done({ error: error?.message || String(error) })); - `, [notebookId, slowUrl]); + `, [notebookId, slowUrl, slowStatusUrl]); assert(stoppedFetch?.abort?.stopped === true && stoppedFetch?.run?.result?.stopped === true && stoppedFetch.elapsedMs < 3_000