From 23407974d2b1061f4efbd645242c83a7210dec19 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:35:29 +0400 Subject: [PATCH] =?UTF-8?q?ext:=20capture/index.ts=20=E2=80=94=20blocking?= =?UTF-8?q?=20onHeadersReceived=20hook,=20fail-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a response shouldCapture() likes: gather cookies, offer to the daemon under a hard 750 ms budget, and return {cancel:true} ONLY on an explicit {action:"take"}. Every other outcome — shouldCapture says no, daemon down / slow / erroring, cookies fail, anything throws — resolves to {} and Firefox downloads normally. Fail-open tests written first (tests/capture/hook.test.ts): offer() rejects, offer() never settles (resolves within budget), timeout-shaped rejection, getRules() throws, malformed details. Plus the happy paths and a check that the capture.offer payload is well-formed from details + stash + headers. background/index.ts: wire the stash + hook onto browser.webRequest, mirror capture rules via capture.getRules on connect and on a capture.* settings change; DEFAULT_CAPTURE_RULES (enabled:false) until the first mirror lands. 84 tests green; web-ext lint clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk --- extension/src/background/capture/index.ts | 228 ++++++++++++++++++++++ extension/src/background/capture/rules.ts | 13 ++ extension/src/background/index.ts | 42 +++- extension/tests/capture/hook.test.ts | 199 +++++++++++++++++++ 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 extension/src/background/capture/index.ts create mode 100644 extension/tests/capture/hook.test.ts diff --git a/extension/src/background/capture/index.ts b/extension/src/background/capture/index.ts new file mode 100644 index 0000000..cb48757 --- /dev/null +++ b/extension/src/background/capture/index.ts @@ -0,0 +1,228 @@ +// The blocking onHeadersReceived hook. +// +// For a response that shouldCapture() likes: gather cookies, offer it to the daemon with +// a hard 750 ms budget, and cancel the browser's own download ONLY on an explicit +// {action:"take"}. Everything else — shouldCapture says no, the daemon is down / slow / +// erroring, cookies fail, anything throws — resolves to {} and Firefox downloads +// normally. Fail-open is not a fallback path here; it is the default every branch returns +// to. tests/capture/hook.test.ts pins that. + +import type { + CaptureOfferParams, + CaptureOfferResult, + CaptureRules, + Cookie, +} from '../../shared/protocol/index.js'; + +import { normalizeHeaders } from './headers.js'; +import type { HeaderStash } from './headers.js'; +import { filenameExtension, shouldCapture, type CaptureCandidate, type ResourceType } from './rules.js'; + +const BUDGET_MS = 750; // docs/05 §2 and METHODS['capture.offer'].deadlineMs +const PROCEED: BlockingResponse = {}; +const CANCEL: BlockingResponse = { cancel: true }; + +export interface BlockingResponse { + cancel?: boolean; +} + +export interface OnHeadersReceivedDetails { + requestId: string; + url: string; + method: string; + type: ResourceType; + statusCode: number; + tabId: number; + responseHeaders?: { name: string; value?: string }[]; + documentUrl?: string | null; + originUrl?: string | null; +} + +interface WebRequestEvent void> { + addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void; + removeListener(listener: L): void; +} + +export interface HeadersReceivedWebRequest { + onHeadersReceived: WebRequestEvent< + (d: OnHeadersReceivedDetails) => BlockingResponse | Promise + >; +} + +/** A browser cookie, as `browser.cookies.getAll` returns it (superset of the wire type). */ +export interface CookieRecord { + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; +} + +export interface CaptureHookDeps { + /** Usually `(p, o) => transport.call('capture.offer', p, o)`. */ + offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise; + stash: Pick; + getCookies: (url: string) => Promise; + /** A synchronous snapshot of the daemon's capture policy (mirrored via capture.getRules). */ + getRules: () => CaptureRules; + /** Whether the user held the bypass modifier for the click behind this request. */ + bypassHeld?: (details: OnHeadersReceivedDetails) => boolean; + /** moz-extension://, for CaptureOfferParams.origin. */ + origin?: string; + budgetMs?: number; +} + +const ALL_URLS = ''; + +export class CaptureHook { + private readonly budgetMs: number; + private bound?: (d: OnHeadersReceivedDetails) => Promise; + private attachedTo?: HeadersReceivedWebRequest; + + constructor(private readonly deps: CaptureHookDeps) { + this.budgetMs = deps.budgetMs ?? BUDGET_MS; + } + + /** The listener. Always resolves within the budget to {} or {cancel:true}; never rejects. */ + handle = (details: OnHeadersReceivedDetails): Promise => { + return this.withinBudget(this.decide(details)); + }; + + attach(webRequest: HeadersReceivedWebRequest): void { + if (this.attachedTo) throw new Error('CaptureHook is already attached'); + this.attachedTo = webRequest; + this.bound = this.handle; + webRequest.onHeadersReceived.addListener(this.bound, { urls: [ALL_URLS] }, [ + 'blocking', + 'responseHeaders', + ]); + } + + detach(): void { + if (this.attachedTo && this.bound) { + this.attachedTo.onHeadersReceived.removeListener(this.bound); + } + this.attachedTo = undefined; + this.bound = undefined; + } + + private withinBudget(work: Promise): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: BlockingResponse): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => done(PROCEED), this.budgetMs); + (timer as { unref?: () => void }).unref?.(); + work.then(done, () => done(PROCEED)); + }); + } + + private async decide(details: OnHeadersReceivedDetails): Promise { + try { + const rules = this.deps.getRules(); + const responseHeaders = normalizeHeaders(details.responseHeaders); + const stashed = this.stashPeek(details.requestId); + + const candidate: CaptureCandidate = { + url: details.url, + method: details.method, + statusCode: details.statusCode, + type: details.type, + responseHeaders, + requestHeaders: stashed?.headers ?? {}, + documentUrl: details.documentUrl ?? details.originUrl ?? null, + bypassHeld: this.deps.bypassHeld?.(details) ?? false, + }; + + if (!shouldCapture(candidate, rules).capture) return PROCEED; + + // We are acting on this request now — take the stash entry. + const taken = this.deps.stash.take(details.requestId); + const requestHeaders = taken?.headers ?? candidate.requestHeaders; + + let cookies: Cookie[] = []; + try { + cookies = toWireCookies(await this.deps.getCookies(details.url)); + } catch { + cookies = []; // best-effort; still worth offering + } + + const params: CaptureOfferParams = { + url: details.url, + method: candidate.method.toUpperCase() === 'POST' ? 'POST' : 'GET', + tabUrl: candidate.documentUrl ?? '', + headers: requestHeaders, + cookies, + contentType: responseHeaders['content-type'] ?? null, + contentLength: intOrNull(responseHeaders['content-length']), + contentDisposition: responseHeaders['content-disposition'] ?? null, + filename: guessFilename(details.url, responseHeaders['content-disposition']), + referrer: requestHeaders['referer'] ?? null, + origin: this.deps.origin ?? null, + requestId: details.requestId, + }; + + const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); + return result.action === 'take' ? CANCEL : PROCEED; + } catch { + return PROCEED; // fail open on anything at all + } + } + + private stashPeek(requestId: string) { + try { + return this.deps.stash.peek(requestId); + } catch { + return undefined; + } + } +} + +// --- helpers ---------------------------------------------------------------------- + +function toWireCookies(records: CookieRecord[]): Cookie[] { + return records.map((c) => { + const out: Cookie = { name: c.name, value: c.value }; + if (c.domain !== undefined) out.domain = c.domain; + if (c.path !== undefined) out.path = c.path; + if (c.secure !== undefined) out.secure = c.secure; + if (c.httpOnly !== undefined) out.httpOnly = c.httpOnly; + return out; + }); +} + +function intOrNull(value: string | undefined): number | null { + if (value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** RFC 6266 filename, falling back to the URL's last path segment. */ +export function guessFilename(url: string, disposition: string | undefined): string | null { + if (disposition) { + const star = /filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)/i.exec(disposition); + if (star?.[1]) { + try { + return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')) || null; + } catch { + /* fall through */ + } + } + const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition); + if (plain?.[1]) return plain[1].trim() || null; + } + try { + const path = new URL(url).pathname; + const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1)); + if (base) return base; + } catch { + /* not a parseable URL */ + } + const ext = filenameExtension(url); + return ext ? `download.${ext}` : null; +} diff --git a/extension/src/background/capture/rules.ts b/extension/src/background/capture/rules.ts index 339e091..96b7bef 100644 --- a/extension/src/background/capture/rules.ts +++ b/extension/src/background/capture/rules.ts @@ -63,6 +63,19 @@ export interface CaptureDecision { reason: CaptureReason; } +/** + * What shouldCapture() runs against before the first capture.getRules has been mirrored + * from the daemon. `enabled: false` — we intercept nothing until we know the real policy. + */ +export const DEFAULT_CAPTURE_RULES: CaptureRules = { + enabled: false, + monitoredExtensions: [], + monitoredMimeTypes: [], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 0, +}; + const RENDERABLE_TYPES = new Set([ 'text/html', 'application/xhtml+xml', diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index eb846c0..ad2be03 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,20 +1,58 @@ // Background event-page entry. // -// Brings the transport up and holds the single shared reference to it. Capture, the -// popup relay, and context menus attach here as they land (docs/05 §6 build order). +// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture +// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety +// net. The popup relay and context menus attach here in later steps of the build order. +import { CaptureHook } from './capture/index.js'; +import { HeaderStash } from './capture/headers.js'; +import type { WebRequestLike } from './capture/headers.js'; +import type { HeadersReceivedWebRequest } from './capture/index.js'; +import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; +import type { CaptureRules } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; +let rules: CaptureRules = DEFAULT_CAPTURE_RULES; + +const stash = new HeaderStash(); +const hook = new CaptureHook({ + offer: (params, opts) => mustTransport().call('capture.offer', params, opts), + stash, + getCookies: (url) => browser.cookies.getAll({ url }), + getRules: () => rules, + origin: new URL(browser.runtime.getURL('/')).origin, +}); + +function mustTransport(): VeloxTransport { + if (!transport) throw new Error('transport not ready'); + return transport; +} + +async function refreshRules(): Promise { + try { + rules = await mustTransport().call('capture.getRules', {}); + } catch { + // Keep the last known rules; capture stays fail-open regardless (docs/05 §2). + } +} function onTransportState(status: TransportStatus): void { const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`); + if (status.state === 'connected') void refreshRules(); } async function start(): Promise { + stash.attach(browser.webRequest as unknown as WebRequestLike); + hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest); + transport = await createTransport(); transport.onStateChange(onTransportState); + transport.on('event.settings.changed', (payload) => { + const keys = (payload as { keys?: string[] }).keys ?? []; + if (keys.some((k) => k.startsWith('capture.'))) void refreshRules(); + }); onTransportState(transport.status); } diff --git a/extension/tests/capture/hook.test.ts b/extension/tests/capture/hook.test.ts new file mode 100644 index 0000000..f1d7621 --- /dev/null +++ b/extension/tests/capture/hook.test.ts @@ -0,0 +1,199 @@ +// The blocking onHeadersReceived hook. +// +// Fail-open is the hard requirement (docs/05 §2, AGENT-EXT M1 DoD): daemon down, slow, or +// erroring must never cost the user a download. Those cases are written first below. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; +import { + CaptureHook, + type CaptureHookDeps, + type HeadersReceivedWebRequest, + type OnHeadersReceivedDetails, +} from '../../src/background/capture/index.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso'], + monitoredMimeTypes: ['application/octet-stream'], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 1, +}; + +function detailsFor(over: Partial = {}): OnHeadersReceivedDetails { + return { + requestId: '42', + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + type: 'other', + statusCode: 200, + tabId: 3, + responseHeaders: [ + { name: 'Content-Type', value: 'application/octet-stream' }, + { name: 'Content-Length', value: String(900 * 1024 * 1024) }, + ], + documentUrl: 'https://example.com/releases', + ...over, + }; +} + +interface Harness { + hook: CaptureHook; + offer: ReturnType; + stash: { take: ReturnType; peek: ReturnType }; + getCookies: ReturnType; +} + +function makeHook(over: Partial = {}): Harness { + const { offer: offerOver, getCookies: cookiesOver, stash: stashOver, ...rest } = over; + const stashEntry = { requestId: '42', url: '', method: 'GET', tabId: 3, stashedAt: 0, headers: { referer: 'https://example.com/releases' } }; + const stash = (stashOver as unknown as Harness['stash']) ?? { + take: vi.fn(() => stashEntry), + peek: vi.fn(() => stashEntry), + }; + const offer = + (offerOver as unknown as ReturnType) ?? + vi.fn(async (): Promise => ({ action: 'ignore', reason: 'type_not_monitored' })); + const getCookies = + (cookiesOver as unknown as ReturnType) ?? + vi.fn(async () => [{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + const deps: CaptureHookDeps = { + offer: offer as unknown as CaptureHookDeps['offer'], + stash: stash as unknown as CaptureHookDeps['stash'], + getCookies: getCookies as unknown as CaptureHookDeps['getCookies'], + getRules: () => RULES, + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + budgetMs: 40, + ...rest, + }; + return { hook: new CaptureHook(deps), offer, stash, getCookies }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CaptureHook — fail-open (written before the feature)', () => { + it('daemon down: offer() rejects → Firefox downloads normally', async () => { + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(new Error('transport is not connected')) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('daemon slow: offer() never settles → resolves to {} within the budget', async () => { + const { hook } = makeHook({ offer: vi.fn(() => new Promise(() => {})), budgetMs: 30 }); + const started = Date.now(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(Date.now() - started).toBeGreaterThanOrEqual(20); + expect(Date.now() - started).toBeLessThan(300); + }); + + it('daemon erroring: offer() rejects with a timeout-like error → {}', async () => { + const err = Object.assign(new Error('capture.offer timed out after 750 ms'), { name: 'RpcTimeoutError' }); + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(err) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('getRules() throws → {} and no offer attempted', async () => { + const offer = vi.fn(); + const { hook } = makeHook({ + offer: offer as unknown as CaptureHookDeps['offer'], + getRules: () => { + throw new Error('rules not loaded'); + }, + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + }); + + it('the listener never rejects, whatever the details', async () => { + const { hook } = makeHook(); + // @ts-expect-error deliberately malformed + await expect(hook.handle({})).resolves.toEqual({}); + }); +}); + +describe('CaptureHook — the happy paths', () => { + it('shouldCapture says no → {} and the offer is not made, stash not consumed', async () => { + const { hook, offer, stash } = makeHook({ getRules: () => ({ ...RULES, enabled: false }) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + expect(stash.take).not.toHaveBeenCalled(); + }); + + it('offer → {action:"take"} cancels the browser download', async () => { + const { hook, offer, stash } = makeHook({ + offer: vi.fn(async () => ({ action: 'take', taskId: 't1' })) as unknown as CaptureHookDeps['offer'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(offer).toHaveBeenCalledOnce(); + expect(stash.take).toHaveBeenCalledWith('42'); + }); + + it('offer → {action:"ignore"} lets Firefox proceed', async () => { + const { hook } = makeHook(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('builds a well-formed capture.offer from details + stash + response headers', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (vi.fn(async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as unknown) as CaptureHookDeps['offer'], + }); + await hook.handle(detailsFor()); + expect(seen[0]).toMatchObject({ + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + tabUrl: 'https://example.com/releases', + contentType: 'application/octet-stream', + contentLength: 900 * 1024 * 1024, + referrer: 'https://example.com/releases', + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + requestId: '42', + filename: 'ubuntu.iso', + }); + expect(seen[0]!.cookies).toEqual([{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + }); + + it('still offers when cookie collection fails, with an empty cookie list', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as CaptureHookDeps['offer'], + getCookies: (async () => { + throw new Error('no cookies perm'); + }) as CaptureHookDeps['getCookies'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(seen[0]!.cookies).toEqual([]); + }); +}); + +describe('CaptureHook.attach', () => { + class FakeEvent { + listeners: Array<(d: OnHeadersReceivedDetails) => unknown> = []; + addListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => void this.listeners.push(l); + removeListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => { + this.listeners = this.listeners.filter((x) => x !== l); + }; + } + + it('registers a blocking responseHeaders listener and unregisters on detach', () => { + const evt = new FakeEvent(); + const wr = { onHeadersReceived: evt } as unknown as HeadersReceivedWebRequest; + const { hook } = makeHook(); + + hook.attach(wr); + expect(evt.listeners).toHaveLength(1); + expect(() => hook.attach(wr)).toThrow(/already attached/); + + hook.detach(); + expect(evt.listeners).toHaveLength(0); + }); +});