diff --git a/docs/design/portable-identity/README.md b/docs/design/portable-identity/README.md index 112001d7..141e2444 100644 --- a/docs/design/portable-identity/README.md +++ b/docs/design/portable-identity/README.md @@ -24,8 +24,9 @@ The code is the specification: - Fail-loud export, targeted custody RPC, authenticated same-did import, serialized custody reads and changes, and a two-step keep/replace decision when the local did differs or its stored material is unreadable. -- An exact-options backup Port, guarded transfer routes, and a static - single-receiver check keep backup passwords off broadcast messaging. +- A targeted Chrome MessageChannel, an exact-sender Firefox backup Port, + guarded transfer routes, and a static receiver check keep backup passwords + off broadcast messaging. - Structural exclusion of raw identity and device-key secrets from the generic encrypted-secrets section. diff --git a/docs/security/HARDENING-ROADMAP.md b/docs/security/HARDENING-ROADMAP.md index 96be93c6..6ba2636e 100644 --- a/docs/security/HARDENING-ROADMAP.md +++ b/docs/security/HARDENING-ROADMAP.md @@ -22,7 +22,8 @@ relay authority to a live run and its sender. Firefox and interruption paths must fail closed when the required isolation host is unavailable. Current source: `extension/peerd-runtime/actor/`, -`extension/background/offscreen-actor-client.js`, and +`extension/background/offscreen-actor-client.js`, +`extension/background/offscreen-actor-channel-client.js`, and `extension/offscreen/`. ### Browser authority diff --git a/docs/security/RED-TEAM-RESULTS.md b/docs/security/RED-TEAM-RESULTS.md index 50514c22..35e280e4 100644 --- a/docs/security/RED-TEAM-RESULTS.md +++ b/docs/security/RED-TEAM-RESULTS.md @@ -144,10 +144,10 @@ _Generated from the current checkout by the command above._ - Adversary: malicious sandboxed code - Asset: the host origin, the network, and other sandbox instances -- Claim checked: Across all three sandbox kinds, confinement holds: the Notebook realm exposes only the audited fetch bridge (raw channels throw, native fetch unrecoverable, bridge un-unseatable) and no same-origin durable store; the Cache API and IndexedDB both throw, so the sealed extension-origin worker cannot reach the `peerd` database; a remote module restricts its whole run to compute only and all remote-controlled output is fenced; an App cannot break out of its iframe or impersonate the service worker to issue actor commands; and the WebVM HTTP bridge refuses non-http(s) schemes, scrubs CRLF header injection, drops any smuggled auth field, and confirms body-bearing verbs. +- Claim checked: Across all three sandbox kinds, confinement holds: the Notebook realm exposes only the audited fetch bridge (raw channels throw, native fetch unrecoverable, bridge un-unseatable) and no same-origin durable store; the Cache API and IndexedDB both throw, so the sealed extension-origin worker cannot reach the `peerd` database; a remote module restricts its whole run to compute only and all remote-controlled output is fenced; an App cannot break out of its iframe or observe a targeted actor job; and the WebVM HTTP bridge refuses non-http(s) schemes, scrubs CRLF header injection, drops any smuggled auth field, and confirms body-bearing verbs. - Threat-model invariant: INV-6 -- Defenses exercised: applyRealmSeal (raw-channel block + native deletion + bridge pin), resolveRelativePath (OPFS ".." collapse), buildWorkerSource + formatEvalResult (remote graph capability collapse + output fence), composeApp + stripMetaRefresh (App iframe breakout/navigation defense), isServiceWorkerSender (actor-command source pin), normalizeRequest + needsWebWriteConfirm (WebVM bridge scheme/CRLF/auth/confirm) -- Verified in the browser by: `extension/tests/unit/engine-tabs/notebook-tab/notebook-seal.test.js (real worker realm); extension/tests/unit/offscreen/job-runner.test.js (a2a run denied egress + delegation); tests/peerd-engine/module-resolver-toolbox.test.ts (remote-to-local toolbox refusal); tests/engine-tabs/notebook-tab/worker-caps-profile.test.ts (remote whole-run profile); tests/peerd-runtime/tools/remote-import-policy.test.ts (remote output fence); tests/peerd-engine/single-module-linker.test.ts (seal-first graph with no child loads); extension/tests/unit/red-team/sandbox-escape.test.js (in-browser red-team framing); scripts/firefox/run-runtime-tests.mjs (opaque worker host, string-compilation refusal, cancellable compiler and fetch, local and remote graph parity); scripts/cdp/states.mjs actor-command-sender-pin (live engine-tab forgery); scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)` +- Defenses exercised: applyRealmSeal (raw-channel block + native deletion + bridge pin), resolveRelativePath (OPFS ".." collapse), buildWorkerSource + formatEvalResult (remote graph capability collapse + output fence), composeApp + stripMetaRefresh (App iframe breakout/navigation defense), makeOffscreenActorChannelClient (exact-client channel transfer), normalizeRequest + needsWebWriteConfirm (WebVM bridge scheme/CRLF/auth/confirm) +- Verified in the browser by: `extension/tests/unit/engine-tabs/notebook-tab/notebook-seal.test.js (real worker realm); extension/tests/unit/offscreen/job-runner.test.js (a2a run denied egress + delegation); tests/peerd-engine/module-resolver-toolbox.test.ts (remote-to-local toolbox refusal); tests/engine-tabs/notebook-tab/worker-caps-profile.test.ts (remote whole-run profile); tests/peerd-runtime/tools/remote-import-policy.test.ts (remote output fence); tests/peerd-engine/single-module-linker.test.ts (seal-first graph with no child loads); extension/tests/unit/red-team/sandbox-escape.test.js (in-browser red-team framing); scripts/firefox/run-runtime-tests.mjs (opaque worker host, string-compilation refusal, cancellable compiler and fetch, local and remote graph parity); scripts/cdp/states.mjs actor-channel-targeting (live sibling-observer probe); scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)` | Probe (adversary action) | Result | Evidence | |--------------------------|--------|----------| @@ -169,7 +169,7 @@ _Generated from the current checkout by the command above._ | break the model trust fence through remote return, console, or error text | blocked | host status stays outside a neutralized untrusted envelope | | embed in an inlined App worker to break out of the shim | blocked | worker source `<` escaped to \u003c, no executable breakout tag | | meta-refresh the App frame to an attacker URL | blocked | meta http-equiv=refresh stripped from the app HTML | -| replay a broadcast actor command from a first-party engine tab | blocked | exact service-worker source accepted; same-extension tab provenance rejected | +| observe an actor job from a first-party engine tab | blocked | the targeted channel offer carries no job or authority; the job moves only over the transferred endpoint | | WebVM requests file:// / chrome:// to read local resources | blocked | normalizeRequest throws RangeError on non-http(s)/peerd:// schemes | | CRLF-inject a second header through a WebVM request | blocked | CR/LF scrubbed from the header value ("aInjected: 1") | | smuggle an auth field on the WebVM wire to attach the git token | blocked | normalizeRequest drops the auth field, only host control ops set credentials | diff --git a/docs/security/THREAT-MODEL.md b/docs/security/THREAT-MODEL.md index bb41bdcc..46f739c0 100644 --- a/docs/security/THREAT-MODEL.md +++ b/docs/security/THREAT-MODEL.md @@ -96,7 +96,10 @@ holds both untrusted input and dangerous capability. Enforcement lives in turn before any target action. - B2. An actor loop and the network or the key. Model and tool calls leave the worker only through privileged, gated relays. The host adds live provider - functions only at the model-call boundary and re-checks every tool call. + functions only at the model-call boundary and re-checks every tool call. On + Chrome, the service worker transfers a standard MessageChannel endpoint to the + exact offscreen WindowClient. The job and relays never use extension-wide + runtime messaging. Firefox binds the same relays to its private in-process host. - B3. The extension and the open web. All outbound bytes pass through `peerd-egress/fetch/`: `safeFetch` (exact-origin provider allowlist, carries the key) or `webFetch` (SSRF and private-network block plus denylist, keyless). @@ -111,6 +114,11 @@ holds both untrusted input and dangerous capability. Enforcement lives in (`manifests/`, generated `extension/manifest.json`). - B7. The user and the agent. Side-effecting actions pass through a confirm gate. The vault requires an explicit unlock. Skills and imports require a click. + Backup and restore plaintext uses an exact options-page transport. Chrome + transfers a MessageChannel to one WindowClient. Firefox accepts a private + background Port only from the exact options sender. The ordinary runtime + message dispatcher never carries passphrases or backup payloads + (`background/private-transfer-port.js`, `options/private-transfer-session.js`). Out of the model entirely (see section 7): a compromised OS or browser, a malicious separate extension, and physical device access. @@ -272,7 +280,8 @@ relay because `makeRelayedCallModel` drops them. Every untrusted summary re-ente orchestrator wrapped as data (`makeActorSummaryFence` and `wrapUntrusted`) with a delimiter the content cannot forge (`neutralizeFence`). Code: `peerd-runtime/actor/actor-worker-core.js`, -`background/offscreen-actor-client.js`, `background/direct-actor-host.js`, +`background/offscreen-actor-client.js`, `background/offscreen-actor-channel-client.js`, +`background/direct-actor-host.js`, `offscreen/actor-channel-host.js`, `offscreen/actor-runner.js`, `offscreen/actor-worker-protocol.js`, and `tools/prompt-wrap.js`. The browser custody test proves an actor cannot enter the privileged turn driver. The installed-XPI Firefox smoke proves the packaged extension diff --git a/extension/background/actor-startup-retry.js b/extension/background/actor-startup-retry.js new file mode 100644 index 00000000..9e7222a6 --- /dev/null +++ b/extension/background/actor-startup-retry.js @@ -0,0 +1,33 @@ +// @ts-check +// Pure retry shell for actor failures that prove no work began. + +import { isKnownActorStartupFailure } from './offscreen-actor-channel-client.js'; + +const LEGACY_ACTOR_HOST_STARTUP_CODES = new Set([ + 'actor_host_unavailable', + 'actor_host_not_ready', + 'actor_host_keepalive_failed', + 'actor_worker_spawn_failed', + 'actor_worker_start_timeout', + 'actor_worker_crashed', + 'actor_worker_message_error', + 'actor_worker_protocol_error', +]); + +export const isActorHostStartupFailure = (/** @type {any} */ result) => + isKnownActorStartupFailure(result) + || (result?.started === false && LEGACY_ACTOR_HOST_STARTUP_CODES.has(result?.code)); + +/** + * @param {Object} deps + * @param {() => Promise} deps.run + * @param {(result: any) => boolean} deps.isStartupFailure + * @param {AbortSignal} [deps.signal] + */ +export const runActorWithStartupRetry = async ({ run, isStartupFailure, signal }) => { + let result = await run(); + if (!isStartupFailure(result)) return { result, exhausted: false }; + if (signal?.aborted) return { result: { ...result, aborted: true }, exhausted: false }; + result = await run(); + return { result, exhausted: isStartupFailure(result) }; +}; diff --git a/extension/background/offscreen-actor-channel-client.js b/extension/background/offscreen-actor-channel-client.js new file mode 100644 index 00000000..fe716cff --- /dev/null +++ b/extension/background/offscreen-actor-channel-client.js @@ -0,0 +1,222 @@ +// @ts-check +// Targeted Chrome transport for isolated actor runs. +// +// why not runtime.connect: Chrome permits one Port to have multiple receivers. +// The service worker instead finds the exact offscreen WindowClient and +// transfers one standard MessageChannel endpoint directly to that document. + +import { ACTOR_CHANNEL_OFFER, ACTOR_CHANNEL_PROTOCOL } from '../shared/actor-channel-protocol.js'; + +export class ActorChannelError extends Error { + /** @param {string} message @param {string} code @param {{ cause?: unknown }} [options] */ + constructor(message, code, options = {}) { + super(message, options); + this.name = 'ActorChannelError'; + this.code = code; + } +} + +const abortedResult = () => ({ + ok: false, started: false, phase: 'startup', code: 'actor_run_aborted', + error: 'actor run aborted', aborted: true, outcomeKnown: true, +}); + +/** A structured failure that is safe to retry because no actor work began. */ +export const isKnownActorStartupFailure = (/** @type {any} */ value) => value?.started === false + && value?.phase === 'startup' && value?.outcomeKnown === true; + +/** + * Fail closed when the exact offscreen recipient is absent or ambiguous. + * @template {{ url?: string }} T + * @param {T[]} candidates + * @param {string} expectedUrl + * @returns {T | null} + */ +export const selectExactActorHostClient = ( + candidates, + expectedUrl, +) => { + const exact = candidates.filter((client) => client.url === expectedUrl); + return exact.length === 1 ? exact[0] : null; +}; + +/** + * @param {Object} deps + * @param {() => Promise} deps.ensureOffscreen + * @param {() => Promise<{ postMessage: (message: any, transfer: Transferable[]) => void } | null>} deps.findOffscreenClient + * @param {() => MessageChannel} [deps.createChannel] + * @param {() => string} [deps.newChannelId] + * @param {number} [deps.handshakeTimeoutMs] + * @param {number} [deps.abortTimeoutMs] + * @param {(job: any) => number} [deps.runTimeoutMsFor] + * @param {typeof setTimeout} [deps.setTimeoutFn] + * @param {typeof clearTimeout} [deps.clearTimeoutFn] + */ +export const makeOffscreenActorChannelClient = ({ + ensureOffscreen, + findOffscreenClient, + createChannel = () => new MessageChannel(), + newChannelId = () => crypto.randomUUID(), + handshakeTimeoutMs = 10_000, + abortTimeoutMs = 5_000, + runTimeoutMsFor = (job) => Number.isFinite(job?.budgetMs) && job.budgetMs > 0 + ? job.budgetMs + abortTimeoutMs + : 30 * 60_000, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, +}) => { + /** + * @param {any} job + * @param {{ signal?: AbortSignal, relay: (type: string, payload: any) => any|Promise }} options + */ + const run = async (job, { signal, relay }) => { + if (signal?.aborted) return abortedResult(); + try { await ensureOffscreen(); } + catch (cause) { + return { + ok: false, started: false, phase: 'startup', code: 'actor_host_unavailable', + error: `actor host unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, + outcomeKnown: true, + }; + } + if (signal?.aborted) return abortedResult(); + const target = await findOffscreenClient().catch(() => null); + if (!target) return { + ok: false, started: false, phase: 'startup', code: 'actor_host_not_ready', + error: 'actor host is not available', outcomeKnown: true, + }; + + const channelId = newChannelId(); + const { port1, port2 } = createChannel(); + /** @type {Map>} */ + const relayReplies = new Map(); + let state = /** @type {'offered'|'ready'|'opened'|'accepted'|'committed'|'settled'} */ ('offered'); + let settle = (/** @type {any} */ _value) => {}; + let handshakeTimer = /** @type {ReturnType | null} */ (null); + let abortTimer = /** @type {ReturnType | null} */ (null); + let runTimer = /** @type {ReturnType | null} */ (null); + + const result = new Promise((resolve) => { settle = resolve; }); + const finish = (/** @type {any} */ value) => { + if (state === 'settled') return; + state = 'settled'; + if (handshakeTimer) clearTimeoutFn(handshakeTimer); + if (abortTimer) clearTimeoutFn(abortTimer); + if (runTimer) clearTimeoutFn(runTimer); + signal?.removeEventListener('abort', onAbort); + try { port1.close(); } catch { /* already closed */ } + settle(value); + }; + const armHandshakeTimeout = (/** @type {string} */ phase) => { + if (handshakeTimer) clearTimeoutFn(handshakeTimer); + handshakeTimer = setTimeoutFn(() => finish({ + ok: false, + started: state === 'committed', + phase: state === 'committed' ? 'run' : 'startup', + code: state === 'committed' ? 'actor_channel_lost' : `actor_channel_${phase}_timeout`, + error: `actor channel ${phase} timed out`, + outcomeKnown: state !== 'committed', + }), handshakeTimeoutMs); + }; + const post = (/** @type {Record} */ message) => port1.postMessage({ + protocol: ACTOR_CHANNEL_PROTOCOL, channelId, ...message, + }); + const onAbort = () => { + if (state === 'settled') return; + if (state !== 'committed') { + try { post({ type: 'actor/abort' }); } catch { /* not started */ } + finish(abortedResult()); + return; + } + try { post({ type: 'actor/abort' }); } catch { /* watchdog settles */ } + abortTimer = setTimeoutFn(() => finish({ + ok: false, started: true, phase: 'run', code: 'actor_abort_unacknowledged', + error: 'actor host did not acknowledge cancellation', aborted: true, + outcomeKnown: false, + }), abortTimeoutMs); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + + port1.onmessage = (event) => { + const message = event.data; + if (!message || message.protocol !== ACTOR_CHANNEL_PROTOCOL + || message.channelId !== channelId || state === 'settled') return; + if (message.type === 'channel/ready' && state === 'offered') { + state = 'ready'; + if (signal?.aborted) { onAbort(); return; } + post({ type: 'actor/open', job }); + state = 'opened'; + armHandshakeTimeout('accept'); + return; + } + if (message.type === 'actor/accepted' && state === 'opened') { + state = 'accepted'; + if (signal?.aborted) { onAbort(); return; } + post({ type: 'actor/commit' }); + state = 'committed'; + if (handshakeTimer) { clearTimeoutFn(handshakeTimer); handshakeTimer = null; } + runTimer = setTimeoutFn(() => { + try { post({ type: 'actor/abort' }); } catch { /* host is gone */ } + finish({ + ok: false, started: true, phase: 'run', code: 'actor_channel_run_timeout', + error: 'actor channel exceeded its run budget', outcomeKnown: false, + }); + }, Math.max(1, runTimeoutMsFor(job))); + return; + } + if (message.type === 'actor/result' && state === 'committed') { + finish(message.result ?? { + ok: false, started: true, error: 'actor host returned no result', outcomeKnown: false, + }); + return; + } + if (message.type !== 'actor/relay' || state !== 'committed' + || typeof message.requestId !== 'string' + || typeof message.relayType !== 'string') return; + // A duplicate request shares the first dispatch promise. Even though the + // MessageChannel is private, transport retries must never repeat a tool. + let pending = relayReplies.get(message.requestId); + if (!pending) { + pending = Promise.resolve(relay(message.relayType, message.payload ?? {})) + .catch((cause) => ({ + ok: false, error: cause instanceof Error ? cause.message : String(cause), + })); + relayReplies.set(message.requestId, pending); + } + pending.then((reply) => { + if (state !== 'committed') return; + try { post({ type: 'actor/relay-response', requestId: message.requestId, result: reply }); } + catch { /* run timeout or restart owns settlement */ } + }); + }; + port1.onmessageerror = () => finish({ + ok: false, started: state === 'committed', + phase: state === 'committed' ? 'run' : 'startup', + code: 'actor_channel_message_error', error: 'actor channel received an invalid message', + outcomeKnown: state !== 'committed', + }); + port1.addEventListener('close', () => finish({ + ok: false, started: state === 'committed', + phase: state === 'committed' ? 'run' : 'startup', + code: 'actor_channel_closed', error: 'actor channel closed before settlement', + outcomeKnown: state !== 'committed', + }), { once: true }); + try { + target.postMessage({ + type: ACTOR_CHANNEL_OFFER, protocol: ACTOR_CHANNEL_PROTOCOL, channelId, + }, [port2]); + port1.start(); + armHandshakeTimeout('ready'); + } catch (cause) { + finish({ + ok: false, started: false, phase: 'startup', code: 'actor_channel_offer_failed', + error: `actor channel could not be offered: ${cause instanceof Error ? cause.message : String(cause)}`, + outcomeKnown: true, + }); + } + if (signal?.aborted) onAbort(); + return result; + }; + + return { run }; +}; diff --git a/extension/background/offscreen-actor-client.js b/extension/background/offscreen-actor-client.js index 94904064..7559897e 100644 --- a/extension/background/offscreen-actor-client.js +++ b/extension/background/offscreen-actor-client.js @@ -24,6 +24,7 @@ * @param {() => Promise} [deps.ensureHost] * @param {() => Promise} [deps.ensureOffscreen] legacy Chrome host alias * @param {(msg: object) => Promise} deps.sendMessage + * @param {(job: object, options: { signal?: AbortSignal, relay: (type: string, payload: any) => any|Promise }) => Promise} [deps.runOnChannel] * @param {(args: object) => AsyncIterable} deps.callModel * @param {(name: string) => Promise} deps.getSecret * @param {(resource: string | URL | Request, init?: RequestInit) => Promise} deps.safeFetch @@ -73,7 +74,7 @@ * supplied from the runtime capability manifest; omission fails closed. */ export const makeOffscreenActorClient = ({ - ensureHost, ensureOffscreen, sendMessage, callModel, getSecret, safeFetch, + ensureHost, ensureOffscreen, sendMessage, runOnChannel, callModel, getSecret, safeFetch, sessions, buildToolContext, dispatchToolCall, pinActorCall, restrictCtxCapabilities, ownedTabFor, EXPOSURE_ACTOR, EXPOSURE_REVIEW, now = Date.now, reviewToolAllowed = () => false, recordModelCall = () => {}, @@ -88,15 +89,12 @@ export const makeOffscreenActorClient = ({ const inboundDwebTools = new Set(inboundDwebToolNames); let seq = 0; /** - * @type {Map | null, actorSurface?: 'tools'|'code', relaySignal: AbortSignal }>} relay grants: + * @type {Map | null, actorSurface?: 'tools'|'code', relaySignal: AbortSignal }>} Firefox relay grants: * token → the identity of the run it was minted for. * - * why a grant and not the message's own `actorSessionId`/`runId`: these three routes - * ride the ONE runtime.onMessage surface, whose only guard is `isFirstPartySender` — - * "some extension context of ours", which is every engine tab page and the side panel, - * not "the offscreen doc running this actor". Trusting the payload's identity therefore - * let ANY first-party page dispatch a tool as an arbitrary actor session (inheriting - * that actor's instance pin and granted tools) or spend the user's key on a dead run. + * why a grant and not the message's own `actorSessionId`/`runId`: Firefox binds + * these routes to its private in-process actor host, but the relay still needs a + * run identity and liveness check that does not trust worker-controlled payloads. * * The token is minted SW-side per run, travels in the job to the offscreen runner, and * comes back on every relay call; identity is DERIVED from it, so the payload's claim @@ -107,13 +105,10 @@ export const makeOffscreenActorClient = ({ * The Worker never receives the token (the runner holds it and stamps it on outbound * relays), so it stays a host-side binding, not a secret the untrusted heap can leak. * - * The token is NOT a secret from other extension pages, though, and must not be treated - * as one: `runtime.sendMessage` has no way to address a single extension context, so the - * `actor/run` job — token included — is broadcast to every listener in the extension, - * including the engine tab pages named above. That is why every route ALSO requires - * `isOffscreenSender`. The sender check is the boundary; the token adds run identity and - * liveness on top of it. Either alone is insufficient: the sender check cannot say WHICH - * run is speaking, and the token cannot say who is holding it. + * Chrome does not serialize this token. Its service worker transfers a standard + * MessageChannel endpoint to the exact offscreen WindowClient and closes the live + * grant over that private channel. The offscreen job and its relays are never + * registered on extension-wide runtime messaging. */ const grants = new Map(); /** @type {Map>} runId → in-flight model-call controllers */ @@ -171,7 +166,7 @@ export const makeOffscreenActorClient = ({ const allowedTools = inbound ? new Set((tools ?? []).map((tool) => tool?.name).filter((name) => typeof name === 'string')) : null; - grants.set(relayToken, { + const grant = { runId, actorSessionId: job.actorSessionId, provider: job.provider, model: job.model, inbound, allowedTools, relaySignal: relayController.signal, @@ -179,7 +174,10 @@ export const makeOffscreenActorClient = ({ ...(job.actorSurface === 'code' || job.actorSurface === 'tools' ? { actorSurface: job.actorSurface } : {}), - }); + }; + // Firefox's direct in-process host uses the private token map. Chrome + // binds the grant directly to one transferred MessageChannel closure. + if (!runOnChannel) grants.set(relayToken, grant); if (onEvent) runOnEvent.set(runId, onEvent); runMeta.set(runId, { sessionId: job.actorSessionId, @@ -192,12 +190,24 @@ export const makeOffscreenActorClient = ({ }; const abortRun = () => { abortRelays(); - sendMessage({ type: 'actor/abort', runId }).catch(() => {}); + if (!runOnChannel) sendMessage({ type: 'actor/abort', runId }).catch(() => {}); }; if (signal && !signal.aborted) signal.addEventListener('abort', abortRun, { once: true }); else if (signal?.aborted) abortRun(); try { - const result = await sendMessage({ type: 'actor/run', job: { ...job, inbound, tools, runId, relayToken } }); + const result = runOnChannel + ? await runOnChannel( + { ...job, inbound, tools, runId }, + { + signal, + relay: (type, payload) => { + const route = /** @type {Record} */ (routes)[type]; + if (!route) return { ok: false, error: `unknown actor relay: ${type}` }; + return route(payload, undefined, grant); + }, + }, + ) + : await sendMessage({ type: 'actor/run', job: { ...job, inbound, tools, runId, relayToken } }); // Stop / cancel cascade: `signal.aborted` HERE is the authoritative proof a Stop // hit THIS run — and the one place it's reliably observable. The worker unwinds an // abort several ways (a rejected relay, a model-error from the SW route, or the @@ -221,7 +231,7 @@ export const makeOffscreenActorClient = ({ for (const ac of inflight.get(runId) ?? []) { try { ac.abort(); } catch { /* already */ } } // Retiring the grant is what makes it a liveness check: every relay for // this run is refused from here on, so a late/replayed one can't dispatch. - grants.delete(relayToken); + if (!runOnChannel) grants.delete(relayToken); runOnEvent.delete(runId); runMeta.delete(runId); inflight.delete(runId); @@ -230,15 +240,15 @@ export const makeOffscreenActorClient = ({ }; /** - * Resolve a relay's identity: the sender must be the offscreen document AND the - * message must carry a live grant token. Returns null for anything else — a - * non-offscreen page, a missing/unknown/retired token — and every route treats - * that as a hard refusal, so an unauthorized caller learns nothing beyond "no". + * Resolve a relay's identity. Chrome passes the grant through a private channel + * closure. Firefox requires its private host sender identity and a live grant + * token. Every route treats a missing or retired grant as a hard refusal. * @param {{ relayToken?: unknown }} [msg] * @param {unknown} [sender] the second argument makeDispatcher hands a handler * @returns {{ runId: string, actorSessionId: string, provider: string, model: string, ollamaHost?: string, inbound: boolean, allowedTools: Set | null, actorSurface?: 'tools'|'code', relaySignal: AbortSignal } | null} */ - const grantFor = (msg, sender) => { + const grantFor = (msg, sender, boundGrant = null) => { + if (boundGrant) return boundGrant; if (!relaySenderAllowed(sender)) return null; const token = msg?.relayToken; if (typeof token !== 'string' || token.length === 0) return null; @@ -250,11 +260,11 @@ export const makeOffscreenActorClient = ({ * @param {{ relayToken?: string, args?: object }} [msg] - the model call: key+egress added HERE. * @param {unknown} [sender] - must be the offscreen document (see grantFor). */ - 'actor/model-call': async (msg = {}, sender = undefined) => { + 'actor/model-call': async (msg = {}, sender = undefined, boundGrant = null) => { // Identity from the offscreen sender + the grant, never from the message: // this route adds the user's key to whatever it forwards, so an // unauthorized caller must not be able to name a run at all. - const grant = grantFor(msg, sender); + const grant = grantFor(msg, sender, boundGrant); if (!grant) return { ok: false, error: 'actor/model-call: unauthorized relay' }; const { runId } = grant; const args = msg.args; @@ -308,12 +318,12 @@ export const makeOffscreenActorClient = ({ * @param {{ relayToken?: string, call?: any }} [msg] - SW-side ctx build + gate + dispatch. * @param {unknown} [sender] - must be the offscreen document (see grantFor). */ - 'actor/tool-dispatch': async (msg = {}, sender = undefined) => { + 'actor/tool-dispatch': async (msg = {}, sender = undefined, boundGrant = null) => { try { // The session comes from the offscreen-pinned GRANT, not the message. This is // the route that builds an instance-pinned, tool-granting context, so a caller // that could name its own session would inherit any actor's pin and toolset. - const grant = grantFor(msg, sender); + const grant = grantFor(msg, sender, boundGrant); if (!grant) return { ok: false, error: 'actor/tool-dispatch: unauthorized relay' }; if (grant.relaySignal.aborted) return { ok: false, error: 'aborted' }; const { actorSessionId } = grant; @@ -440,11 +450,11 @@ export const makeOffscreenActorClient = ({ * @param {{ relayToken?: string, event?: object }} [msg] * @param {unknown} [sender] - must be the offscreen document (see grantFor). */ - 'actor/loop-event': (msg = {}, sender = undefined) => { + 'actor/loop-event': (msg = {}, sender = undefined, boundGrant = null) => { // Lowest-authority of the three (it only feeds the actor card + cost meter), // but bound the same way: an unauthorized sender could otherwise inject // fabricated progress/cost events into another run's UI. - const grant = grantFor(msg, sender); + const grant = grantFor(msg, sender, boundGrant); if (!grant) return { ok: false, error: 'actor/loop-event: unauthorized relay' }; if (grant.relaySignal.aborted) return { ok: false, error: 'aborted' }; try { if (msg.event) runOnEvent.get(grant.runId)?.(msg.event); } catch { /* never break the relay */ } diff --git a/extension/background/private-transfer-port.js b/extension/background/private-transfer-port.js index af93922f..33793a24 100644 --- a/extension/background/private-transfer-port.js +++ b/extension/background/private-transfer-port.js @@ -1,45 +1,112 @@ // @ts-check -// Dedicated options-page RPC for backup and restore requests. -// -// why a Port plus a static exclusivity test: runtime.sendMessage broadcasts the -// passphrase and import payload to every extension listener. The shipped source -// registers onConnect only in the service worker, so this named connection has -// one receiver and the exact options-page sender is verified before attachment. +// Dedicated options-page RPC for backup and restore requests. The service +// worker creates a standard MessageChannel only after an exact options-page +// request, then transfers it directly to that WindowClient. Firefox has no +// service-worker WindowClient API, so its exact options sender uses the private +// background-page Port fallback. const PRIVATE_TRANSFER_TYPES = new Set([ 'transfer/export', 'transfer/inspectImport', 'transfer/import', ]); +const isOptionsClientUrl = (/** @type {string | undefined} */ candidate, /** @type {string} */ expected) => { + try { + const current = new URL(candidate ?? ''); + const target = new URL(expected); + return current.origin === target.origin + && current.pathname === target.pathname + && current.search === target.search; + } catch { return false; } +}; + /** * @param {Object} deps * @param {Record Promise>} deps.handlers * @param {symbol} deps.authorization */ -export const makePrivateTransferPort = ({ handlers, authorization }) => ({ - /** @param {import('webextension-polyfill').Runtime.Port} port */ - attach(port) { - port.onMessage.addListener((/** @type {any} */ request) => { - if (request?.type !== 'private-transfer/request' - || typeof request.requestId !== 'string' - || !request.message || typeof request.message !== 'object' - || !PRIVATE_TRANSFER_TYPES.has(request.message.type)) return; - const handler = handlers[request.message.type]; - if (typeof handler !== 'function') return; - const respond = (/** @type {any} */ response) => { - try { port.postMessage(response); } catch { /* page observes disconnect */ } - }; - Promise.resolve(handler({ - ...request.message, privateTransferAuthorization: authorization, - })) - .then((reply) => respond({ - type: 'private-transfer/response', requestId: request.requestId, - ok: true, reply, +export const makePrivateTransferPort = ({ handlers, authorization }) => { + /** @type {Set} */ + const activePorts = new Set(); + return { + /** @param {MessagePort | import('webextension-polyfill').Runtime.Port} port */ + attach(port) { + // A transferred MessagePort needs a host-owned reference for the whole + // options session. Event-handler self-cycles are collectible. + activePorts.add(port); + if ('onDisconnect' in port) { + port.onDisconnect.addListener(() => activePorts.delete(port)); + } else { + port.addEventListener('close', () => activePorts.delete(port), { once: true }); + } + const receive = (/** @type {any} */ request) => { + if (request?.type !== 'private-transfer/request' + || typeof request.requestId !== 'string' + || !request.message || typeof request.message !== 'object' + || !PRIVATE_TRANSFER_TYPES.has(request.message.type)) return; + const handler = handlers[request.message.type]; + if (typeof handler !== 'function') return; + const respond = (/** @type {any} */ response) => { + try { port.postMessage(response); } catch { /* page observes disconnect */ } + }; + Promise.resolve(handler({ + ...request.message, privateTransferAuthorization: authorization, })) - .catch((cause) => respond({ - type: 'private-transfer/response', requestId: request.requestId, - ok: false, - error: /** @type {{ message?: string }} */ (cause)?.message ?? 'transfer-failed', - })); - }); - }, -}); + .then((reply) => respond({ + type: 'private-transfer/response', requestId: request.requestId, + ok: true, reply, + })) + .catch((cause) => respond({ + type: 'private-transfer/response', requestId: request.requestId, + ok: false, + error: /** @type {{ message?: string }} */ (cause)?.message ?? 'transfer-failed', + })); + }; + if ('onMessage' in port) port.onMessage.addListener(receive); + else { + port.onmessage = (event) => receive(event.data); + port.start(); + port.postMessage({ type: 'private-transfer/ready' }); + } + }, + }; +}; + +/** + * @param {Object} deps + * @param {(sender: unknown) => boolean} deps.isOptionsSender + * @param {() => Promise void }>>} deps.listWindowClients + * @param {string} deps.optionsUrl + * @param {(port: MessagePort) => void} deps.attach + * @param {() => MessageChannel} [deps.createChannel] + */ +export const makePrivateTransferOpenRoute = ({ + isOptionsSender, listWindowClients, optionsUrl, attach, + createChannel = () => new MessageChannel(), +}) => async ( + /** @type {{ requestId?: unknown }} */ message, + /** @type {({ documentId?: unknown } & Record) | null | undefined} */ sender, +) => { + if (!isOptionsSender(sender) + || typeof message.requestId !== 'string' || message.requestId.length === 0) { + return { ok: false, error: 'private-transfer-channel-refused' }; + } + // WindowClient ids are not documented as MessageSender documentIds. Require + // one exact options document instead of turning that coincidence into trust. + // Hash routes identify panes within the same page and are intentionally ignored. + const exact = (await listWindowClients()) + .filter((client) => isOptionsClientUrl(client.url, optionsUrl)); + const target = exact.length === 1 ? exact[0] : null; + if (!target) return { ok: false, error: 'private-transfer-channel-target-missing' }; + const channel = createChannel(); + attach(channel.port1); + try { + target.postMessage({ + type: 'private-transfer/channel', requestId: message.requestId, + }, [channel.port2]); + return { ok: true }; + } catch { + channel.port1.close(); + channel.port2.close(); + return { ok: false, error: 'private-transfer-channel-offer-failed' }; + } +}; diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index 4e95bb3d..f3191881 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -372,6 +372,12 @@ import { createScriptRunRegistry } from './script-runs.js'; import { createContextSnapshots } from './context-snapshots.js'; import { confirmGrantKey } from './confirm-grant-key.js'; import { makeOffscreenActorClient } from './offscreen-actor-client.js'; +import { + makeOffscreenActorChannelClient, selectExactActorHostClient, +} from './offscreen-actor-channel-client.js'; +import { + isActorHostStartupFailure, runActorWithStartupRetry, +} from './actor-startup-retry.js'; import { makeDirectActorHost, makeStorageSessionKeepAlive } from './direct-actor-host.js'; import { makeOffscreenPdfClient } from './offscreen-pdf-client.js'; import { makeOffscreenDocClient } from './offscreen-doc-client.js'; @@ -443,7 +449,7 @@ import { makeDwebCustodyClient, makeRetryableCustodyReset } from './dweb-custody import { identityChangeBlockedByApps, makeDwebIdentityCustody, } from './dweb-identity-custody.js'; -import { makePrivateTransferPort } from './private-transfer-port.js'; +import { makePrivateTransferOpenRoute, makePrivateTransferPort } from './private-transfer-port.js'; import { downgradesActorConfirm, a2aConsentOutcome } from './a2a-consent.js'; import { makeVaultRoutes } from './routes/vault.js'; import { makeProviderRoutes } from './routes/providers.js'; @@ -3559,9 +3565,25 @@ notifyActorHostKeepAliveLost = (error) => { // and dispatches there — the worker holds no // key, no engine clients, no browser extension APIs. Null only when no // dedicated-worker host exists. +const actorChannelClient = offscreenAvailable ? makeOffscreenActorChannelClient({ + ensureOffscreen, + findOffscreenClient: async () => { + const clientsApi = /** @type {any} */ (globalThis).clients; + if (!clientsApi?.matchAll) return null; + const candidates = /** @type {Array<{ + * url: string, + * postMessage: (message: any, transfer: Transferable[]) => void, + * }>} */ (await clientsApi.matchAll({ type: 'window', includeUncontrolled: true })); + const exactUrl = browser.runtime.getURL('offscreen/offscreen.html'); + // why: a duplicate exact-URL page makes the recipient ambiguous. Failing + // closed prevents a user-opened sibling from receiving an actor channel. + return selectExactActorHostClient(candidates, exactUrl); + }, +}) : null; const actorClient = actorIsolationAvailable(baseActorIsolation) ? makeOffscreenActorClient({ ensureHost: offscreenAvailable ? ensureOffscreen : async () => {}, sendMessage: directActorHost?.sendMessage ?? ((m) => browser.runtime.sendMessage(m)), + runOnChannel: actorChannelClient?.run, callModel: /** @type {any} */ (callModel), getSecret, safeFetch, @@ -3591,12 +3613,9 @@ const actorClient = actorIsolationAvailable(baseActorIsolation) ? makeOffscreenA // eval harness's OM2W recorder and any // activity view are blind to what an actor actually did. broadcastOp: (/** @type {any} */ msg) => uiPorts.broadcast(msg), - // The relay boundary. `runtime.sendMessage` cannot address one extension - // context, so the actor/run job (grant token included) is broadcast to every - // listener, including the side panel and the engine tab pages. Pinning the - // three relay routes to the offscreen document is therefore what actually - // keeps another first-party page from dispatching as an actor; the token - // carries run identity and liveness on top of it. + // Firefox's direct host binds relays behind an unforgeable object identity. + // Chrome binds them to the exact offscreen WindowClient through a transferred + // MessageChannel and does not register these routes on runtime messaging. isRelaySender: directActorHost?.isRelaySender ?? isOffscreenSender, inboundDwebToolNames: DWEB_INBOUND_TOOL_NAMES, // Spend-limit preflight for the actor lane. Two tallies, because they fail in @@ -3626,17 +3645,6 @@ const actorClient = actorIsolationAvailable(baseActorIsolation) ? makeOffscreenA }) : null; directActorHost?.bindRelayRoutes(actorClient?.routes ?? {}); -const ACTOR_HOST_STARTUP_CODES = new Set([ - 'actor_host_unavailable', - 'actor_host_not_ready', - 'actor_host_keepalive_failed', - 'actor_worker_spawn_failed', - 'actor_worker_start_timeout', - 'actor_worker_crashed', - 'actor_worker_message_error', - 'actor_worker_protocol_error', -]); - /** * Run on the detected dedicated-worker host. A failure before the realm proof * is safe to retry once because no model call or tool relay could have begun. @@ -3649,13 +3657,13 @@ const runActorIsolated = async (job, opts) => { if (!actorClient || !actorIsolationAvailable(actorIsolation)) { return actorIsolationRefusal(actorIsolation, { targetRead: false, targetChanged: false }); } - let result = await actorClient.run(job, opts); - if (result?.started || result?.ok || !ACTOR_HOST_STARTUP_CODES.has(result?.code)) return result; - // Stop can win while the first host proof is failing. A second Worker would - // be safe from relays but would still be needless post-cancel execution. - if (opts?.signal?.aborted) return { ...result, aborted: true }; - result = await actorClient.run(job, opts); - if (result?.started || result?.ok || !ACTOR_HOST_STARTUP_CODES.has(result?.code)) return result; + const attempt = await runActorWithStartupRetry({ + run: () => actorClient.run(job, opts), + isStartupFailure: isActorHostStartupFailure, + signal: opts?.signal, + }); + const { result } = attempt; + if (!attempt.exhausted) return result; actorIsolation = actorIsolationTemporarilyUnavailable(baseActorIsolation, result?.error ?? 'actor worker startup failed'); let persisted = true; try { @@ -4321,7 +4329,7 @@ browser.runtime.onConnect.addListener((port) => { // message dispatcher. if (!isTrustedSender(port.sender)) { try { port.disconnect(); } catch { /* already gone */ } return; } if (port.name === 'private-transfer') { - if (!privateTransferPort || !isActualOptionsSender(port.sender)) { + if (offscreenAvailable || !privateTransferPort || !isActualOptionsSender(port.sender)) { try { port.disconnect(); } catch { /* already gone */ } return; } @@ -7129,7 +7137,9 @@ const dwebTransfer = makeDwebTransfer({ // Backup requests carry passwords and recovery records. They are present in // the normal route modules for shared business logic, but every transfer route // requires this non-serializable capability and the options page reaches them -// only through its exact-sender verified Port. +// through a MessageChannel transferred to its exact WindowClient on Chrome. +// Firefox has no service-worker WindowClient API, so its exact options sender +// uses the private background-page Port fallback above. const privateTransferAuthorization = Symbol('private-transfer'); const makeSystemRouteSet = () => makeSystemRoutes({ vault, auditLog, sessions, pushState, kv, memory, buildStateSnapshot, closeSidePanel, @@ -7156,6 +7166,17 @@ privateTransferPort = makePrivateTransferPort({ 'transfer/import': systemMessageRoutes['transfer/import'], }, }); +const privateTransferOpenRoute = makePrivateTransferOpenRoute({ + isOptionsSender: isActualOptionsSender, + optionsUrl: browser.runtime.getURL('options/options.html'), + attach: (port) => privateTransferPort?.attach(port), + listWindowClients: async () => { + const clientsApi = /** @type {any} */ (globalThis).clients; + return clientsApi?.matchAll + ? clientsApi.matchAll({ type: 'window' }) + : []; + }, +}); // Message routes live in background/routes/*.js as import-free, deps-injected // factories. Each is wired with an EXPLICIT per-module deps object naming @@ -7171,16 +7192,17 @@ privateTransferPort = makePrivateTransferPort({ // belongs in a routes/ module too; if it needs mutable SW state, give that state // a store and inject it, rather than reaching for a module-level let. browser.runtime.onMessage.addListener(/** @type {any} */ (makeDispatcher({ + // Nonsecret request for a MessageChannel transferred to the exact options + // WindowClient. Backup passphrases and payloads use only that channel. + 'private-transfer/open': privateTransferOpenRoute, // The heap split: the offscreen→SW relays for the ONE agent-loop client — model-call // (getSecret + safeFetch added in the handler; the key never left the SW), the // SW-side pin+gate tool-dispatch, and the fire-and-forget loop-event (→ the actor/ // actor card + cost meter). Serves both spawned reasoners and bound actors; a // reasoning child never exercises tool-dispatch. actorClient is defined above (after // ensureOffscreen), before this dispatcher literal — safe to spread. - // Chrome's offscreen document relays over runtime messaging. Firefox's - // direct background-page host binds these routes in-process above, so they - // must not be callable from any extension page. - ...(offscreenAvailable ? (actorClient?.routes ?? {}) : {}), + // Firefox binds these routes directly inside the background page. Chrome's + // offscreen host reaches them only through its run-specific channel closure. // The script tool's actors-in-code relay: live-run/owner/grant verified, // then every ask re-enters the existing messageActor gate chain. ...makeActorsRoutes({ diff --git a/extension/offscreen/actor-channel-host.js b/extension/offscreen/actor-channel-host.js new file mode 100644 index 00000000..219154a9 --- /dev/null +++ b/extension/offscreen/actor-channel-host.js @@ -0,0 +1,100 @@ +// @ts-check +// Offscreen half of the targeted actor MessageChannel transport. + +import { ACTOR_CHANNEL_PROTOCOL } from '/shared/actor-channel-protocol.js'; + +/** + * @param {Object} deps + * @param {MessagePort} deps.port + * @param {string} deps.channelId + * @param {(job: any, deps: { workerUrl: string, sendToSW: (type: string, payload: object) => Promise }) => Promise} deps.run + * @param {(runId: string) => void} deps.abort + * @param {string} deps.workerUrl + */ +export const bindActorChannel = ({ port, channelId, run, abort, workerUrl }) => { + /** @type {Map void, reject: (reason: any) => void }>} */ + const pendingRelays = new Map(); + let job = /** @type {any} */ (null); + let runId = ''; + let committed = false; + let completed = false; + let abortRequested = false; + let relaySequence = 0; + const post = (/** @type {Record} */ message) => port.postMessage({ + protocol: ACTOR_CHANNEL_PROTOCOL, channelId, ...message, + }); + const sendToSW = (/** @type {string} */ type, /** @type {object} */ payload) => new Promise((resolve, reject) => { + if (!committed || completed) { reject(new Error('actor channel is not active')); return; } + const requestId = `relay-${++relaySequence}`; + pendingRelays.set(requestId, { resolve, reject }); + try { post({ type: 'actor/relay', requestId, relayType: type, payload }); } + catch (cause) { pendingRelays.delete(requestId); reject(cause); } + }); + port.onmessage = (event) => { + const message = event.data; + if (!message || message.protocol !== ACTOR_CHANNEL_PROTOCOL + || message.channelId !== channelId || completed) return; + if (message.type === 'actor/relay-response' + && typeof message.requestId === 'string' && committed) { + const pending = pendingRelays.get(message.requestId); + if (!pending) return; + pendingRelays.delete(message.requestId); + pending.resolve(message.result); + return; + } + if (message.type === 'actor/abort') { + abortRequested = true; + if (runId) abort(runId); + if (!committed) { + completed = true; + job = null; + try { port.close(); } catch { /* already closed */ } + } + return; + } + if (message.type === 'actor/open' && !job && !committed && message.job) { + job = message.job; + runId = typeof job.runId === 'string' ? job.runId : ''; + post({ type: 'actor/accepted' }); + return; + } + if (message.type !== 'actor/commit' || !job || committed) return; + committed = true; + if (abortRequested && runId) abort(runId); + run(job, { workerUrl, sendToSW }) + .then((result) => { + completed = true; + try { post({ type: 'actor/result', result }); } catch { /* SW restarted */ } + }) + .catch((cause) => { + completed = true; + try { + post({ + type: 'actor/result', + result: { + ok: false, started: true, outcomeKnown: false, + error: cause instanceof Error ? cause.message : String(cause), + }, + }); + } catch { /* SW restarted */ } + }) + .finally(() => { + for (const pending of pendingRelays.values()) { + pending.reject(new Error('actor channel settled')); + } + pendingRelays.clear(); + }); + }; + port.onmessageerror = () => { + if (runId && committed && !completed) abort(runId); + for (const pending of pendingRelays.values()) pending.reject(new Error('actor channel message error')); + pendingRelays.clear(); + }; + port.addEventListener('close', () => { + if (runId && committed && !completed) abort(runId); + for (const pending of pendingRelays.values()) pending.reject(new Error('actor channel closed')); + pendingRelays.clear(); + }, { once: true }); + port.start(); + post({ type: 'channel/ready' }); +}; diff --git a/extension/offscreen/actor-runner.js b/extension/offscreen/actor-runner.js index 5fa9cfd1..f7ec8bb6 100644 --- a/extension/offscreen/actor-runner.js +++ b/extension/offscreen/actor-runner.js @@ -170,7 +170,9 @@ export const runActor = async (job, { } if (m.type === 'model-request') { try { - const resp = await sendToSW('actor/model-call', { relayToken, args: m.args }); + const resp = await sendToSW('actor/model-call', { + ...(relayToken ? { relayToken } : {}), args: m.args, + }); if (resp?.ok) w.postMessage({ type: 'model-response', rid: m.rid, events: resp.events ?? [] }); else w.postMessage({ type: 'model-error', rid: m.rid, error: resp?.error ?? 'model call failed' }); } catch (e) { w.postMessage({ type: 'model-error', rid: m.rid, error: /** @type {{ message?: string }} */ (e)?.message ?? String(e) }); } @@ -185,12 +187,19 @@ export const runActor = async (job, { // worker's call args) and returns the ToolResult. The relay grant keys // the actor ctx it builds — the session is no longer sent at all, so // neither this runner nor any other first-party page can name one. - const reply = await sendToSW('actor/tool-dispatch', { relayToken, call: m.call }); + const reply = await sendToSW('actor/tool-dispatch', { + ...(relayToken ? { relayToken } : {}), call: m.call, + }); w.postMessage({ type: 'tool-response', rid: m.rid, reply }); } catch (e) { w.postMessage({ type: 'tool-response', rid: m.rid, reply: { ok: false, error: /** @type {{ message?: string }} */ (e)?.message ?? String(e) } }); } return; } - if (m.type === 'loop-event') { sendToSW('actor/loop-event', { relayToken, event: m.event }).catch(() => {}); return; } + if (m.type === 'loop-event') { + sendToSW('actor/loop-event', { + ...(relayToken ? { relayToken } : {}), event: m.event, + }).catch(() => {}); + return; + } if (m.type === 'done') { const r = m.result ?? {}; // No `aborted` here: a Stop-cascade is stamped at the SW client (which alone diff --git a/extension/offscreen/offscreen.js b/extension/offscreen/offscreen.js index 1ba0e6c9..18b9229f 100644 --- a/extension/offscreen/offscreen.js +++ b/extension/offscreen/offscreen.js @@ -27,6 +27,8 @@ import { runJob, abortJob } from './job-runner.js'; // The heap split: EVERY offscreen agent loop — ephemeral spawned reasoners AND // bound actors (VM/Notebook/App/web) — runs in a dedicated Worker via this ONE host. import { runActor, abortActor } from './actor-runner.js'; +import { bindActorChannel } from './actor-channel-host.js'; +import { ACTOR_CHANNEL_OFFER, ACTOR_CHANNEL_PROTOCOL } from '/shared/actor-channel-protocol.js'; // PDF text extraction (the read_pdf runner tool): pdf.js needs a Worker, which // the SW can't host. Self-registers a 'pdf/extract' message handler. import './pdf-extract.js'; @@ -137,6 +139,24 @@ setInterval(() => { connect(); +// Chrome actor jobs arrive over a standard MessageChannel transferred by the +// service worker directly to this exact offscreen WindowClient. This avoids +// runtime messaging and runtime Port fan-out to other extension frames. +navigator.serviceWorker?.addEventListener('message', (event) => { + const source = /** @type {{ scriptURL?: string } | null} */ (event.source); + if (!event.isTrusted + || source?.scriptURL !== browser.runtime.getURL('background/service-worker.js') + || event.data?.type !== ACTOR_CHANNEL_OFFER + || event.data?.protocol !== ACTOR_CHANNEL_PROTOCOL + || typeof event.data?.channelId !== 'string' + || event.ports?.length !== 1) return; + bindActorChannel({ + port: event.ports[0], channelId: event.data.channelId, + run: runActor, abort: abortActor, + workerUrl: browser.runtime.getURL('offscreen/actor-worker.js'), + }); +}); + // --------------------------------------------------------------------------- // Voice: lazy-loaded Moonshine transcriber. // @@ -398,33 +418,6 @@ const onJobAbort = (msg, sender, sendResponse) => { }; browser.runtime.onMessage.addListener(/** @type {any} */ (onJobAbort)); -// --- the heap split: every offscreen agent loop (spawned reasoners + bound actors) --- -// Runs a reasoning (tools:[]) OR bound-actor (VM/Notebook/App/web) loop in a dedicated -// Worker (its own heap), relaying its model call — AND, for a tool-bearing actor, every -// tool call — back to the SW (which holds the key and pins + gates + dispatches). Same -// command trust posture as onJobMessage: the exact service-worker source only. -/** - * @param {any} msg - * @param {import('webextension-polyfill').Runtime.MessageSender} sender - * @param {(response: any) => void} sendResponse - */ -const onActorMessage = (msg, sender, sendResponse) => { - if (msg?.type !== 'actor/run' && msg?.type !== 'actor/abort') return undefined; - if (!isServiceWorkerSender(sender)) { sendResponse({ ok: false, error: 'unauthorized-command-sender' }); return true; } - if (msg.type === 'actor/abort') { abortActor(msg.runId); sendResponse({ ok: true }); return true; } - runActor( - msg.job ?? {}, - { - workerUrl: browser.runtime.getURL('offscreen/actor-worker.js'), - sendToSW: (type, payload) => browser.runtime.sendMessage({ type, ...payload }), - }, - ) - .then((result) => sendResponse(result)) - .catch((e) => sendResponse({ ok: false, error: /** @type {{ message?: string }} */ (e)?.message ?? String(e) })); - return true; -}; -browser.runtime.onMessage.addListener(/** @type {any} */ (onActorMessage)); - // Local WebGPU inference (FEATURE-LOCAL-WEBGPU B). The SW's local-webgpu adapter // drives this: status/probe/init/teardown are request→response; generate STREAMS // tokens back as local-model/delta messages (the SW collects them into the diff --git a/extension/options/options.js b/extension/options/options.js index 0d00acf1..30afee64 100644 --- a/extension/options/options.js +++ b/extension/options/options.js @@ -22,11 +22,7 @@ import m from '/vendor/mithril/mithril.js'; import browser from '/vendor/browser-polyfill.js'; import { CHANNEL, DWEB_ENABLED } from '/shared/channel-config.js'; import { OptionsApp } from './components/options-app.js'; -import { makePrivateTransferClient } from './private-transfer-client.js'; - -const privateTransferClient = makePrivateTransferClient({ - connect: () => browser.runtime.connect({ name: 'private-transfer' }), -}); +import { callPrivateTransfer } from './private-transfer-session.js'; // null until the first snapshot lands — the shell renders a loading // gate rather than guessing at vault state (a flash of "set up peerd" @@ -113,7 +109,7 @@ const foldReply = (msg, reply) => { */ const send = async (msg) => { const reply = msg.type.startsWith('transfer/') - ? await privateTransferClient.call(msg) + ? await callPrivateTransfer(msg) : await browser.runtime.sendMessage(msg); foldReply(msg, reply); return reply; diff --git a/extension/options/private-transfer-client.js b/extension/options/private-transfer-client.js index 12644d44..912c7f75 100644 --- a/extension/options/private-transfer-client.js +++ b/extension/options/private-transfer-client.js @@ -1,5 +1,6 @@ // @ts-check -// Options-side half of the sender-verified backup/restore Port. +// Options-side half of the backup/restore transport. Chrome receives a +// targeted MessageChannel. Firefox uses an exact-sender background Port. export class PrivateTransferPortError extends Error { /** @param {string} message @param {string} code */ @@ -12,15 +13,20 @@ export class PrivateTransferPortError extends Error { /** * @param {Object} deps - * @param {() => import('webextension-polyfill').Runtime.Port} deps.connect + * @param {(requestId: string) => void|Promise} [deps.requestChannel] + * @param {() => import('webextension-polyfill').Runtime.Port} [deps.connect] * @param {() => string} [deps.newRequestId] * @param {number} [deps.timeoutMs] */ export const makePrivateTransferClient = ({ - connect, newRequestId = () => crypto.randomUUID(), timeoutMs = 60_000, + requestChannel, connect, newRequestId = () => crypto.randomUUID(), timeoutMs = 60_000, }) => { - /** @type {import('webextension-polyfill').Runtime.Port | null} */ + /** @type {MessagePort | import('webextension-polyfill').Runtime.Port | null} */ let port = null; + /** @type {{ requestId: string, resolve: (port: MessagePort) => void, reject: (reason: any) => void, timer: ReturnType } | null} */ + let channelRequest = null; + /** @type {Promise | null} */ + let channelPromise = null; /** @type {Map void, reject: (reason: any) => void, timer: ReturnType }>} */ const pending = new Map(); @@ -32,38 +38,110 @@ export const makePrivateTransferClient = ({ pending.clear(); }; - const ensurePort = () => { - if (port) return port; - const next = connect(); + /** @param {MessagePort | import('webextension-polyfill').Runtime.Port} active */ + const dropPort = (active) => { + if (port !== active) return; + port = null; + channelPromise = null; + try { + if ('disconnect' in active) active.disconnect(); + else active.close(); + } catch { /* already closed */ } + rejectPending(); + }; + + /** @param {any} response */ + const handleResponse = (response) => { + if (response?.type === 'private-transfer/ready' && port && channelRequest) { + const request = channelRequest; + clearTimeout(request.timer); + channelRequest = null; + request.resolve(/** @type {MessagePort} */ (port)); + channelPromise = Promise.resolve(/** @type {MessagePort} */ (port)); + return; + } + if (response?.type !== 'private-transfer/response' + || typeof response.requestId !== 'string') return; + const entry = pending.get(response.requestId); + if (!entry) return; + const active = port; + pending.delete(response.requestId); + clearTimeout(entry.timer); + if (response.ok) entry.resolve(response.reply); + else entry.reject(new PrivateTransferPortError( + typeof response.error === 'string' ? response.error : 'backup request failed', + 'request-failed', + )); + // Backup operations are infrequent. Retire an idle channel after each + // response so the next click never inherits a peer lost to an SW restart. + if (active && pending.size === 0) dropPort(active); + }; + + const ensurePort = async () => { + if (port && (!requestChannel || !channelRequest)) return port; + if (!requestChannel) { + if (!connect) throw new PrivateTransferPortError( + 'backup connection is unavailable', 'channel-unavailable', + ); + const next = connect(); + port = next; + next.onMessage.addListener(handleResponse); + next.onDisconnect.addListener(() => dropPort(next)); + return next; + } + if (!channelPromise) { + const requestId = newRequestId(); + channelPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (channelRequest?.requestId === requestId) channelRequest = null; + reject(new PrivateTransferPortError('backup connection timed out', 'channel-timeout')); + }, timeoutMs); + channelRequest = { + requestId, + resolve: /** @type {(port: MessagePort) => void} */ (resolve), + reject, timer, + }; + }); + Promise.resolve(requestChannel(requestId)).catch((cause) => { + const request = channelRequest; + if (request?.requestId !== requestId) return; + clearTimeout(request.timer); + channelRequest = null; + request.reject(new PrivateTransferPortError( + cause instanceof Error ? cause.message : 'backup connection could not be requested', + 'channel-request-failed', + )); + }); + } + try { return await channelPromise; } + finally { if (!port) channelPromise = null; } + }; + + /** @param {string} requestId @param {MessagePort} next */ + const acceptChannel = (requestId, next) => { + const request = channelRequest; + if (!request || request.requestId !== requestId || port) { + try { next.close(); } catch { /* unsolicited channel */ } + return false; + } port = next; - next.onMessage.addListener((/** @type {any} */ response) => { - if (response?.type !== 'private-transfer/response' - || typeof response.requestId !== 'string') return; - const entry = pending.get(response.requestId); - if (!entry) return; - pending.delete(response.requestId); - clearTimeout(entry.timer); - if (response.ok) entry.resolve(response.reply); - else entry.reject(new PrivateTransferPortError( - typeof response.error === 'string' ? response.error : 'backup request failed', - 'request-failed', - )); - }); - next.onDisconnect.addListener(() => { - if (port !== next) return; - port = null; - rejectPending(); - }); - return next; + next.onmessage = (event) => handleResponse(event.data); + next.onmessageerror = () => { + dropPort(next); + }; + next.addEventListener('close', () => dropPort(next), { once: true }); + next.start(); + return true; }; /** @param {{ type: string } & Record} message */ - const call = (message) => { - const active = ensurePort(); + const call = async (message) => { + const active = await ensurePort(); const requestId = newRequestId(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); + dropPort(active); reject(new PrivateTransferPortError('backup request timed out', 'timeout')); }, timeoutMs); pending.set(requestId, { resolve, reject, timer }); @@ -72,6 +150,7 @@ export const makePrivateTransferClient = ({ } catch (cause) { pending.delete(requestId); clearTimeout(timer); + dropPort(active); reject(new PrivateTransferPortError( /** @type {{ message?: string }} */ (cause)?.message ?? 'backup request could not be sent', 'post-failed', @@ -80,5 +159,5 @@ export const makePrivateTransferClient = ({ }); }; - return { call }; + return { call, acceptChannel }; }; diff --git a/extension/options/private-transfer-session.js b/extension/options/private-transfer-session.js new file mode 100644 index 00000000..41eb1042 --- /dev/null +++ b/extension/options/private-transfer-session.js @@ -0,0 +1,30 @@ +// @ts-check +// One owner for the options page's private backup/restore transport. + +import browser from '/vendor/browser-polyfill.js'; +import { makePrivateTransferClient } from './private-transfer-client.js'; + +const serviceWorkerChannels = typeof navigator.serviceWorker?.addEventListener === 'function'; +const client = makePrivateTransferClient(serviceWorkerChannels ? { + requestChannel: async (requestId) => { + const reply = /** @type {any} */ (await browser.runtime.sendMessage({ + type: 'private-transfer/open', requestId, + })); + if (!reply?.ok) throw new Error(reply?.error ?? 'backup channel refused'); + }, +} : { + connect: () => browser.runtime.connect({ name: 'private-transfer' }), +}); + +if (serviceWorkerChannels) navigator.serviceWorker.addEventListener('message', (event) => { + const source = /** @type {{ scriptURL?: string } | null} */ (event.source); + if (!event.isTrusted + || source?.scriptURL !== browser.runtime.getURL('background/service-worker.js') + || event.data?.type !== 'private-transfer/channel' + || typeof event.data?.requestId !== 'string' + || event.ports?.length !== 1) return; + client.acceptChannel(event.data.requestId, event.ports[0]); +}); + +/** @param {{ type: string } & Record} message */ +export const callPrivateTransfer = (message) => client.call(message); diff --git a/extension/options/sections/transfer.js b/extension/options/sections/transfer.js index 652a02c0..29290943 100644 --- a/extension/options/sections/transfer.js +++ b/extension/options/sections/transfer.js @@ -1,16 +1,20 @@ // @ts-check // Options → Backup & restore — the explicit migration path between // installs, including between peerd (store) and peerd preview, which -// are separate extensions with isolated storage by design. Ported -// backed by the existing transfer routes: +// are separate extensions with isolated storage by design. Backed by the +// existing guarded transfer routes: // transfer/export + transfer/inspectImport + transfer/import. import m from '/vendor/mithril/mithril.js'; import { CHANNEL } from '/shared/channel-config.js'; import { bundleToOtlp, EXPORT_PASSPHRASE_MIN_LENGTH } from '/peerd-runtime/index.js'; import { EXPORT_FILE_LIMIT_BYTES } from '/peerd-engine/index.js'; +import { PrivateTransferPortError } from '../private-transfer-client.js'; const MAX_BACKUP_FILE_BYTES = 32 * 1024 * 1024; +const IMPORT_NOT_STARTED_CODES = new Set([ + 'channel-unavailable', 'channel-request-failed', 'channel-timeout', 'post-failed', +]); /** @param {string | null | undefined} did */ const displayDid = (did) => did || 'unknown'; /** @param {any} summary */ @@ -387,12 +391,21 @@ export const TransferSection = { text: describeImportError(reply?.error), }; } - } catch { - ui.importMsg = { - ok: false, - text: 'The connection to peerd was lost during the import, so its final state is unknown. Reopen peerd, inspect local state, then choose the backup file again.', - }; - clearImportSelection(true); + } catch (error) { + if (error instanceof PrivateTransferPortError + && IMPORT_NOT_STARTED_CODES.has(error.code)) { + ui.importMsg = { + ok: false, + text: 'Restore did not start because the private connection was unavailable. Close any other peerd Settings tabs, then try again.', + }; + ui.focusImportStatusOnUpdate = true; + } else { + ui.importMsg = { + ok: false, + text: 'The connection to peerd was lost during the import, so its final state is unknown. Reopen peerd, inspect local state, then choose the backup file again.', + }; + clearImportSelection(true); + } } finally { ui.importBusy = false; m.redraw(); diff --git a/extension/shared/actor-channel-protocol.js b/extension/shared/actor-channel-protocol.js new file mode 100644 index 00000000..d0b30aef --- /dev/null +++ b/extension/shared/actor-channel-protocol.js @@ -0,0 +1,5 @@ +// @ts-check +// Constants shared by the two ends of the targeted actor MessageChannel. + +export const ACTOR_CHANNEL_PROTOCOL = 1; +export const ACTOR_CHANNEL_OFFER = 'peerd/actor-channel'; diff --git a/extension/shared/sender-trust.js b/extension/shared/sender-trust.js index 04699fdc..fe675ddf 100644 --- a/extension/shared/sender-trust.js +++ b/extension/shared/sender-trust.js @@ -93,11 +93,12 @@ export const isOffscreenSender = (sender, { runtimeId, extensionOrigin, offscree /** * Is this sender specifically the extension SERVICE WORKER? * - * why: runtime.sendMessage is broadcast to extension contexts. An engine tab - * is first-party but hosts agent-authored/untrusted state, so it must not mint - * a headless job or actor run by replaying an observed command. Command - * receivers in the offscreen document use this exact source-script pin; relay - * replies travel in the opposite direction and use isOffscreenSender. + * why: runtime.sendMessage reaches extension contexts broadly. An engine tab is + * first-party but hosts agent-authored or untrusted state, so exact source pins + * remain necessary for the headless jobs that still use runtime messaging. + * Chrome actor runs and private backup transfers use targeted MessageChannels. + * Firefox actor relays stay inside its background page, while its backup Port + * still requires an exact options-page sender. * * @param {{ id?: string, url?: string, tab?: unknown, documentId?: string } | null | undefined} sender * @param {{ runtimeId?: string, extensionOrigin?: string, serviceWorkerUrl?: string }} [trust] diff --git a/extension/tests/unit/options/transfer-identity.test.js b/extension/tests/unit/options/transfer-identity.test.js index 8a6d94ab..876fb9e8 100644 --- a/extension/tests/unit/options/transfer-identity.test.js +++ b/extension/tests/unit/options/transfer-identity.test.js @@ -5,6 +5,7 @@ import m from '/vendor/mithril/mithril.js'; import { describe, it, expect } from '../../framework.js'; import { TransferSection } from '/options/sections/transfer.js'; +import { PrivateTransferPortError } from '/options/private-transfer-client.js'; const RECORD = { did: 'did:key:zIncomingPortable' }; const SUMMARY = Object.freeze({ @@ -150,6 +151,25 @@ describe('options.transfer — portable identity restore', () => { } finally { unmount(); } }); + it('keeps the inspected backup when the private restore connection never starts', async () => { + const { root, unmount } = await mount(() => Promise.reject(new PrivateTransferPortError( + 'private-transfer-channel-target-missing', 'channel-request-failed', + ))); + try { + const passphrase = /** @type {HTMLInputElement} */ (root.querySelector('#imppass')); + passphrase.value = 'backup-passphrase'; + passphrase.dispatchEvent(new Event('input')); + await flush(); + /** @type {HTMLButtonElement} */ ([...root.querySelectorAll('button')] + .find((button) => button.textContent === 'Apply import')).click(); + await flush(); + expect(root.querySelector('[role=alert]')?.textContent).toContain('Restore did not start'); + expect(root.querySelector('#restore-summary')).toBeTruthy(); + expect(/** @type {HTMLInputElement} */ (root.querySelector('#imppass')).value) + .toBe('backup-passphrase'); + } finally { unmount(); } + }); + it('discards an older inspection reply when a newer file was selected', async () => { /** @type {(value: any) => void} */ let resolveA = () => {}; /** @type {(value: any) => void} */ let resolveB = () => {}; diff --git a/packaging/security-baseline.json b/packaging/security-baseline.json index ff3db51b..804b542b 100644 --- a/packaging/security-baseline.json +++ b/packaging/security-baseline.json @@ -485,7 +485,7 @@ "engine-tabs/vm-tab/vm-tab.js": 1, "offscreen/doc-extract.js": 1, "offscreen/dweb-base.js": 1, - "offscreen/offscreen.js": 5, + "offscreen/offscreen.js": 4, "offscreen/web-extract.js": 1, "shared/peer-notifications.js": 1, "shared/pull-in-peerd.js": 1 diff --git a/scripts/cdp/states.mjs b/scripts/cdp/states.mjs index d20f2c98..42bc121b 100644 --- a/scripts/cdp/states.mjs +++ b/scripts/cdp/states.mjs @@ -62,17 +62,24 @@ const actorIsolationEvidence = (entries) => { }; }; -// Transfer routes require the exact options-page Port. Keep the live E2E on +// Transfer routes require the exact options-page channel. Keep the live E2E on // that production boundary instead of calling the generic dispatcher. const privateTransferRpc = (page, message) => evalIn(page, `(async () => { - const browser = (await import('/vendor/browser-polyfill.js')).default; - const { makePrivateTransferClient } = await import('/options/private-transfer-client.js'); - globalThis.__peerdE2eTransferClient ??= makePrivateTransferClient({ - connect: () => browser.runtime.connect({ name: 'private-transfer' }), - }); - return globalThis.__peerdE2eTransferClient.call(${JSON.stringify(message)}); + const { callPrivateTransfer } = await import('/options/private-transfer-session.js'); + return callPrivateTransfer(${JSON.stringify(message)}); })()`, true); +// The raw CDP handle's close() disconnects the debugger but does not close the +// tab. Navigate private-transfer fixtures away first so the next exact-client +// assertion cannot inherit an old options document from an earlier state. +const retirePrivateTransferPage = async (page) => { + await page.send('Page.navigate', { url: 'about:blank' }); + const retired = await waitFor(() => evalIn(page, `location.href === 'about:blank'`), + { budgetMs: 5_000, pollMs: 50 }); + if (!retired) throw new Error('private transfer fixture did not retire'); + try { page.close(); } catch { /* */ } +}; + // The local-first personal-data agent, end to end through the REAL stack: the // faked model calls script, the sealed worker builds an on-device index in OPFS // and queries it, and the agent reports the answer — every byte computed on @@ -110,6 +117,7 @@ let dwebActorState = { delegates: 0, actorCalls: 0 }; let a2aState = { delegates: 0, actorCalls: 0 }; // heap-split phase 1: the offscreen pure-reasoning actor state. let reasoningState = { spawned: 0, childCalls: 0 }; +let actorChannelTargetState = { spawned: 0, childCalls: 0 }; // heap-split phase 4: the offscreen TOOL-BEARING actor state. let actorToolsState = { spawned: 0, childCalls: 0 }; // issue #324: an offscreen actor delegating FROM its granted script surface. @@ -1023,7 +1031,7 @@ export const STATES = [ restarted?.ok === true && restarted?.running === true && restarted?.did === incoming.did, JSON.stringify(restarted)); await rec.shot('final'); - try { transferPage.close(); } catch { /* */ } + await retirePrivateTransferPage(transferPage); }, }, @@ -1037,7 +1045,7 @@ export const STATES = [ await waitFor(() => evalIn(page, `!!document.querySelector('#exppass')`), { budgetMs: 15_000, pollMs: 80 }).catch(() => {}); await rec.visualPage('options-transfer', page); - } finally { try { page.close(); } catch { /* */ } } + } finally { await retirePrivateTransferPage(page); } }, }, { @@ -1071,7 +1079,7 @@ export const STATES = [ localDid = postBootstrap?.payload?.dweb?.identityRecord?.did ?? null; localReady = !!localDid; } - try { seedPage.close(); } catch { /* */ } + await retirePrivateTransferPage(seedPage); const incoming = await evalIn(ctx.page, `(async () => { const dweb = await import('/peerd-distributed/index.js'); let value = null; @@ -1106,8 +1114,15 @@ export const STATES = [ input.files = transfer.files; input.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await waitFor(() => evalIn(page, `!!document.querySelector('#imppass')`), + const importReady = await waitFor(() => evalIn(page, `!!document.querySelector('#imppass')`), { budgetMs: 15_000, pollMs: 80 }); + if (!importReady) { + const importState = await evalIn(page, `(() => ({ + alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent), + fileCount: document.querySelector('#peerd-backup-file')?.files?.length ?? 0, + }))()`); + throw new Error(`backup inspection did not settle: ${JSON.stringify(importState)}`); + } await evalIn(page, `(() => { const input = document.querySelector('#imppass'); input.value = ${JSON.stringify(PASSPHRASE)}; @@ -1164,7 +1179,7 @@ export const STATES = [ const identityTextReady = await waitFor(pinIdentityText, { budgetMs: 5000, pollMs: 50 }); if (!identityTextReady) throw new Error('identity conflict text did not settle'); await rec.visualPage('options-transfer-conflict', page, { beforeShot: pinIdentityText }); - } finally { try { page.close(); } catch { /* */ } } + } finally { await retirePrivateTransferPage(page); } }, }, @@ -4646,34 +4661,76 @@ Promise.resolve().then(async () => { }, }, - // --- red-team: actor host commands accept only the service worker -------- + // --- red-team: actor jobs and relays reach only the offscreen client ------ { - name: 'actor-command-sender-pin', kind: 'functional', phase: 'post-unlock', - responder: null, + name: 'actor-channel-targeting', kind: 'functional', phase: 'post-unlock', + responder: (_callIndex, request) => { + const body = request?.postData ?? ''; + if (body.includes('') && body.includes('kind: ephemeral')) { + actorChannelTargetState.childCalls += 1; + return { sse: sseText('TARGETED-CHILD-DONE') }; + } + if (actorChannelTargetState.spawned === 0) { + actorChannelTargetState.spawned += 1; + return { sse: sseToolCall('actor_create', { + task: 'target-only-marker-308', tools: [], sync: true, + }) }; + } + return { sse: sseText('TARGETED-PARENT-DONE') }; + }, async run(ctx, rec) { + actorChannelTargetState = { spawned: 0, childCalls: 0 }; let appPage = null; try { - // why an App host page: it is a real first-party extension tab, the - // exact sender class the old broad extension-origin check admitted. - appPage = await openExtPage(ctx, 'engine-tabs/app-tab/index.html#sender-pin-probe'); + // why an App host page: it is a real first-party extension tab and the + // exact sibling context that must not receive an offscreen job or relay. + appPage = await openExtPage(ctx, 'engine-tabs/app-tab/index.html#channel-target-probe'); const ready = await waitFor(() => evalIn(appPage, `location.protocol === 'chrome-extension:' && document.readyState !== 'loading'`), { budgetMs: 8_000, pollMs: 60 }); rec.check('the adversarial engine-tab sender loaded', ready === true); - - const abortReply = await rpc(appPage, { - type: 'actor/abort', runId: 'forged-by-engine-tab', - }); - const runReply = await rpc(appPage, { - type: 'actor/run', - job: { runId: 'forged-by-engine-tab', relayToken: 'broadcast-token-is-not-authority' }, + await evalIn(appPage, `(async () => { + const browser = (await import('/vendor/browser-polyfill.js')).default; + globalThis.__actorChannelObservations = []; + const record = (kind, value) => { + let text = ''; + try { text = JSON.stringify(value); } catch { text = String(value); } + globalThis.__actorChannelObservations.push({ kind, text }); + }; + browser.runtime.onMessage.addListener((message) => record('runtime-message', message)); + browser.runtime.onConnect.addListener((port) => record('runtime-connect', { name: port.name })); + navigator.serviceWorker?.addEventListener('message', (event) => record('service-worker-message', event.data)); + return true; + })()`, true); + const priorAuditIds = new Set((await auditEntries(ctx)).map((entry) => entry.id)); + const sent = await rpc(ctx.page, { + type: 'agent/send', text: 'run the targeted actor transport probe', }); - rec.check('an engine tab cannot forge actor/abort', - abortReply?.ok === false && abortReply?.error === 'unauthorized-command-sender', - JSON.stringify(abortReply)); - rec.check('an engine tab cannot replay actor/run', - runReply?.ok === false && runReply?.error === 'unauthorized-command-sender', - JSON.stringify(runReply)); + rec.check('the actor probe turn was accepted', sent?.ok === true, JSON.stringify(sent)); + let turnView = {}; + const settled = await waitFor(async () => { + turnView = await evalIn(ctx.page, `({ + bubbles: [...document.querySelectorAll('.message-assistant .bubble')] + .map((bubble) => bubble.textContent.trim()), + busy: !!document.querySelector('form.input-bar button.stop'), + })`) ?? {}; + return actorChannelTargetState.childCalls >= 1 && !turnView.busy; + }, + { budgetMs: 30_000, pollMs: 100 }); + let isolation = actorIsolationEvidence([]); + await waitFor(async () => { + const entries = (await auditEntries(ctx)).filter((entry) => !priorAuditIds.has(entry.id)); + isolation = actorIsolationEvidence(entries); + return isolation.exactProof; + }, { budgetMs: 5_000, pollMs: 100 }); + rec.check('the targeted actor completed', + settled === true && isolation.exactProof === true, + JSON.stringify({ turnView, actorChannelTargetState, isolation })); + const observations = await evalIn(appPage, `globalThis.__actorChannelObservations`); + const observedText = JSON.stringify(observations ?? []); + rec.check('the sibling engine page saw no actor channel, job, relay, or marker', + !/peerd\/actor-channel|actor\/(?:open|commit|relay|run)|target-only-marker-308/.test(observedText), + observedText.slice(0, 2_000)); } finally { try { appPage?.close(); } catch { /* */ } } diff --git a/scripts/firefox/run-runtime-tests.mjs b/scripts/firefox/run-runtime-tests.mjs index 2532a0f5..8319b1b0 100644 --- a/scripts/firefox/run-runtime-tests.mjs +++ b/scripts/firefox/run-runtime-tests.mjs @@ -3338,9 +3338,14 @@ return { sourceUrl, tlaValue };`); type: 'js/eval', notebookId: id, runId: 'firefox-concurrent-probe', code: "return 'MUST-NOT-REPLACE';", timeoutMs: 20_000, }); - const notebookFile = await browser.tabs.sendMessage(tab.id, { - type: 'js/read-file', notebookId: id, path: 'notebook.js', - }); + let notebookFile = null; + for (let fileAttempt = 0; fileAttempt < 50; fileAttempt += 1) { + notebookFile = await browser.tabs.sendMessage(tab.id, { + type: 'js/read-file', notebookId: id, path: 'notebook.js', + }); + if (notebookFile?.content === 'while (true) {}') break; + await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + } running = { label: button.getAttribute('aria-label'), text: button.textContent, @@ -4417,6 +4422,32 @@ const main = async () => { && background?.capabilities?.moonshineVoiceHost?.status === 'unsupported', 'Firefox reports offscreen-hosted facilities unavailable before use', JSON.stringify(background?.capabilities)); + await driver.navigate(`${EXTENSION_ORIGIN}/options/options.html#!/transfer`); + const transferMounted = await waitFor(() => driver.execute( + "return document.readyState === 'complete' && !!document.getElementById('exppass');", + ), { budgetMs: 30_000 }); + assert(transferMounted === true, 'packaged Firefox transfer page mounts'); + const privateTransfer = await driver.executeAsync(` + const done = arguments[arguments.length - 1]; + import('/options/private-transfer-session.js') + .then(({ callPrivateTransfer }) => callPrivateTransfer({ + type: 'transfer/export', passphrase: ${JSON.stringify(PASSPHRASE_CANARY)}, + })) + .then((reply) => done({ + ok: reply?.ok === true, + format: reply?.payload?.format, + version: reply?.payload?.version, + }), (error) => done({ ok: false, error: error?.message || String(error) })); + `); + assert(privateTransfer?.ok === true + && privateTransfer?.format === 'peerd-export' + && Number.isInteger(privateTransfer?.version), + 'Firefox backup uses the exact-options private background Port', JSON.stringify(privateTransfer)); + await driver.navigate(`${EXTENSION_ORIGIN}/sidepanel/sidepanel.html`); + await waitFor(() => driver.execute( + "return document.readyState === 'complete' && (document.getElementById('app')?.childElementCount || 0) > 0;", + ), { budgetMs: 30_000 }); + const unsupportedVoiceNudge = await driver.execute(` return [...document.querySelectorAll('.onboarding-card h3')] .some((heading) => heading.textContent === 'Try voice input'); diff --git a/tests/background/actor-startup-retry.test.ts b/tests/background/actor-startup-retry.test.ts new file mode 100644 index 00000000..8e5eeff9 --- /dev/null +++ b/tests/background/actor-startup-retry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { + isActorHostStartupFailure, runActorWithStartupRetry, +} from '../../extension/background/actor-startup-retry.js'; +import { isKnownActorStartupFailure } from '../../extension/background/offscreen-actor-channel-client.js'; + +describe('actor startup retry', () => { + test('two structured channel startup failures exhaust the retry', async () => { + let calls = 0; + const attempt = await runActorWithStartupRetry({ + run: async () => { + calls += 1; + return { + ok: false, started: false, phase: 'startup', outcomeKnown: true, + code: calls === 1 ? 'actor_channel_ready_timeout' : 'actor_channel_accept_timeout', + }; + }, + isStartupFailure: isKnownActorStartupFailure, + }); + expect(calls).toBe(2); + expect(attempt.exhausted).toBe(true); + expect(attempt.result.code).toBe('actor_channel_accept_timeout'); + }); + + test('a started or outcome-unknown failure is never retried', async () => { + let calls = 0; + const attempt = await runActorWithStartupRetry({ + run: async () => { + calls += 1; + return { ok: false, started: true, phase: 'run', outcomeKnown: false }; + }, + isStartupFailure: isKnownActorStartupFailure, + }); + expect(calls).toBe(1); + expect(attempt.exhausted).toBe(false); + }); + + for (const code of ['actor_worker_crashed', 'actor_worker_message_error']) { + test(`${code} after work starts is never retried`, async () => { + let calls = 0; + const attempt = await runActorWithStartupRetry({ + run: async () => { + calls += 1; + return { ok: false, started: true, phase: 'run', outcomeKnown: false, code }; + }, + isStartupFailure: isActorHostStartupFailure, + }); + expect(calls).toBe(1); + expect(attempt.exhausted).toBe(false); + }); + } +}); diff --git a/tests/background/offscreen-actor-channel-client.test.ts b/tests/background/offscreen-actor-channel-client.test.ts new file mode 100644 index 00000000..3bd386fe --- /dev/null +++ b/tests/background/offscreen-actor-channel-client.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test'; +import { + isKnownActorStartupFailure, makeOffscreenActorChannelClient, selectExactActorHostClient, +} from '../../extension/background/offscreen-actor-channel-client.js'; +import { bindActorChannel } from '../../extension/offscreen/actor-channel-host.js'; + +describe('targeted offscreen actor MessageChannel', () => { + test('classifies only explicit known not-started outcomes as retryable', () => { + expect(isKnownActorStartupFailure({ + started: false, phase: 'startup', outcomeKnown: true, + })).toBe(true); + expect(isKnownActorStartupFailure({ + started: true, phase: 'run', outcomeKnown: false, + })).toBe(false); + expect(isKnownActorStartupFailure({ + started: false, phase: 'startup', outcomeKnown: false, + })).toBe(false); + }); + + test('selects one exact offscreen recipient and refuses ambiguity', () => { + const expected = 'chrome-extension://example/offscreen/offscreen.html'; + const target = { url: expected }; + expect(selectExactActorHostClient([target], expected)).toBe(target); + expect(selectExactActorHostClient([], expected)).toBeNull(); + expect(selectExactActorHostClient([ + target, { url: expected }, + ], expected)).toBeNull(); + expect(selectExactActorHostClient([ + { url: `${expected}?sibling=1` }, { url: `${expected}#sibling` }, + ], expected)).toBeNull(); + }); + + test('runs and relays over the transferred channel without a bearer grant', async () => { + const offered: any[] = []; + const relayed: any[] = []; + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, + findOffscreenClient: async () => ({ + postMessage: (message: any, transfer: Transferable[]) => { + offered.push(message); + bindActorChannel({ + port: transfer[0] as MessagePort, + channelId: message.channelId, + workerUrl: '/offscreen/actor-worker.js', + abort: () => {}, + run: async (job, { sendToSW }) => { + expect(job.relayToken).toBeUndefined(); + const reply = await sendToSW('actor/model-call', { args: { messages: [] } }); + expect(reply).toEqual({ ok: true, events: [] }); + return { ok: true, started: true, finalText: 'done' }; + }, + }); + }, + }), + newChannelId: () => 'channel-one', + }); + const result = await client.run( + { runId: 'run-one', message: 'private job' }, + { + relay: async (type, payload) => { + relayed.push({ type, payload }); + return { ok: true, events: [] }; + }, + }, + ); + expect(result).toEqual({ ok: true, started: true, finalText: 'done' }); + expect(offered).toHaveLength(1); + expect(offered[0]).toMatchObject({ + type: 'peerd/actor-channel', protocol: 1, channelId: 'channel-one', + }); + expect(JSON.stringify(offered)).not.toContain('private job'); + expect(relayed).toEqual([{ + type: 'actor/model-call', payload: { args: { messages: [] } }, + }]); + }); + + test('Stop before the host accepts never sends or commits the job', async () => { + let transferred: MessagePort | null = null; + const abort = new AbortController(); + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, + findOffscreenClient: async () => ({ + postMessage: (_message: any, transfer: Transferable[]) => { + transferred = transfer[0] as MessagePort; + abort.abort(); + }, + }), + newChannelId: () => 'channel-abort', + }); + const messages: any[] = []; + const result = await client.run({}, { signal: abort.signal, relay: async () => ({}) }); + expect(result).toMatchObject({ started: false, aborted: true, outcomeKnown: true }); + expect(transferred).not.toBeNull(); + const port = transferred as unknown as MessagePort; + port.onmessage = (event) => messages.push(event.data); + port.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messages.some((message) => message.type === 'actor/open' + || message.type === 'actor/commit')).toBe(false); + port.close(); + }); + + test('missing exact offscreen client is a structured not-started failure', async () => { + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, findOffscreenClient: async () => null, + }); + await expect(client.run({}, { relay: async () => ({}) })).resolves.toMatchObject({ + ok: false, started: false, code: 'actor_host_not_ready', outcomeKnown: true, + }); + }); + + test('a host that never becomes ready is a structured not-started failure', async () => { + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, handshakeTimeoutMs: 5, + findOffscreenClient: async () => ({ postMessage: () => {} }), + }); + await expect(client.run({}, { relay: async () => ({}) })).resolves.toMatchObject({ + ok: false, started: false, phase: 'startup', + code: 'actor_channel_ready_timeout', outcomeKnown: true, + }); + }); + + test('a committed run that exceeds its channel budget settles unknown and aborts the host', async () => { + let aborted = 0; + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, runTimeoutMsFor: () => 5, + findOffscreenClient: async () => ({ + postMessage: (message: any, transfer: Transferable[]) => bindActorChannel({ + port: transfer[0] as MessagePort, + channelId: message.channelId, + workerUrl: '/offscreen/actor-worker.js', + abort: () => { aborted += 1; }, + run: async () => new Promise(() => {}), + }), + }), + }); + await expect(client.run({ runId: 'wedged-run' }, { relay: async () => ({}) })) + .resolves.toMatchObject({ + ok: false, started: true, phase: 'run', + code: 'actor_channel_run_timeout', outcomeKnown: false, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(aborted).toBe(1); + }); + + test('a duplicate relay request shares one privileged dispatch', async () => { + let dispatches = 0; + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, newChannelId: () => 'channel-dedupe', + findOffscreenClient: async () => ({ + postMessage: (offer: any, transfer: Transferable[]) => { + const port = transfer[0] as MessagePort; + port.onmessage = (event) => { + const message = event.data; + const common = { protocol: 1, channelId: offer.channelId }; + if (message.type === 'actor/open') { + port.postMessage({ ...common, type: 'actor/accepted' }); + } else if (message.type === 'actor/commit') { + const relay = { + ...common, type: 'actor/relay', requestId: 'same-request', + relayType: 'actor/tool-dispatch', payload: { call: { name: 'click' } }, + }; + port.postMessage(relay); + port.postMessage(relay); + } else if (message.type === 'actor/relay-response') { + port.postMessage({ + ...common, type: 'actor/result', result: { ok: true, started: true }, + }); + } + }; + port.start(); + port.postMessage({ protocol: 1, channelId: offer.channelId, type: 'channel/ready' }); + }, + }), + }); + await client.run({}, { + relay: async () => { dispatches += 1; return { ok: true }; }, + }); + expect(dispatches).toBe(1); + }); +}); diff --git a/tests/background/offscreen-actor-client.test.ts b/tests/background/offscreen-actor-client.test.ts index 6c8d4f65..b499ba74 100644 --- a/tests/background/offscreen-actor-client.test.ts +++ b/tests/background/offscreen-actor-client.test.ts @@ -70,6 +70,27 @@ const clientWithRelay = (over: any = {}) => { }; describe('run() — Stop-cascade aborted stamping', () => { + test('channel transport binds a relay without serializing its grant', async () => { + let sentJob: any = null; + let relayResult: any = null; + const client = makeOffscreenActorClient(baseDeps({ + sessions: { get: async () => ({ kind: 'actor', actorType: 'web', backing: 'api' }) }, + dispatchToolCall: async () => ({ ok: true, content: 'ran' }), + runOnChannel: async (job: any, { relay }: any) => { + sentJob = job; + relayResult = await relay('actor/tool-dispatch', { call: { name: 'fetch_url', args: {} } }); + return { ok: true, started: true, finalText: 'done' }; + }, + })); + const result = await client.run({ + actorSessionId: 'actor-channel', message: 'm', systemPrompt: 's', + provider: 'anthropic', model: 'm', + } as any); + expect(sentJob.relayToken).toBeUndefined(); + expect(relayResult).toEqual({ ok: true, result: { ok: true, content: 'ran' } }); + expect(result.finalText).toBe('done'); + }); + test('stamps aborted when the signal fired and the turn produced NO reply', async () => { // The worker can unwind an abort CLEANLY (empty reply, no error) → looks ok at the // result shape. signal.aborted here is the authoritative proof a Stop hit this run. @@ -521,9 +542,9 @@ describe('relay grant — routes refuse anything without a live token', () => { }); }); -// The sender pin. `runtime.sendMessage` from the SW cannot address one context, so -// the actor/run job — grant token included — is broadcast to the side panel and to -// every engine tab page. A page that keeps the token must still get nowhere. +// Firefox's private background host still uses a token to bind each relay to a +// live run. Even there, a token-bearing call from a different extension page +// must get nowhere. Chrome closes the grant over a targeted MessageChannel. describe('relay sender pin — a leaked token is useless from any other page', () => { const dispatchDeps = { sessions: { get: async () => ({ kind: 'actor', actorType: 'webvm', instanceId: 'vm-1' }) }, diff --git a/tests/background/private-transfer-port.test.ts b/tests/background/private-transfer-port.test.ts index df64b543..7887b33c 100644 --- a/tests/background/private-transfer-port.test.ts +++ b/tests/background/private-transfer-port.test.ts @@ -1,37 +1,63 @@ import { describe, expect, test } from 'bun:test'; -import { makePrivateTransferPort } from '../../extension/background/private-transfer-port.js'; +import { + makePrivateTransferOpenRoute, makePrivateTransferPort, +} from '../../extension/background/private-transfer-port.js'; import { makePrivateTransferClient } from '../../extension/options/private-transfer-client.js'; -const makeEvent = () => { - const listeners: Array<(message?: any) => void> = []; - return { - addListener: (listener: (message?: any) => void) => { listeners.push(listener); }, - emit: (message?: any) => { for (const listener of listeners) listener(message); }, - }; -}; +describe('private transfer MessageChannel', () => { + test('offers a channel only to the exact requesting options WindowClient', async () => { + const offers: any[] = []; + let attached = 0; + const route = makePrivateTransferOpenRoute({ + isOptionsSender: (sender: any) => sender?.url === 'chrome-extension://id/options/options.html', + optionsUrl: 'chrome-extension://id/options/options.html', + attach: () => { attached += 1; }, + listWindowClients: async () => [ + { + id: 'options-document', url: 'chrome-extension://id/options/options.html#!/transfer', + postMessage: (message, transfer) => offers.push({ message, transfer }), + }, + { + id: 'engine-document', url: 'chrome-extension://id/engine-tabs/app-tab/index.html', + postMessage: () => { throw new Error('engine must not receive a channel'); }, + }, + ], + }); + await expect(route( + { requestId: 'offer-1' }, + { url: 'chrome-extension://id/options/options.html', documentId: 'options-document' }, + )).resolves.toEqual({ ok: true }); + expect(attached).toBe(1); + expect(offers).toHaveLength(1); + expect(offers[0].message).toMatchObject({ + type: 'private-transfer/channel', requestId: 'offer-1', + }); + expect(offers[0].transfer).toHaveLength(1); -const makePortPair = () => { - const clientMessage = makeEvent(); - const serverMessage = makeEvent(); - const disconnect = makeEvent(); - return { - client: { - onMessage: clientMessage, onDisconnect: disconnect, - postMessage: (message: any) => serverMessage.emit(message), - } as any, - server: { - onMessage: serverMessage, onDisconnect: disconnect, - postMessage: (message: any) => clientMessage.emit(message), - } as any, - disconnect: () => disconnect.emit(), - }; -}; + await expect(route( + { requestId: 'forged' }, + { url: 'chrome-extension://id/engine-tabs/app-tab/index.html', documentId: 'engine-document' }, + )).resolves.toEqual({ ok: false, error: 'private-transfer-channel-refused' }); + expect(attached).toBe(1); + }); -const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0)); + test('refuses an ambiguous or query-bearing options WindowClient', async () => { + const route = makePrivateTransferOpenRoute({ + isOptionsSender: () => true, + optionsUrl: 'chrome-extension://id/options/options.html', + attach: () => { throw new Error('must not attach'); }, + listWindowClients: async () => [ + { url: 'chrome-extension://id/options/options.html#!/transfer', postMessage: () => {} }, + { url: 'chrome-extension://id/options/options.html#!/security', postMessage: () => {} }, + { url: 'chrome-extension://id/options/options.html?forged=1', postMessage: () => {} }, + ], + }); + await expect(route({ requestId: 'ambiguous' }, { + url: 'chrome-extension://id/options/options.html', documentId: 'undocumented-id', + })).resolves.toEqual({ ok: false, error: 'private-transfer-channel-target-missing' }); + }); -describe('private transfer Port', () => { test('carries the password only to the attached handler with an unforgeable capability', async () => { - const pair = makePortPair(); const authorization = Symbol('transfer'); const received: any[] = []; const server = makePrivateTransferPort({ @@ -43,9 +69,15 @@ describe('private transfer Port', () => { }, }, }); - server.attach(pair.server); - const client = makePrivateTransferClient({ - connect: () => pair.client, newRequestId: () => 'request-1', timeoutMs: 100, + let client: ReturnType; + client = makePrivateTransferClient({ + requestChannel: (requestId) => { + const channel = new MessageChannel(); + server.attach(channel.port1); + client.acceptChannel(requestId, channel.port2); + }, + newRequestId: (() => { let sequence = 0; return () => `request-${++sequence}`; })(), + timeoutMs: 100, }); await expect(client.call({ type: 'transfer/export', passphrase: 'private password' })) .resolves.toEqual({ ok: true, payload: { version: 2 } }); @@ -56,16 +88,108 @@ describe('private transfer Port', () => { }); }); - test('ignores non-transfer operations and rejects pending work on disconnect', async () => { - const pair = makePortPair(); + test('uses a fresh channel for the next completed operation', async () => { + let offers = 0; + const server = makePrivateTransferPort({ + authorization: Symbol('transfer'), + handlers: { 'transfer/export': async () => ({ ok: true }) }, + }); + let client: ReturnType; + client = makePrivateTransferClient({ + requestChannel: (requestId) => { + offers += 1; + const channel = new MessageChannel(); + server.attach(channel.port1); + client.acceptChannel(requestId, channel.port2); + }, + timeoutMs: 100, + }); + await expect(client.call({ type: 'transfer/export' })).resolves.toEqual({ ok: true }); + await expect(client.call({ type: 'transfer/export' })).resolves.toEqual({ ok: true }); + expect(offers).toBe(2); + }); + + test('ignores non-transfer operations and resets the channel after timeout', async () => { const server = makePrivateTransferPort({ authorization: Symbol(), handlers: {} }); - server.attach(pair.server); + let offers = 0; + let client: ReturnType; + client = makePrivateTransferClient({ + requestChannel: (requestId) => { + offers += 1; + const channel = new MessageChannel(); + server.attach(channel.port1); + client.acceptChannel(requestId, channel.port2); + }, + newRequestId: (() => { let sequence = 0; return () => `timeout-${++sequence}`; })(), + timeoutMs: 5, + }); + await expect(client.call({ type: 'vault/export', passphrase: 'nope' })) + .rejects.toMatchObject({ code: 'timeout' }); + await expect(client.call({ type: 'vault/export', passphrase: 'nope' })) + .rejects.toMatchObject({ code: 'timeout' }); + expect(offers).toBe(2); + }); + + test('fails an open-route refusal immediately instead of waiting for timeout', async () => { const client = makePrivateTransferClient({ - connect: () => pair.client, newRequestId: () => 'request-2', timeoutMs: 100, + requestChannel: async () => { throw new Error('private-transfer-channel-target-missing'); }, + timeoutMs: 1_000, + }); + const started = Date.now(); + await expect(client.call({ type: 'transfer/export' })) + .rejects.toMatchObject({ code: 'channel-request-failed' }); + expect(Date.now() - started).toBeLessThan(100); + }); + + test('drops a dead channel after postMessage throws and reconnects', async () => { + let offers = 0; + let client: ReturnType; + client = makePrivateTransferClient({ + requestChannel: (requestId) => { + offers += 1; + const fakePort: any = { + onmessage: null, onmessageerror: null, + start() { + queueMicrotask(() => this.onmessage?.({ data: { type: 'private-transfer/ready' } })); + }, + addEventListener: () => {}, close: () => {}, + postMessage: () => { throw new Error('closed'); }, + }; + client.acceptChannel(requestId, fakePort); + }, + timeoutMs: 100, }); - const pending = client.call({ type: 'vault/export', passphrase: 'nope' }); - await nextTask(); - pair.disconnect(); - await expect(pending).rejects.toMatchObject({ code: 'port-disconnected' }); + await expect(client.call({ type: 'transfer/export' })) + .rejects.toMatchObject({ code: 'post-failed' }); + await expect(client.call({ type: 'transfer/export' })) + .rejects.toMatchObject({ code: 'post-failed' }); + expect(offers).toBe(2); + }); + + test('supports the Firefox exact-options runtime Port fallback', async () => { + const listeners: Array<(message: any) => void> = []; + const disconnects: Array<() => void> = []; + const server = makePrivateTransferPort({ + authorization: Symbol('firefox-transfer'), + handlers: { 'transfer/export': async () => ({ ok: true, browser: 'firefox' }) }, + }); + const runtimePort: any = { + onMessage: { addListener: (listener: (message: any) => void) => listeners.push(listener) }, + onDisconnect: { addListener: (listener: () => void) => disconnects.push(listener) }, + postMessage(message: any) { + if (message.type === 'private-transfer/request') serverPort.onMessage.emit(message); + else for (const listener of listeners) listener(message); + }, + disconnect() { for (const listener of disconnects) listener(); }, + }; + const serverPort: any = { + onMessage: { listener: null as null | ((message: any) => void), addListener(listener: (message: any) => void) { this.listener = listener; }, emit(message: any) { this.listener?.(message); } }, + onDisconnect: { addListener: () => {} }, + postMessage: (message: any) => runtimePort.postMessage(message), + }; + server.attach(serverPort); + const client = makePrivateTransferClient({ connect: () => runtimePort, timeoutMs: 100 }); + await expect(client.call({ type: 'transfer/export', passphrase: 'secret' })) + .resolves.toEqual({ ok: true, browser: 'firefox' }); }); }); diff --git a/tests/meta/runtime-connect-exclusivity.test.ts b/tests/meta/runtime-connect-exclusivity.test.ts index 7f6e1e4d..b1c74c63 100644 --- a/tests/meta/runtime-connect-exclusivity.test.ts +++ b/tests/meta/runtime-connect-exclusivity.test.ts @@ -1,7 +1,6 @@ -// Confidential extension Ports rely on one shipped runtime.onConnect receiver. -// Chrome permits multiple receivers, so adding a listener anywhere outside the -// service worker would turn offscreen or options-originated messages into a -// broadcast. Keep that architectural boundary fail-closed in CI. +// Firefox backup transfer uses one exact-sender background Port. Chrome uses a +// targeted WindowClient MessageChannel because runtime Ports can have multiple +// receivers. Keep both boundaries fail-closed in CI. import { describe, expect, test } from 'bun:test'; import { readFileSync, readdirSync, statSync } from 'node:fs'; @@ -30,6 +29,22 @@ describe('runtime Port receiver exclusivity', () => { expect(registrations).toEqual(['background/service-worker.js']); }); + test('Chrome actor jobs and relays are absent from runtime messaging', () => { + const serviceWorker = stripComments(readFileSync( + join(EXTENSION_DIR, 'background/service-worker.js'), 'utf8', + )); + const offscreen = stripComments(readFileSync( + join(EXTENSION_DIR, 'offscreen/offscreen.js'), 'utf8', + )); + const dispatcher = serviceWorker.slice( + serviceWorker.indexOf('browser.runtime.onMessage.addListener'), + ); + expect(dispatcher).not.toContain('actorClient?.routes'); + expect(offscreen).not.toMatch(/['"]actor\/(?:run|abort)['"]/); + expect(serviceWorker).toContain('makeOffscreenActorChannelClient'); + expect(offscreen).toContain('bindActorChannel'); + }); + test('no source aliases onConnect outside the guarded registration', () => { const offenders = filesUnder(EXTENSION_DIR) .filter((path) => path.endsWith('.js') || path.endsWith('.mjs')) diff --git a/tests/red-team/scenarios/06-sandbox-escape.ts b/tests/red-team/scenarios/06-sandbox-escape.ts index b3cc8e56..83397cd6 100644 --- a/tests/red-team/scenarios/06-sandbox-escape.ts +++ b/tests/red-team/scenarios/06-sandbox-escape.ts @@ -25,7 +25,7 @@ import { applyRealmSeal } from '../../../extension/engine-tabs/notebook-tab/note import { resolveRelativePath } from '../../../extension/peerd-engine/module-resolver.js'; import { composeApp, stripMetaRefresh } from '../../../extension/peerd-engine/app-compose.js'; import { normalizeRequest, needsWebWriteConfirm } from '../../../extension/peerd-engine/vm-net/http-bridge.js'; -import { isServiceWorkerSender } from '../../../extension/shared/sender-trust.js'; +import { makeOffscreenActorChannelClient } from '../../../extension/background/offscreen-actor-channel-client.js'; import { buildWorkerSource } from '../../../extension/engine-tabs/notebook-tab/worker-source.js'; import { formatEvalResult } from '../../../extension/peerd-runtime/tools/defs/js-notebook.js'; @@ -69,7 +69,7 @@ export const scenario: Scenario = { title: 'Sandbox escape (Notebook worker, App iframe, WebVM)', adversary: 'malicious sandboxed code', asset: 'the host origin, the network, and other sandbox instances', - claim: 'Across all three sandbox kinds, confinement holds: the Notebook realm exposes only the audited fetch bridge (raw channels throw, native fetch unrecoverable, bridge un-unseatable) and no same-origin durable store; the Cache API and IndexedDB both throw, so the sealed extension-origin worker cannot reach the `peerd` database; a remote module restricts its whole run to compute only and all remote-controlled output is fenced; an App cannot break out of its iframe or impersonate the service worker to issue actor commands; and the WebVM HTTP bridge refuses non-http(s) schemes, scrubs CRLF header injection, drops any smuggled auth field, and confirms body-bearing verbs.', + claim: 'Across all three sandbox kinds, confinement holds: the Notebook realm exposes only the audited fetch bridge (raw channels throw, native fetch unrecoverable, bridge un-unseatable) and no same-origin durable store; the Cache API and IndexedDB both throw, so the sealed extension-origin worker cannot reach the `peerd` database; a remote module restricts its whole run to compute only and all remote-controlled output is fenced; an App cannot break out of its iframe or observe a targeted actor job; and the WebVM HTTP bridge refuses non-http(s) schemes, scrubs CRLF header injection, drops any smuggled auth field, and confirms body-bearing verbs.', threatModelRef: 'INV-6', tier: 'unit', async run() { @@ -204,27 +204,42 @@ export const scenario: Scenario = { ? blocked('meta-refresh the App frame to an attacker URL', 'meta http-equiv=refresh stripped from the app HTML') : leaked('meta-refresh the App frame to an attacker URL', 'meta refresh survived')); - const id = 'red-team-extension'; - const origin = `chrome-extension://${id}/`; - const trust = { - runtimeId: id, - extensionOrigin: origin, - serviceWorkerUrl: `${origin}background/service-worker.js`, - }; - const engineTabDenied = !isServiceWorkerSender({ - id, - url: `${origin}engine-tabs/app-tab/index.html`, - tab: { id: 42 }, - }, trust); - const exactWorkerAccepted = isServiceWorkerSender({ - id, - url: trust.serviceWorkerUrl, - }, trust); - probes.push(engineTabDenied && exactWorkerAccepted - ? blocked('replay a broadcast actor command from a first-party engine tab', - 'exact service-worker source accepted; same-extension tab provenance rejected') - : leaked('replay a broadcast actor command from a first-party engine tab', - `engineTabDenied=${engineTabDenied} exactWorkerAccepted=${exactWorkerAccepted}`)); + let publicOffer: any = null; + let privateJob: any = null; + const client = makeOffscreenActorChannelClient({ + ensureOffscreen: async () => {}, + findOffscreenClient: async () => ({ + postMessage: (offer: any, transfer: Transferable[]) => { + publicOffer = offer; + const port = transfer[0] as MessagePort; + port.onmessage = (event) => { + const message = event.data; + if (message.type === 'actor/open') { + privateJob = message.job; + port.postMessage({ ...offer, type: 'actor/accepted' }); + } else if (message.type === 'actor/commit') { + port.postMessage({ + ...offer, type: 'actor/result', + result: { ok: true, started: true, finalText: 'done' }, + }); + setTimeout(() => port.close(), 0); + } + }; + port.start(); + port.postMessage({ ...offer, type: 'channel/ready' }); + }, + }), + }); + const actorResult = await client.run({ runId: 'red-team-run', message: 'private-marker' }, { + relay: async () => ({ ok: true }), + }); + const offerIsNonsecret = !JSON.stringify(publicOffer).includes('private-marker'); + const endpointReceivedJob = privateJob?.message === 'private-marker'; + probes.push(offerIsNonsecret && endpointReceivedJob && actorResult?.ok + ? blocked('observe an actor job from a first-party engine tab', + 'the targeted channel offer carries no job or authority; the job moves only over the transferred endpoint') + : leaked('observe an actor job from a first-party engine tab', + `offerIsNonsecret=${offerIsNonsecret} endpointReceivedJob=${endpointReceivedJob}`)); } // 8) WebVM HTTP bridge: the guest cannot pick a dangerous scheme, inject @@ -260,7 +275,7 @@ export const scenario: Scenario = { 'resolveRelativePath (OPFS ".." collapse)', 'buildWorkerSource + formatEvalResult (remote graph capability collapse + output fence)', 'composeApp + stripMetaRefresh (App iframe breakout/navigation defense)', - 'isServiceWorkerSender (actor-command source pin)', + 'makeOffscreenActorChannelClient (exact-client channel transfer)', 'normalizeRequest + needsWebWriteConfirm (WebVM bridge scheme/CRLF/auth/confirm)', ]); // The pure seal runs here; the real-worker-realm proof (and the a2a run's @@ -274,7 +289,7 @@ export const scenario: Scenario = { 'tests/peerd-engine/single-module-linker.test.ts (seal-first graph with no child loads)', 'extension/tests/unit/red-team/sandbox-escape.test.js (in-browser red-team framing)', 'scripts/firefox/run-runtime-tests.mjs (opaque worker host, string-compilation refusal, cancellable compiler and fetch, local and remote graph parity)', - 'scripts/cdp/states.mjs actor-command-sender-pin (live engine-tab forgery)', + 'scripts/cdp/states.mjs actor-channel-targeting (live sibling-observer probe)', 'scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)', ].join('; '); return result;