Skip to content
Open
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
9 changes: 8 additions & 1 deletion extension/background/routes/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AbortController>} */
Expand Down Expand Up @@ -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} */
Expand Down
8 changes: 7 additions & 1 deletion extension/background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────
//
Expand Down Expand Up @@ -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
Expand Down
35 changes: 30 additions & 5 deletions scripts/firefox/run-runtime-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/background/routes-engine.test.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Expand Down Expand Up @@ -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
Expand Down
Loading