diff --git a/extension/background/routes/engine.js b/extension/background/routes/engine.js index b5bcfa3b..74a05335 100644 --- a/extension/background/routes/engine.js +++ b/extension/background/routes/engine.js @@ -6,6 +6,34 @@ // decide whether to un-share over the dweb). Everything here closes over only // stable collaborators. Bodies verbatim, deps injected, imports none. +/** + * Await work through the same cancellation boundary as the operation it gates. + * The underlying one-time hydration may still finish for other callers, but a + * stopped or expired run no longer waits for it or proceeds to egress. + * @template T + * @param {() => Promise} start + * @param {AbortSignal | null} signal + * @returns {Promise} + */ +const awaitWithinSignal = (start, signal) => { + if (!signal) return Promise.resolve().then(start); + if (signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve().then(start).then((value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }); + }); +}; + /** * @param {Record} deps * @returns {Record Promise>} @@ -19,8 +47,11 @@ export const makeEngineRoutes = (deps) => { openEnvelope, inspectEnvelope, exportFilename, ArtifactTooLargeError, EnvelopeFormatError, EnvelopeIntegrityError, settingsStore, DWEB_ENABLED, applyWebExtract, withDwebPublication, withAppLifecycle, - listOffscreenContexts, scriptRuns, isOffscreenSender, + listOffscreenContexts, scriptRuns, isOffscreenSender, awaitDenylistPolicy, } = deps; + if (typeof awaitDenylistPolicy !== 'function') { + throw new TypeError('makeEngineRoutes: awaitDenylistPolicy is required'); + } /** @type {Map} */ const notebookFetchControllers = new Map(); @@ -99,6 +130,15 @@ export const makeEngineRoutes = (deps) => { // vmHttpFetch layers the IDB GET cache + optional git-auth on top; noCache // (module-source fetches) bypasses that cache so every run is re-audited. try { + // why: hydration is part of the egress operation, not a preflight outside + // it. Admit the run and arm Stop/deadline first, then await policy through + // that signal. A stopped or expired run cannot reach vmHttpFetch even if + // the shared hydration later succeeds. + await awaitWithinSignal( + awaitDenylistPolicy, + runController?.signal ?? null, + ); + if (runController?.signal.aborted) return { ok: false, error: 'aborted' }; const resp = await vmHttpFetch({ url, method, headers, body, gitAuth, noCache: noCache === true, ...(runController ? { signal: runController.signal } : {}), @@ -109,8 +149,12 @@ export const makeEngineRoutes = (deps) => { return await applyWebExtract(resp, extract, url); } catch (e) { const ev = /** @type {{ name?: string, message?: string }} */ (e); - return { ok: false, error: ev?.name === 'EgressDeniedError' - ? `denylisted: ${ev.message}` : (ev?.message ?? String(e)) }; + return { ok: false, error: ev?.name === 'AbortError' + ? 'aborted' + : ev?.name === 'DenylistPolicyUnavailableError' + ? 'The sensitive-origin policy is unavailable. Network access is blocked.' + : ev?.name === 'EgressDeniedError' + ? `denylisted: ${ev.message}` : (ev?.message ?? String(e)) }; } finally { if (deadlineTimer) clearTimeout(deadlineTimer); if (sourceSignal && onAbort) sourceSignal.removeEventListener('abort', onAbort); 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..98c06a4c 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,15 @@ 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', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify({ requests: slowModuleRequests })); + return; + } if (host === DNR_PUBLIC_HOST && request.method === 'GET' && requestUrl.pathname === REMOTE_MODULE_SLOW_PATH) { slowModuleRequests += 1; @@ -3506,32 +3516,51 @@ 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]); - assert(stoppedFetch?.abort?.stopped === true + `, [notebookId, slowUrl, slowStatusUrl]); + assert(stoppedFetch?.sawRequest === true + && stoppedFetch?.abort?.stopped === true && stoppedFetch?.run?.result?.stopped === true && stoppedFetch.elapsedMs < 3_000 && providerServer.slowModuleRequests === 1 diff --git a/tests/background/routes-engine.test.ts b/tests/background/routes-engine.test.ts index c3e9d5b7..412b76c9 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 {} @@ -53,10 +54,15 @@ const baseDeps = (over: any = {}) => ({ // The SW always injects the extract post-step (a passthrough when extract is // absent — that contract is pinned in tests/shared/fetch-extract.test.ts). applyWebExtract: async (resp: any) => resp, + awaitDenylistPolicy: async () => {}, ...over, }); describe('sw/web-fetch', () => { + test('denylist hydration is a required route dependency', () => { + expect(() => makeEngineRoutes(baseDeps({ awaitDenylistPolicy: undefined }))) + .toThrow('awaitDenylistPolicy is required'); + }); test('rejects empty url', async () => { const r = makeEngineRoutes(baseDeps()); expect(await r['sw/web-fetch']({ url: '' })).toEqual({ ok: false, error: 'url-required' }); @@ -80,6 +86,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. Network access is blocked.', + }); + 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 @@ -157,12 +196,62 @@ describe('sw/web-fetch', () => { url: 'https://example.com', runId: 'run-1', ownerSessionId: 'owner-1', deadlineAt: Date.now() + 10_000, }, { url: 'offscreen' }); - await Promise.resolve(); + for (let attempt = 0; attempt < 10 && !seenSignal; attempt += 1) await Promise.resolve(); + expect(seenSignal).toBeDefined(); controller.abort(); expect(await pending).toEqual({ ok: false, error: 'aborted' }); expect(seenSignal?.aborted).toBe(true); }); + test('Stop during unresolved denylist hydration admits then exits without egress', async () => { + const controller = new AbortController(); + let hydrationStarted = false; + let fetched = false; + let admissions = 0; + const routes = makeEngineRoutes(baseDeps({ + awaitDenylistPolicy: () => { + hydrationStarted = true; + return new Promise(() => {}); + }, + vmHttpFetch: async () => { fetched = true; return { ok: true }; }, + isOffscreenSender: (sender: any) => sender?.url === 'offscreen', + scriptRuns: { + ownerFor: () => 'owner-1', allows: () => true, + admitOp: () => { admissions += 1; return true; }, + signalFor: () => controller.signal, + }, + })); + const pending = (routes['sw/web-fetch'] as any)({ + url: 'https://example.com', runId: 'run-1', ownerSessionId: 'owner-1', + deadlineAt: Date.now() + 10_000, + }, { url: 'offscreen' }); + await Promise.resolve(); + expect(hydrationStarted).toBe(true); + expect(admissions).toBe(1); + controller.abort(); + expect(await pending).toEqual({ ok: false, error: 'aborted' }); + expect(fetched).toBe(false); + }); + + test('deadline during unresolved denylist hydration exits without egress', async () => { + let fetched = false; + const routes = makeEngineRoutes(baseDeps({ + awaitDenylistPolicy: () => new Promise(() => {}), + vmHttpFetch: async () => { fetched = true; return { ok: true }; }, + isOffscreenSender: (sender: any) => sender?.url === 'offscreen', + scriptRuns: { + ownerFor: () => 'owner-1', allows: () => true, admitOp: () => true, + signalFor: () => new AbortController().signal, + }, + })); + const result = await (routes['sw/web-fetch'] as any)({ + url: 'https://example.com', runId: 'run-1', ownerSessionId: 'owner-1', + deadlineAt: Date.now() + 10, + }, { url: 'offscreen' }); + expect(result).toEqual({ ok: false, error: 'aborted' }); + expect(fetched).toBe(false); + }); + test('a Notebook can cancel only its own token-bound module fetch', async () => { let seenSignal: AbortSignal | undefined; const notebookUrl = 'moz-extension://test/engine-tabs/notebook-tab/index.html#n1'; @@ -186,7 +275,8 @@ describe('sw/web-fetch', () => { url: 'https://modules.example/a.js', noCache: true, abortToken: 'token-1', notebookId: 'n1', }, sender); - await Promise.resolve(); + for (let attempt = 0; attempt < 10 && !seenSignal; attempt += 1) await Promise.resolve(); + expect(seenSignal).toBeDefined(); expect(await (routes['sw/web-fetch-abort'] as any)( { abortToken: 'token-1', notebookId: 'n2' }, { tab: { id: 99, url: 'moz-extension://test/engine-tabs/notebook-tab/index.html#n2' }, diff --git a/tests/peerd-runtime/tools/script-format.test.ts b/tests/peerd-runtime/tools/script-format.test.ts index 5ed5b3e2..3d25930a 100644 --- a/tests/peerd-runtime/tools/script-format.test.ts +++ b/tests/peerd-runtime/tools/script-format.test.ts @@ -277,6 +277,7 @@ describe('scriptTool.execute — workspace opt + value spill', () => { opsFor: () => [], }; const routes = makeEngineRoutes({ + awaitDenylistPolicy: async () => {}, vmHttpFetch: async () => { fetched = true; return { ok: true, status: 200 }; }, applyWebExtract: async (response: any) => response, scriptRuns,