Skip to content
Merged
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
50 changes: 47 additions & 3 deletions extension/background/routes/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>} start
* @param {AbortSignal | null} signal
* @returns {Promise<T>}
*/
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<string, any>} deps
* @returns {Record<string, (msg?: any, sender?: any) => Promise<any>>}
Expand All @@ -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<string, AbortController>} */
const notebookFetchControllers = new Map();
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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);
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
41 changes: 35 additions & 6 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,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;
Expand Down Expand Up @@ -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
Expand Down
94 changes: 92 additions & 2 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 @@ -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' });
Expand All @@ -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
Expand Down Expand Up @@ -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';
Expand All @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions tests/peerd-runtime/tools/script-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading