import { randomUUID } from "node:crypto"; import { createBrowserBridge } from "./bridge.ts"; import { BrowserOperationError, FAST_BROWSER_ARTIFACT_LIMITS, } from "./contracts.ts"; import type { ActRequest, ActResult, AssertRequest, AssertResult, BrowserErrorCode, BrowserErrorDetails, BrowserOperation, BrowserRuntime, BrowserRuntimeFactory, BrowserRuntimeOperationName, BrowserRuntimeOptions, BrowserTab, BrowserTabId, BrowserTransport, BrowserTransportEvidence, BrowserTransportFactory, CloseTabRequest, CloseTabResult, ConsoleFailureSummary, EvidenceRequest, FailureEvidence, NavigateRequest, NavigationResult, NarrowOriginPolicyRequest, Observation, PageRead, PrivateArtifactKind, PrivateArtifactMetadata, PrivateArtifactStore, ReadRequest, ReloadRequest, ReloadResult, RuntimeOperationContext, RuntimeOriginPolicy, SelectTabRequest, SelectTabResult, WaitPopupRequest, WaitPopupResult, } from "./contracts.ts"; /** * Create one persistent runtime factory. The transport is supplied by the * transport worker (and owns JSONL, browser-harness and teardown); this module * owns session policy, tab identity, serialization and safe result handling. */ export function createBrowserRuntimeFactory( transportFactory?: BrowserTransportFactory, ): BrowserRuntimeFactory { return { create(options, parentSignal) { return createBrowserRuntime(options, parentSignal, transportFactory); }, }; } const MAX_VISIBLE_TEXT_BYTES = 50 * 1024; const MAX_VISIBLE_TEXT_LINES = 2_000; const MAX_TABS = 100; const MAX_EVIDENCE_ITEMS = 64; const MAX_ERROR_MESSAGE = 400; const MAX_ARTIFACT_ID = 200; const OPERATION_BUDGETS: Readonly> = { observe: 10_000, act: 10_000, read: 3_000, assert: 3_000, navigate: 10_000, reload: 10_000, listTabs: 5_000, selectTab: 5_000, waitPopup: 5_000, closeTab: 10_000, captureEvidence: 2_000, narrowOriginPolicy: 3_000, close: 10_000, }; function safeMessage(value: unknown, fallback = "browser operation failed"): string { let message = value instanceof Error ? value.message : typeof value === "string" ? value : fallback; message = message.replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim(); if (!message) return fallback; return message.length > MAX_ERROR_MESSAGE ? `${message.slice(0, MAX_ERROR_MESSAGE - 1)}…` : message; } function abortError(): Error { const error = new Error("browser operation cancelled"); error.name = "AbortError"; return error; } function throwIfAborted(signal: AbortSignal): void { if (signal.aborted) throw abortError(); } function exactOrigin(value: string): string { if (typeof value !== "string" || value.length < 1 || value.length > 4_000) throw new Error("origin policy is invalid"); let url: URL; try { url = new URL(value); } catch { throw new Error("origin policy is invalid"); } if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.hostname.includes("*")) { throw new Error("origin policy requires exact http(s) origins"); } const origin = url.origin; if (origin !== value.replace(/\/$/, "")) throw new Error("origin policy requires exact origins"); return origin; } function normalizePolicy(policy: RuntimeOriginPolicy): RuntimeOriginPolicy { if (policy === "*") return "*"; if (!Array.isArray(policy) || policy.length < 1) throw new Error("origin policy must not be empty"); return [...new Set(policy.map(exactOrigin))]; } function contextFor(base: RuntimeOriginPolicy, requested?: RuntimeOperationContext): RuntimeOperationContext { if (requested === undefined) return { allowedOrigins: base }; const allowedOrigins = normalizePolicy(requested.allowedOrigins); if (base !== "*" && (allowedOrigins === "*" || allowedOrigins.some((origin) => !base.includes(origin)))) { throw new Error("origin policy cannot be broadened"); } return { allowedOrigins }; } function policyAllows(policy: RuntimeOriginPolicy, value: string): boolean { let url: URL; try { url = new URL(value); } catch { return false; } if (url.protocol !== "http:" && url.protocol !== "https:") return false; return policy === "*" || policy.includes(url.origin); } function ensureUrl(value: unknown, label: string): string { if (typeof value !== "string" || value.length < 1 || value.length > 8_000) throw new Error(`${label} is invalid`); let url: URL; try { url = new URL(value); } catch { throw new Error(`${label} is invalid`); } if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) { throw new Error(`${label} must be an HTTP(S) URL without credentials`); } return value; } function ensureTabId(value: unknown, label = "tabId"): BrowserTabId { if (typeof value !== "string" || value.length < 1 || value.length > 200 || /[\0\r\n]/.test(value)) throw new Error(`${label} is invalid`); return value as BrowserTabId; } function operationError( operation: BrowserRuntimeOperationName, phase: string, code: BrowserErrorCode, message: string, mutationState: BrowserErrorDetails["mutationState"], retry: BrowserErrorDetails["retry"] = "never", ): BrowserOperationError { return new BrowserOperationError({ code, operation, phase, message: safeMessage(message), mutationState, retry, }); } function mutationStateFor(operation: BrowserRuntimeOperationName, dispatched: boolean): BrowserErrorDetails["mutationState"] { return dispatched && (operation === "act" || operation === "navigate" || operation === "reload" || operation === "selectTab" || operation === "waitPopup" || operation === "closeTab") ? "unknown" : "not_attempted"; } function asOperationError( error: unknown, operation: BrowserRuntimeOperationName, phase: string, dispatched: boolean, ): BrowserOperationError { if (error instanceof Error && error.name === "BrowserOperationError" && "details" in error) { return error as BrowserOperationError; } if ((error as { name?: unknown })?.name === "AbortError") { return operationError(operation, phase, "aborted", "browser operation cancelled", mutationStateFor(operation, dispatched)); } const text = safeMessage(error); const code: BrowserErrorCode = /closed|exited|disconnected|broken pipe/i.test(text) ? "browser_closed" : /unsupported/i.test(text) ? "unsupported" : /stale|document/i.test(text) ? "stale" : /policy|origin|approved/i.test(text) ? "policy_denied" : /invalid|malformed/i.test(text) ? "invalid_request" : "worker_failure"; const message = code === "unsupported" ? text : code === "policy_denied" ? "operation is outside the approved origin policy" : "browser worker operation failed"; return operationError(operation, phase, code, message, mutationStateFor(operation, dispatched)); } interface FailureCaptureSummary { status: "complete" | "partial" | "unavailable"; reason?: string; } function failureCaptureReason(error: unknown): string { if (error instanceof BrowserOperationError) { if (error.details.code === "browser_closed") return "browser session is unavailable"; if (error.details.code === "aborted") return "evidence capture was cancelled"; if (error.details.code === "worker_failure") return "browser transport is unavailable"; return error.details.message; } return "browser evidence capture is unavailable"; } function attachFailureCapture(error: BrowserOperationError, summary: FailureCaptureSummary): void { Object.defineProperty(error, "failureEvidence", { value: summary, configurable: true, enumerable: false, }); } function timedSignal(parent: AbortSignal, ms: number): { signal: AbortSignal; dispose: () => void } { const controller = new AbortController(); const onParent = () => controller.abort(parent.reason); if (parent.aborted) controller.abort(parent.reason); else parent.addEventListener("abort", onParent, { once: true }); const timer = setTimeout(() => controller.abort(new Error("operation deadline exceeded")), ms); timer.unref?.(); return { signal: controller.signal, dispose: () => { clearTimeout(timer); parent.removeEventListener("abort", onParent); }, }; } function maxedVisibleText(value: unknown): { text: string; truncated: boolean } { if (typeof value !== "string") throw new Error("worker returned invalid visible text"); const lines = value.split(/\r?\n/); let text = lines.length > MAX_VISIBLE_TEXT_LINES ? lines.slice(0, MAX_VISIBLE_TEXT_LINES).join("\n") : value; let truncated = lines.length > MAX_VISIBLE_TEXT_LINES; if (Buffer.byteLength(text, "utf8") > MAX_VISIBLE_TEXT_BYTES) { text = Buffer.from(text, "utf8").subarray(0, MAX_VISIBLE_TEXT_BYTES).toString("utf8"); truncated = true; } return { text, truncated }; } function validateTab(value: unknown): BrowserTab { if (!value || typeof value !== "object") throw new Error("worker returned invalid tab"); const tab = value as Record; const id = ensureTabId(tab.tabId); const url = ensureUrl(tab.url, "tab URL"); let origin: string; try { origin = exactOrigin(String(tab.origin ?? new URL(url).origin)); } catch { throw new Error("worker returned invalid tab origin"); } if (new URL(url).origin !== origin || (tab.kind !== "page" && tab.kind !== "popup") || typeof tab.selected !== "boolean" || tab.owned !== true || typeof tab.title !== "string" || tab.title.length > 2_000) throw new Error("worker returned invalid tab"); const result: BrowserTab = { tabId: id, kind: tab.kind, selected: tab.selected, owned: true, url, origin, title: tab.title }; if (tab.openerTabId !== undefined) result.openerTabId = ensureTabId(tab.openerTabId, "openerTabId"); return result; } function validateObservation(value: unknown, policy: RuntimeOriginPolicy): Observation { if (!value || typeof value !== "object") throw new Error("worker returned invalid observation"); const result = value as Observation; ensureUrl(result.url, "observation URL"); if (!policyAllows(policy, result.url) || !Array.isArray(result.candidates)) throw new Error("worker returned observation outside policy"); return result; } function validateAct(value: unknown): ActResult { if (!value || typeof value !== "object") throw new Error("worker returned invalid action result"); const result = value as Record; if (typeof result.verified !== "boolean" || typeof result.evidence !== "string" || result.evidence.length > 4_000) throw new Error("worker returned invalid action result"); return { verified: result.verified, evidence: result.evidence }; } function validateCommitted(value: unknown, policy: RuntimeOriginPolicy, label: "navigation" | "reload"): NavigationResult | ReloadResult { if (!value || typeof value !== "object") throw new Error(`worker returned invalid ${label}`); const result = value as Record; if (result.status !== "committed" || result.mutationState !== "verified") throw new Error(`${label} was not verified`); const tab = validateTab(result.tab); if (!policyAllows(policy, tab.url) || typeof result.documentId !== "string" || !result.documentId || result.documentId.length > 200) throw new Error(`worker returned invalid ${label}`); return { status: "committed", mutationState: "verified", tab, documentId: result.documentId } as NavigationResult; } function validateRead(value: unknown, policy: RuntimeOriginPolicy): PageRead { if (!value || typeof value !== "object") throw new Error("worker returned invalid read"); const result = value as Record; const tab = validateTab(result.tab); const url = ensureUrl(result.url, "read URL"); if (!policyAllows(policy, url) || new URL(url).origin !== tab.origin || typeof result.documentId !== "string" || result.documentId.length < 1 || result.documentId.length > 200 || typeof result.title !== "string") { throw new Error("worker returned invalid read metadata"); } const visible = maxedVisibleText(result.visibleText); return { tab, documentId: result.documentId, url, title: result.title, visibleText: visible.text, visibleTextTruncated: Boolean(result.visibleTextTruncated) || visible.truncated }; } function validateEvidence(value: BrowserTransportEvidence): BrowserTransportEvidence { if (!value || (value.status !== "complete" && value.status !== "partial" && value.status !== "unavailable")) throw new Error("worker returned invalid evidence status"); if (!Array.isArray(value.console) || !Array.isArray(value.failedRequests)) throw new Error("worker returned invalid evidence"); return value; } function validateTabRequest(request: { tabId?: unknown }, label = "tabId"): void { if (request.tabId !== undefined) ensureTabId(request.tabId, label); } function validateAssertion(request: AssertRequest): void { validateTabRequest(request); if (request.wait !== "none" && request.wait !== "bounded") throw new Error("assertion wait mode is invalid"); if (!request.assertion || typeof request.assertion.value !== "string" || request.assertion.value.length < 1 || request.assertion.value.length > 2_000) throw new Error("assertion is invalid"); if (request.assertion.kind === "url") { if (request.assertion.match !== "exact" && request.assertion.match !== "origin") throw new Error("URL assertion match is invalid"); ensureUrl(request.assertion.value, "assertion URL"); } else if (request.assertion.kind === "title") { if (request.assertion.match !== "exact" && request.assertion.match !== "contains") throw new Error("title assertion match is invalid"); } else if (request.assertion.kind === "visibleText") { if (request.assertion.match !== "contains") throw new Error("visible-text assertion match is invalid"); } else throw new Error("assertion kind is invalid"); } function sanitizeConsole(value: unknown): string { // Console content is untrusted page content. Keep a bounded local summary, // but never turn obvious credentials/tokens into report text. return safeMessage(value, "console message") .replace(/https?:\/\/[^\s)]+/gi, "[url]") .replace(/\b(?:bearer|basic)\s+[^\s]+/gi, "[redacted]") .replace(/[?&](?:token|key|secret|password|passwd|auth|code)=[^&#\s]+/gi, "[redacted-query]") .replace(/\b(?:password|passwd|secret|token|api[_ -]?key)\s*[=:]\s*[^\s,;]+/gi, "[redacted]"); } function sanitizeEvidence(value: BrowserTransportEvidence): { console: readonly ConsoleFailureSummary[]; failedRequests: FailureEvidence["failedRequests"] } { const consoleItems: ConsoleFailureSummary[] = value.console.slice(0, MAX_EVIDENCE_ITEMS).map((item) => ({ level: item.level === "warning" ? "warning" : "error", message: sanitizeConsole(item.message), count: Number.isSafeInteger(item.count) && item.count > 0 ? Math.min(item.count, 100_000) : 1, truncated: Boolean(item.truncated), })); const failedRequests = value.failedRequests.slice(0, MAX_EVIDENCE_ITEMS).map((item) => { const origin = exactOrigin(item.origin); const result: FailureEvidence["failedRequests"][number] = { method: safeMessage(item.method, "GET").slice(0, 16), origin, category: item.category }; if (item.status !== undefined && Number.isSafeInteger(item.status) && item.status >= 100 && item.status <= 599) result.status = item.status; return result; }); return { console: consoleItems, failedRequests }; } function validArtifactPayload(input: unknown, kind: PrivateArtifactKind, maxBytes: number): { contentType: "image/png" | "application/json"; bytes: Uint8Array } { if (!input || typeof input !== "object") throw new Error("worker returned invalid artifact payload"); const payload = input as Record; if (payload.kind !== kind || payload.encoding !== "base64" || typeof payload.dataBase64 !== "string" || typeof payload.byteLength !== "number" || payload.byteLength < 0 || payload.byteLength > maxBytes || payload.truncated !== Boolean(payload.truncated) || (payload.contentType !== "image/png" && payload.contentType !== "application/json")) throw new Error("worker returned invalid artifact payload"); if (payload.dataBase64.length > Math.ceil((maxBytes * 4) / 3) + 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload.dataBase64)) throw new Error("worker returned invalid artifact encoding"); const bytes = Buffer.from(payload.dataBase64, "base64"); if (bytes.byteLength !== payload.byteLength) throw new Error("worker returned invalid artifact size"); return { contentType: payload.contentType, bytes: new Uint8Array(bytes) }; } async function commitArtifact( store: PrivateArtifactStore, owner: { sessionId: string; runId: string; operationId: string }, kind: PrivateArtifactKind, payload: unknown, maxBytes: number, ): Promise { const value = validArtifactPayload(payload, kind, maxBytes); const writer = await store.open(owner, kind); try { await writer.write(value.bytes); const metadata = await writer.commit(); if (metadata.permissions !== "0600" || metadata.localOnly !== true || metadata.modelForwarded !== false || metadata.owner.sessionId !== owner.sessionId || metadata.owner.runId !== owner.runId || metadata.owner.operationId !== owner.operationId || metadata.bytes !== value.bytes.byteLength || metadata.contentType !== value.contentType) throw new Error("artifact store returned invalid metadata"); return metadata; } catch (error) { await writer.abort().catch(() => {}); throw error; } } function defaultTransportFactory(): BrowserTransportFactory { return { create(options, parentSignal) { const allowedOrigins = normalizePolicy(options.allowedOrigins); return createBrowserBridge({ cdpUrl: options.cdpUrl, allowedOrigins: allowedOrigins === "*" ? "*" : [...allowedOrigins], ...(options.python ? { python: options.python } : {}), }, parentSignal); }, }; } function resultFor(value: { op: string; result: unknown }, operation: T): unknown { if (!value || value.op !== operation || !("result" in value)) throw new Error("worker returned an invalid typed result"); return value.result; } /** Create a session-scoped runtime. No worker is created until first use. */ export function createBrowserRuntime( options: BrowserRuntimeOptions, parentSignal: AbortSignal = new AbortController().signal, transportFactory: BrowserTransportFactory = options.transportFactory ?? defaultTransportFactory(), ): BrowserRuntime { if (!options || typeof options.sessionId !== "string" || options.sessionId.length < 1 || options.sessionId.length > 200) throw new Error("sessionId is required"); if (typeof options.cdpUrl !== "string" || !options.cdpUrl) throw new Error("cdpUrl is required"); const basePolicy = normalizePolicy(options.allowedOrigins); const store = options.artifacts; let transport: BrowserTransport | undefined; let closed = false; let closePromise: Promise | undefined; let lane: Promise = Promise.resolve(); const getTransport = (): BrowserTransport => transport ?? (transport = transportFactory.create({ cdpUrl: options.cdpUrl, allowedOrigins: basePolicy, ...(options.python ? { python: options.python } : {}) }, parentSignal)); const invoke = ( operation: BrowserRuntimeOperationName, request: BrowserOperation, signal: AbortSignal, context: RuntimeOperationContext | undefined, validate: (value: unknown, effective: RuntimeOperationContext) => T, ): Promise => { const run = lane.then(async () => { if (closed && operation !== "close") throw operationError(operation, "lifecycle", "browser_closed", "browser session is closed", "not_attempted"); if (parentSignal.aborted && operation !== "close") throw operationError(operation, "lifecycle", "browser_closed", "browser session is unavailable", "not_attempted"); const effective = contextFor(basePolicy, context); throwIfAborted(signal); const timed = timedSignal(signal, OPERATION_BUDGETS[operation]); let dispatched = false; try { dispatched = true; const transportRequest = context === undefined ? { operation: request } : { operation: request, context: effective }; const response = await getTransport().dispatch(transportRequest, timed.signal); return validate(resultFor(response, operation), effective); } catch (error) { throw asOperationError(error, operation, "dispatch", dispatched); } finally { timed.dispose(); } }); lane = run.catch(() => undefined); return run; }; // Policy narrowing is a runtime concern, not a worker operation. Keep it // on the same lane so it observes the same lifecycle and serialization // boundaries without starting a transport or emitting JSONL. const invokeLocal = ( operation: BrowserRuntimeOperationName, signal: AbortSignal, action: () => T, ): Promise => { const run = lane.then(() => { if (closed && operation !== "close") throw operationError(operation, "lifecycle", "browser_closed", "browser session is closed", "not_attempted"); if (parentSignal.aborted && operation !== "close") throw operationError(operation, "lifecycle", "browser_closed", "browser session is unavailable", "not_attempted"); // Match invoke(): a pre-aborted operation signal is rejected before // dispatch/error shaping, while lifecycle failures remain typed. throwIfAborted(signal); try { return action(); } catch (error) { throw asOperationError(error, operation, "dispatch", false); } }); lane = run.catch(() => undefined); return run; }; const captureAfterFailure = async (operation: BrowserRuntimeOperationName, context?: RuntimeOperationContext): Promise => { if (parentSignal.aborted) return { status: "unavailable", reason: "browser session is unavailable" }; if (operation === "captureEvidence" || operation === "close" || operation === "narrowOriginPolicy") { return { status: "unavailable", reason: "failure evidence is not applicable to this operation" }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), OPERATION_BUDGETS.captureEvidence); timer.unref?.(); try { const evidence = await captureEvidence({ runId: `${options.sessionId}:${randomUUID()}`, reason: "failure", operation }, controller.signal, context); return evidence.status === "complete" ? { status: "complete" } : { status: evidence.status, reason: "browser returned no complete failure evidence" }; } catch (error) { return { status: "unavailable", reason: failureCaptureReason(error) }; } finally { clearTimeout(timer); } }; const runWithCapture = (operation: BrowserRuntimeOperationName, request: BrowserOperation, signal: AbortSignal, context: RuntimeOperationContext | undefined, validate: (value: unknown, effective: RuntimeOperationContext) => T): Promise => invoke(operation, request, signal, context, validate).catch(async (error) => { const safe = asOperationError(error, operation, "dispatch", true); const evidence = await captureAfterFailure(operation, context); attachFailureCapture(safe, evidence); throw safe; }); const captureEvidence = async (request: EvidenceRequest, signal: AbortSignal, context?: RuntimeOperationContext): Promise => { if (!request || typeof request.runId !== "string" || request.runId.length < 1 || request.runId.length > 200) throw operationError("captureEvidence", "validate", "invalid_request", "runId is invalid", "not_attempted"); const operationId = randomUUID(); const value = await invoke("captureEvidence", { op: "captureEvidence", request }, signal, context, async (raw) => { const evidence = validateEvidence(raw as BrowserTransportEvidence); const safe = sanitizeEvidence(evidence); let screenshot: PrivateArtifactMetadata | undefined; let diagnostics: PrivateArtifactMetadata | undefined; let status = evidence.status; try { if (evidence.screenshot) screenshot = await commitArtifact(store, { sessionId: options.sessionId, runId: request.runId, operationId }, "screenshot", evidence.screenshot, FAST_BROWSER_ARTIFACT_LIMITS.screenshotBytes); if (evidence.diagnostics) diagnostics = await commitArtifact(store, { sessionId: options.sessionId, runId: request.runId, operationId }, "diagnostics", evidence.diagnostics, FAST_BROWSER_ARTIFACT_LIMITS.diagnosticsBytes); } catch { // A partial bundle remains useful; artifact errors never expose bytes. status = status === "unavailable" ? status : "partial"; } return { status, ...(screenshot ? { screenshot } : {}), ...(diagnostics ? { diagnostics } : {}), console: safe.console, failedRequests: safe.failedRequests }; }); return value; }; const close = async (): Promise => { if (closePromise) return closePromise; closePromise = (async () => { if (closed) return; const controller = new AbortController(); const timed = timedSignal(controller.signal, OPERATION_BUDGETS.close); try { // Wait for local-only operations too. A narrow policy must not race // session teardown, and waiting here still keeps an unused transport // unstarted. await lane; if (transport) { await invoke("close", { op: "close" }, timed.signal, undefined, () => undefined).catch(() => undefined); await transport.close().catch(() => {}); } } finally { timed.dispose(); closed = true; await store.close().catch(() => {}); } })(); return closePromise; }; const runtime: BrowserRuntime = { sessionId: options.sessionId, observe: (signal, context) => runWithCapture("observe", { op: "observe" }, signal, context, (raw, effective) => validateObservation(raw, effective.allowedOrigins)), act: (request: ActRequest, signal, context) => { try { const effective = contextFor(basePolicy, context); if (request.postcondition?.kind === "url") ensureUrl(request.postcondition.value, "postcondition URL"); if (request.postcondition?.kind === "url" && !policyAllows(effective.allowedOrigins, request.postcondition.value)) return Promise.reject(operationError("act", "policy", "policy_denied", "postcondition is outside the approved origin policy", "not_attempted")); return runWithCapture("act", { op: "act", request }, signal, effective, (raw) => validateAct(raw)); } catch (error) { return Promise.reject(asOperationError(error, "act", "policy", false)); } }, read: (request: ReadRequest, signal, context) => { try { validateTabRequest(request); } catch (error) { return Promise.reject(asOperationError(error, "read", "validate", false)); } return runWithCapture("read", { op: "read", request }, signal, context, (raw, effective) => validateRead(raw, effective.allowedOrigins)); }, assert: (request: AssertRequest, signal, context) => { try { validateAssertion(request); } catch (error) { return Promise.reject(asOperationError(error, "assert", "validate", false)); } return runWithCapture("assert", { op: "assert", request }, signal, context, (raw, effective) => { if (!raw || typeof raw !== "object") throw new Error("worker returned invalid assertion"); const row = raw as Record; if (row.status !== "satisfied" && row.status !== "not_satisfied" && row.status !== "timed_out" || row.readOnly !== true) throw new Error("worker returned invalid assertion"); const tab = validateTab(row.tab); if (!policyAllows(effective.allowedOrigins, tab.url)) throw new Error("assertion returned a tab outside policy"); if (typeof row.documentId !== "string" || !row.documentId) throw new Error("worker returned invalid document identity"); return { status: row.status, readOnly: true, tab, documentId: row.documentId } as AssertResult; }); }, navigate: (request: NavigateRequest, signal, context) => { try { validateTabRequest(request); } catch (error) { return Promise.reject(asOperationError(error, "navigate", "validate", false)); } let effective: RuntimeOperationContext; try { effective = contextFor(basePolicy, context); } catch (error) { return Promise.reject(asOperationError(error, "navigate", "policy", false)); } let url: string; try { url = ensureUrl(request.url, "navigation URL"); } catch (error) { return Promise.reject(asOperationError(error, "navigate", "validate", false)); } if (!policyAllows(effective.allowedOrigins, url)) return Promise.reject(operationError("navigate", "policy", "policy_denied", "navigation is outside the approved origin policy", "not_attempted")); return runWithCapture("navigate", { op: "navigate", request: { ...request, url } }, signal, effective, (raw) => { const result = validateCommitted(raw, effective.allowedOrigins, "navigation") as NavigationResult; if (request.tabId !== undefined && result.tab.tabId !== request.tabId) throw new Error("navigation returned a different tab"); return result; }); }, reload: (request: ReloadRequest, signal, context) => { try { validateTabRequest(request); } catch (error) { return Promise.reject(asOperationError(error, "reload", "validate", false)); } return runWithCapture("reload", { op: "reload", request }, signal, context, (raw, effective) => { const result = validateCommitted(raw, effective.allowedOrigins, "reload") as ReloadResult; if (request.tabId !== undefined && result.tab.tabId !== request.tabId) throw new Error("reload returned a different tab"); return result; }); }, listTabs: (signal, context) => runWithCapture("listTabs", { op: "listTabs" }, signal, context, (raw, effective) => { if (!Array.isArray(raw) || raw.length > MAX_TABS) throw new Error("worker returned too many tabs"); return raw.map(validateTab).filter((tab) => policyAllows(effective.allowedOrigins, tab.url)).sort((left, right) => left.tabId.localeCompare(right.tabId)); }), selectTab: (request: SelectTabRequest, signal, context) => { try { ensureTabId(request.tabId); } catch (error) { return Promise.reject(asOperationError(error, "selectTab", "validate", false)); } return runWithCapture("selectTab", { op: "selectTab", request }, signal, context, (raw, effective) => { if (!raw || typeof raw !== "object" || (raw as Record).selected !== true) throw new Error("worker did not verify selected tab"); const tab = validateTab((raw as Record).tab); if (!policyAllows(effective.allowedOrigins, tab.url) || tab.tabId !== request.tabId) throw new Error("selected tab is outside policy or does not match the request"); return { selected: true, tab } as SelectTabResult; }); }, waitPopup: (request: WaitPopupRequest, signal, context) => { try { ensureTabId(request.openerTabId, "openerTabId"); } catch (error) { return Promise.reject(asOperationError(error, "waitPopup", "validate", false)); } return runWithCapture("waitPopup", { op: "waitPopup", request }, signal, context, (raw, effective) => { if (!raw || typeof raw !== "object") throw new Error("worker returned invalid popup result"); const row = raw as Record; if ((row.status !== "found" && row.status !== "timed_out") || row.autoSwitched !== false) throw new Error("worker returned invalid popup result"); if (row.tab !== undefined) { const popup = validateTab(row.tab); if (!policyAllows(effective.allowedOrigins, popup.url) || popup.kind !== "popup" || popup.openerTabId !== request.openerTabId) throw new Error("popup is outside policy or does not match its opener"); return { status: row.status, autoSwitched: false, tab: popup } as WaitPopupResult; } return { status: row.status, autoSwitched: false } as WaitPopupResult; }); }, closeTab: (request: CloseTabRequest, signal, context) => { try { ensureTabId(request.tabId); } catch (error) { return Promise.reject(asOperationError(error, "closeTab", "validate", false)); } return runWithCapture("closeTab", { op: "closeTab", request }, signal, context, (raw) => { if (!raw || typeof raw !== "object") throw new Error("worker returned invalid close-tab result"); const row = raw as Record; if (row.closed !== true || row.mutationState !== "verified" || ensureTabId(row.tabId) !== request.tabId) throw new Error("worker did not verify the requested tab was closed"); return { closed: true, tabId: request.tabId, mutationState: "verified" } as CloseTabResult; }); }, captureEvidence, narrowOriginPolicy: async (request: NarrowOriginPolicyRequest, signal): Promise => { const allowedOrigins = normalizePolicy(request.allowedOrigins); if (basePolicy !== "*" && (allowedOrigins === "*" || allowedOrigins.some((origin) => !basePolicy.includes(origin)))) throw operationError("narrowOriginPolicy", "policy", "policy_denied", "origin policy cannot be broadened", "not_attempted"); const context = { allowedOrigins }; return invokeLocal("narrowOriginPolicy", signal, () => context); }, close, }; return runtime; } /** Scope a policy narrowing to the explicitly supplied callback. */ export async function withRuntimeOriginPolicy( runtime: BrowserRuntime, allowedOrigins: RuntimeOriginPolicy, operation: (context: RuntimeOperationContext) => Promise, signal: AbortSignal = new AbortController().signal, ): Promise { const context = await runtime.narrowOriginPolicy({ allowedOrigins }, signal); return operation(context); } /** Offline/default store used only when an embedding does not supply its * private filesystem store. It retains metadata, never exposes artifact bytes. */ export function createInMemoryArtifactStore(): PrivateArtifactStore { const openWriters = new Set<{ abort(): Promise }>(); return { async open(owner, kind) { let bytes = 0; let closed = false; const artifactId = randomUUID(); const writer = { owner, kind, async write(chunk: Uint8Array) { if (closed) throw new Error("artifact writer is closed"); bytes += chunk.byteLength; }, async commit(): Promise { if (closed) throw new Error("artifact writer is closed"); closed = true; openWriters.delete(writer); return { artifactId, kind, contentType: kind === "screenshot" ? "image/png" : "application/json", path: `[private:${artifactId}]`, bytes, owner, permissions: "0600", localOnly: true, modelForwarded: false, }; }, async abort() { closed = true; openWriters.delete(writer); }, }; openWriters.add(writer); return writer; }, async close() { for (const writer of [...openWriters]) await writer.abort().catch(() => {}); openWriters.clear(); }, }; }