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/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 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