import { spawn as nodeSpawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import type { ActRequest, ActResult, AssertRequest, AssertResult, BrowserBridge, BrowserErrorCode, BrowserErrorDetails, BrowserOperation, BrowserTransport, BrowserTransportArtifactPayload, BrowserTransportEvidence, BrowserTransportFactory, BrowserTransportOptions, BrowserTransportRequest, BrowserTransportResultFor, BrowserTab, Candidate, ConsoleFailureSummary, NetworkFailureSummary, Observation, PageRead, ReloadResult, RuntimeOperationContext, WaitPopupResult, RuntimeOriginPolicy, } from "./contracts.ts"; import { BrowserOperationError as RuntimeError, BROWSER_RUNTIME_OPERATIONS, FAST_BROWSER_ARTIFACT_LIMITS } from "./contracts.ts"; import { approvedOrigin as policyApprovedOrigin, type OriginPolicy } from "./origin-policy.ts"; const DEFAULT_TIMEOUT_MS = 10_000; const MAX_REQUEST_LINE_BYTES = 128 * 1024; const MAX_STRING = 4_000; const MAX_CANDIDATES = 60; const MAX_EVIDENCE = 4_000; const MAX_FILL_TEXT = 8_000; const MAX_VISIBLE_TEXT = 16_000; const MAX_TAB_COUNT = 32; const MAX_ARTIFACT_DATA_BASE64 = Math.ceil((FAST_BROWSER_ARTIFACT_LIMITS.screenshotBytes * 4) / 3) + 4; const MAX_RESPONSE_LINE_BYTES = MAX_ARTIFACT_DATA_BASE64 + 64 * 1024; export interface BrowserBridgeOptions { cdpUrl: string; allowedOrigins: OriginPolicy; python?: string; /** Test seam; production uses node's child_process.spawn. */ spawn?: SpawnLike; /** Test seam and packaging hook; defaults to the adjacent worker. */ workerPath?: string; /** Bounded IPC deadline. The browser worker itself never retries actions. */ requestTimeoutMs?: number; } export type SpawnLike = ( command: string, args: readonly string[], options: { env: NodeJS.ProcessEnv; stdio: ["pipe", "pipe", "pipe"]; }, ) => ChildProcessLike; export interface ChildProcessLike { stdin: { write(data: string): boolean; end(): void; once(event: string, listener: (...args: any[]) => void): unknown }; stdout: { on(event: string, listener: (...args: any[]) => void): unknown; setEncoding?: (encoding: string) => void }; stderr?: { on(event: string, listener: (...args: any[]) => void): unknown }; once(event: string, listener: (...args: any[]) => void): unknown; kill(signal?: NodeJS.Signals | number): boolean; pid?: number; } class WorkerOperationError extends Error { override name = "WorkerOperationError"; } interface Pending { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType; signal?: AbortSignal; parentSignal?: AbortSignal; onAbort?: () => void; } function abortError(message = "browser bridge aborted"): Error { const error = new Error(message); error.name = "AbortError"; return error; } function mutationOperation(operation: BrowserOperation["op"]): boolean { return operation === "act" || operation === "navigate" || operation === "reload" || operation === "selectTab" || operation === "closeTab"; } function transportFailure( error: unknown, operation: BrowserOperation["op"], phase: string, mutationAttempted: boolean, codeHint?: BrowserErrorCode, ): RuntimeError { if (error instanceof RuntimeError) return error; const raw = error instanceof Error ? error.message : ""; const lower = raw.toLowerCase(); let code: BrowserErrorCode = codeHint ?? "worker_failure"; if (!codeHint) { if (error instanceof Error && error.name === "AbortError") code = "aborted"; else if (lower.includes("timed out") || lower.includes("timeout")) code = "timeout"; else if (lower.includes("stale") || lower.includes("observation")) code = "stale"; else if (lower.includes("origin") || lower.includes("policy")) code = "policy_denied"; else if (lower.includes("closed") || lower.includes("no tab") || lower.includes("tab is unavailable")) code = "no_tab"; } const mutationState = mutationOperation(operation) && mutationAttempted ? (code === "stale" || code === "invalid_request" ? "not_attempted" : "unknown") : "not_attempted"; const messages: Record = { aborted: "browser operation was aborted", timeout: "browser worker request timed out", stale: "browser target is stale", policy_denied: "browser origin policy denied the operation", invalid_request: "browser operation request is invalid", no_tab: "browser tab is unavailable", ambiguous_tab: "browser tab selection is ambiguous", browser_closed: "browser session is closed", unsupported: "browser operation is unsupported", worker_failure: "browser worker operation failed", }; const details: BrowserErrorDetails = { code, operation, phase, message: messages[code], mutationState, retry: code === "invalid_request" && mutationState === "not_attempted" ? "safe" : "never", }; return new RuntimeError(details); } function exactOrigin(value: string): string { if (typeof value !== "string" || value.length === 0 || value.length > MAX_STRING) { throw new Error("allowed origin must be a short URL origin"); } let url: URL; try { url = new URL(value); } catch { throw new Error("allowed origin is not a URL origin"); } if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.hostname.includes("*") || url.pathname !== "/" || url.search || url.hash) { throw new Error("allowed origin must be an exact http(s) origin"); } const origin = url.origin; // URL.origin canonicalizes a trailing slash, default ports, and host case. if (origin !== value.replace(/\/$/, "")) { throw new Error("allowed origin must be an exact origin"); } return origin; } function approvedOrigin(urlValue: string, allowed: OriginPolicy): boolean { return policyApprovedOrigin(urlValue, allowed); } function loopbackCdpUrl(value: string): string { if (typeof value !== "string" || value.length === 0 || value.length > MAX_STRING) { throw new Error("cdpUrl must be a short URL"); } let url: URL; try { url = new URL(value); } catch { throw new Error("cdpUrl must be an http loopback endpoint"); } const host = url.hostname.toLowerCase(); const loopback = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; if (url.protocol !== "http:" || !loopback || url.username || url.password || !url.port || url.hash || url.search || url.pathname !== "/") { throw new Error("cdpUrl must be an http loopback endpoint"); } return value; } function validString(value: unknown, label: string, limit = MAX_STRING): string { if (typeof value !== "string" || value.length > limit) throw new Error(`${label} is invalid`); return value; } function validateCandidate(value: unknown): Candidate { if (!value || typeof value !== "object") throw new Error("worker returned an invalid candidate"); const candidate = value as Record; if (Object.keys(candidate).sort().join(",") !== "actions,id,name,role") throw new Error("worker returned an invalid candidate shape"); const actions = candidate.actions; if (!Array.isArray(actions) || actions.length === 0 || actions.length > 2 || actions.some((item) => item !== "click" && item !== "fill")) { throw new Error("worker returned invalid candidate actions"); } if (!candidate.id || !candidate.role) throw new Error("worker returned empty candidate identity"); return { id: validString(candidate.id, "candidate id", 200), role: validString(candidate.role, "candidate role", 100), name: validString(candidate.name, "candidate name", 240), actions: [...new Set(actions)] as Candidate["actions"], }; } function validateObservation(value: unknown, allowed: OriginPolicy): Observation { if (!value || typeof value !== "object") throw new Error("worker returned an invalid observation"); const observation = value as Record; if (Object.keys(observation).sort().join(",") !== "candidates,observationId,title,url") throw new Error("worker returned an invalid observation shape"); const url = validString(observation.url, "observation URL"); if (!approvedOrigin(url, allowed)) throw new Error("worker returned a URL outside the approved origins"); if (!Array.isArray(observation.candidates) || observation.candidates.length > MAX_CANDIDATES) throw new Error("worker returned too many candidates"); const candidates = observation.candidates.map(validateCandidate); if (!observation.observationId || new Set(candidates.map((item) => item.id)).size !== candidates.length) { throw new Error("worker returned duplicate or empty browser identity"); } return { observationId: validString(observation.observationId, "observation id", 200), url, title: validString(observation.title, "observation title", 2_000), candidates, }; } function validateActResult(value: unknown): ActResult { if (!value || typeof value !== "object") throw new Error("worker returned an invalid action result"); const result = value as Record; if (Object.keys(result).sort().join(",") !== "evidence,verified" || typeof result.verified !== "boolean") { throw new Error("worker returned an invalid action result shape"); } return { verified: result.verified, evidence: validString(result.evidence, "action evidence", MAX_EVIDENCE) }; } function validateActRequest(request: ActRequest, allowed: OriginPolicy): void { if (!request || typeof request !== "object") throw new Error("invalid browser action"); if (request.action !== "click" && request.action !== "fill") throw new Error("unsupported browser action"); const allowedKeys = request.action === "click" ? new Set(["observationId", "candidateId", "action", "postcondition"]) : new Set(["observationId", "candidateId", "action", "text", "postcondition"]); if (Object.keys(request).some((key) => !allowedKeys.has(key))) throw new Error("invalid browser action shape"); validString(request.observationId, "observation id", 200); validString(request.candidateId, "candidate id", 200); if (request.action === "fill") { if (typeof request.text !== "string" || request.text.length > MAX_FILL_TEXT) throw new Error("fill text exceeds protocol limit"); const postcondition = request.postcondition; if (postcondition !== undefined) { if (!["url", "text"].includes(postcondition.kind) || typeof postcondition.value !== "string" || postcondition.value.length === 0 || postcondition.value.length > MAX_STRING) { throw new Error("invalid fill postcondition"); } if (postcondition.kind === "url" && !approvedOrigin(postcondition.value, allowed)) { throw new Error("fill URL postcondition is outside the approved origins"); } } return; } if (request.text !== undefined) throw new Error("click does not accept text"); const postcondition = request.postcondition; if (!postcondition || !["url", "text"].includes(postcondition.kind) || typeof postcondition.value !== "string" || postcondition.value.length === 0 || postcondition.value.length > MAX_STRING) { throw new Error("click requires a bounded postcondition"); } if (postcondition.kind === "url" && !approvedOrigin(postcondition.value, allowed)) { throw new Error("click URL postcondition is outside the approved origins"); } } function validateTabId(value: unknown, label = "tab id"): string { const id = validString(value, label, 200); if (!id) throw new Error(`${label} is invalid`); return id; } function normalizePolicy(value: unknown, label = "allowed origins"): OriginPolicy { if (value === "*") return "*"; if (!Array.isArray(value) || value.length === 0 || value.length > 20) throw new Error(`${label} is invalid`); const origins = [...new Set(value.map((origin) => exactOrigin(origin)))]; if (origins.length === 0) throw new Error(`${label} is invalid`); return origins; } function policyContains(base: OriginPolicy, candidate: OriginPolicy): boolean { if (base === "*") return true; if (candidate === "*") return false; return candidate.every((origin) => base.includes(origin)); } function effectivePolicy(base: OriginPolicy, context?: RuntimeOperationContext): OriginPolicy { if (context === undefined) return base; const requested = normalizePolicy(context.allowedOrigins, "operation origin policy"); if (!policyContains(base, requested)) throw new Error("operation origin policy broadens the session policy"); return requested; } function exactKeys(value: Record, keys: readonly string[], label: string): void { if (Object.keys(value).sort().join(",") !== [...keys].sort().join(",")) throw new Error(`${label} has an invalid shape`); } function optionalTabId(value: unknown, label = "tab id"): string | undefined { if (value === undefined) return undefined; return validateTabId(value, label); } function validateBrowserTab(value: unknown, allowed: OriginPolicy): BrowserTab { if (!value || typeof value !== "object") throw new Error("worker returned an invalid tab"); const tab = value as Record; const keys = Object.keys(tab).sort().join(","); if (keys !== "kind,origin,owned,selected,tabId,title,url" && keys !== "kind,openerTabId,origin,owned,selected,tabId,title,url") { throw new Error("worker returned an invalid tab shape"); } const tabId = validateTabId(tab.tabId); const url = validString(tab.url, "tab URL"); if (!approvedOrigin(url, allowed)) throw new Error("worker returned a tab outside the approved origins"); let parsed: URL; try { parsed = new URL(url); } catch { throw new Error("worker returned an invalid tab URL"); } if (tab.origin !== parsed.origin) throw new Error("worker returned a tab with an invalid origin"); const origin = validString(tab.origin, "tab origin", 300); if (tab.kind !== "page" && tab.kind !== "popup") throw new Error("worker returned an invalid tab kind"); if (tab.selected !== true && tab.selected !== false) throw new Error("worker returned an invalid tab selection"); if (tab.owned !== true) throw new Error("worker returned an unowned tab"); const openerTabId = optionalTabId(tab.openerTabId, "opener tab id"); if (openerTabId === tabId) throw new Error("worker returned a self-owned popup"); return { tabId: tabId as BrowserTab["tabId"], kind: tab.kind, ...(openerTabId === undefined ? {} : { openerTabId: openerTabId as BrowserTab["tabId"] }), selected: tab.selected, owned: true, url, origin, title: validString(tab.title, "tab title", 2_000), }; } function validateReadResult(value: unknown, allowed: OriginPolicy): PageRead { if (!value || typeof value !== "object") throw new Error("worker returned an invalid read result"); const result = value as Record; exactKeys(result, ["documentId", "tab", "title", "url", "visibleText", "visibleTextTruncated"], "read result"); const tab = validateBrowserTab(result.tab, allowed); const url = validString(result.url, "read URL"); if (!approvedOrigin(url, allowed) || url !== tab.url) throw new Error("worker returned an invalid read URL"); const visibleText = validString(result.visibleText, "visible text", MAX_VISIBLE_TEXT); if (typeof result.visibleTextTruncated !== "boolean") throw new Error("worker returned an invalid visible-text flag"); return { tab, documentId: validateTabId(result.documentId, "document id"), url, title: validString(result.title, "read title", 2_000), visibleText, visibleTextTruncated: result.visibleTextTruncated, }; } function validateAssertResult(value: unknown, allowed: OriginPolicy): AssertResult { if (!value || typeof value !== "object") throw new Error("worker returned an invalid assertion result"); const result = value as Record; exactKeys(result, ["documentId", "readOnly", "status", "tab"], "assertion result"); if (result.status !== "satisfied" && result.status !== "not_satisfied" && result.status !== "timed_out") throw new Error("worker returned an invalid assertion status"); if (result.readOnly !== true) throw new Error("worker returned a mutating assertion"); return { status: result.status, readOnly: true, tab: validateBrowserTab(result.tab, allowed), documentId: validateTabId(result.documentId, "document id") }; } function validateCommittedResult(value: unknown, operation: "navigate" | "reload", allowed: OriginPolicy): NavigationResult | ReloadResult { if (!value || typeof value !== "object") throw new Error(`worker returned an invalid ${operation} result`); const result = value as Record; exactKeys(result, ["documentId", "mutationState", "status", "tab"], `${operation} result`); if (result.status !== "committed" || result.mutationState !== "verified") throw new Error(`worker returned an unverified ${operation}`); return { status: "committed", mutationState: "verified", tab: validateBrowserTab(result.tab, allowed), documentId: validateTabId(result.documentId, "document id") }; } function validateListTabsResult(value: unknown, allowed: OriginPolicy): readonly BrowserTab[] { if (!Array.isArray(value) || value.length > MAX_TAB_COUNT) throw new Error("worker returned too many tabs"); const tabs = value.map((item) => validateBrowserTab(item, allowed)); const ids = new Set(tabs.map((tab) => tab.tabId)); if (ids.size !== tabs.length || tabs.filter((tab) => tab.selected).length > 1) throw new Error("worker returned duplicate or ambiguous tabs"); return tabs; } function validateSelectTabResult(value: unknown, allowed: OriginPolicy): { selected: true; tab: BrowserTab } { if (!value || typeof value !== "object") throw new Error("worker returned an invalid tab selection result"); const result = value as Record; exactKeys(result, ["selected", "tab"], "tab selection result"); if (result.selected !== true) throw new Error("worker did not confirm tab selection"); return { selected: true, tab: validateBrowserTab(result.tab, allowed) }; } function validateWaitPopupResult(value: unknown, allowed: OriginPolicy): WaitPopupResult { if (!value || typeof value !== "object") throw new Error("worker returned an invalid popup result"); const result = value as Record; const keys = Object.keys(result).sort().join(","); if (keys !== "autoSwitched,status" && keys !== "autoSwitched,status,tab") throw new Error("popup result has an invalid shape"); if (result.autoSwitched !== false || (result.status !== "found" && result.status !== "timed_out")) throw new Error("worker returned an invalid popup status"); if (result.status === "found") { if (result.tab === undefined) throw new Error("worker returned no popup tab"); return { status: "found", tab: validateBrowserTab(result.tab, allowed), autoSwitched: false }; } if (result.tab !== undefined) throw new Error("worker returned a popup after timeout"); return { status: "timed_out", autoSwitched: false }; } function validateCloseTabResult(value: unknown): { closed: true; tabId: BrowserTab["tabId"]; mutationState: "verified" } { if (!value || typeof value !== "object") throw new Error("worker returned an invalid close-tab result"); const result = value as Record; exactKeys(result, ["closed", "mutationState", "tabId"], "close-tab result"); if (result.closed !== true || result.mutationState !== "verified") throw new Error("worker did not verify tab closure"); return { closed: true, tabId: validateTabId(result.tabId) as BrowserTab["tabId"], mutationState: "verified" }; } function validatePayload(value: unknown, expectedKind: "screenshot" | "diagnostics"): BrowserTransportArtifactPayload { if (!value || typeof value !== "object") throw new Error("worker returned an invalid artifact payload"); const payload = value as Record; exactKeys(payload, ["byteLength", "contentType", "dataBase64", "encoding", "kind", "truncated"], "artifact payload"); if (payload.kind !== expectedKind || payload.encoding !== "base64" || payload.contentType !== (expectedKind === "screenshot" ? "image/png" : "application/json")) throw new Error("worker returned an invalid artifact payload type"); if (typeof payload.dataBase64 !== "string" || payload.dataBase64.length > MAX_ARTIFACT_DATA_BASE64 || !/^(?:[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 bytes"); const bytes = Buffer.from(payload.dataBase64, "base64"); const limit = expectedKind === "screenshot" ? FAST_BROWSER_ARTIFACT_LIMITS.screenshotBytes : FAST_BROWSER_ARTIFACT_LIMITS.diagnosticsBytes; const byteLength = payload.byteLength; if (!typeOfInteger(byteLength) || byteLength > limit || bytes.byteLength !== byteLength || typeof payload.truncated !== "boolean") throw new Error("worker returned artifact bytes outside the bound"); return { kind: expectedKind, contentType: expectedKind === "screenshot" ? "image/png" : "application/json", encoding: "base64", dataBase64: payload.dataBase64, byteLength, truncated: payload.truncated }; } function validateConsole(value: unknown): ConsoleFailureSummary { if (!value || typeof value !== "object") throw new Error("worker returned invalid console evidence"); const item = value as Record; exactKeys(item, ["count", "level", "message", "truncated"], "console evidence"); if (item.level !== "error" && item.level !== "warning") throw new Error("worker returned invalid console level"); const count = item.count; if (!typeOfInteger(count) || count < 1 || count > 10_000) throw new Error("worker returned invalid console count"); return { level: item.level, message: validString(item.message, "console evidence", MAX_EVIDENCE), count, truncated: item.truncated === true ? true : item.truncated === false ? false : (() => { throw new Error("worker returned invalid console truncation") })() }; } function validateNetwork(value: unknown, allowed: OriginPolicy): NetworkFailureSummary { if (!value || typeof value !== "object") throw new Error("worker returned invalid network evidence"); const item = value as Record; const keys = Object.keys(item).sort().join(","); if (keys !== "category,method,origin" && keys !== "category,method,origin,status") throw new Error("network evidence has an invalid shape"); const origin = validString(item.origin, "network origin", 300); if (!approvedOrigin(origin, allowed) || new URL(origin).origin !== origin) throw new Error("worker returned a network path instead of an origin"); if (item.category !== "http" && item.category !== "network" && item.category !== "blocked") throw new Error("worker returned invalid network category"); const status = item.status; if (status !== undefined && (!typeOfInteger(status) || status < 100 || status > 599)) throw new Error("worker returned invalid network status"); return { method: validString(item.method, "network method", 20), origin, category: item.category, ...(status === undefined ? {} : { status }) }; } function validateEvidence(value: unknown, allowed: OriginPolicy): BrowserTransportEvidence { if (!value || typeof value !== "object") throw new Error("worker returned invalid evidence"); const evidence = value as Record; const keys = Object.keys(evidence).sort().join(","); if (!["console,failedRequests,status", "console,diagnostics,failedRequests,status", "console,failedRequests,screenshot,status", "console,diagnostics,failedRequests,screenshot,status"].includes(keys)) throw new Error("evidence has an invalid shape"); if (evidence.status !== "complete" && evidence.status !== "partial" && evidence.status !== "unavailable") throw new Error("worker returned invalid evidence status"); if (!Array.isArray(evidence.console) || evidence.console.length > 100 || !Array.isArray(evidence.failedRequests) || evidence.failedRequests.length > 100) throw new Error("worker returned too much evidence"); const result: BrowserTransportEvidence = { status: evidence.status, console: evidence.console.map(validateConsole), failedRequests: evidence.failedRequests.map((item) => validateNetwork(item, allowed)) }; if (evidence.screenshot !== undefined) result.screenshot = validatePayload(evidence.screenshot, "screenshot"); if (evidence.diagnostics !== undefined) result.diagnostics = validatePayload(evidence.diagnostics, "diagnostics"); return result; } function validateOperation(operation: BrowserOperation, base: OriginPolicy, context?: RuntimeOperationContext): OriginPolicy { if (!operation || typeof operation !== "object" || typeof operation.op !== "string") throw new Error("invalid browser operation"); const allowed = effectivePolicy(base, context); switch (operation.op) { case "observe": case "listTabs": case "close": exactKeys(operation as unknown as Record, ["op"], `${operation.op} request`); break; case "act": exactKeys(operation as unknown as Record, ["op", "request"], "act request"); validateActRequest(operation.request, allowed); break; case "read": exactKeys(operation as unknown as Record, ["op", "request"], "read request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid read request"); exactKeys(operation.request as unknown as Record, operation.request.tabId === undefined ? [] : ["tabId"], "read request"); optionalTabId(operation.request.tabId); break; case "assert": exactKeys(operation as unknown as Record, ["op", "request"], "assert request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid assertion request"); exactKeys(operation.request as unknown as Record, ["assertion", "tabId", "wait"].filter((key) => key !== "tabId" || operation.request.tabId !== undefined), "assert request"); optionalTabId(operation.request.tabId); if (operation.request.wait !== "none" && operation.request.wait !== "bounded") throw new Error("invalid assertion wait"); const assertion = operation.request.assertion; if (!assertion || typeof assertion !== "object") throw new Error("invalid page assertion"); exactKeys(assertion as unknown as Record, ["kind", "match", "value"], "page assertion"); if (assertion.kind === "url") { if (assertion.match !== "exact" && assertion.match !== "origin") throw new Error("invalid URL assertion"); validString(assertion.value, "URL assertion", MAX_STRING); if (!approvedOrigin(assertion.value, allowed)) throw new Error("URL assertion is outside the approved origins"); } else if (assertion.kind === "title") { if (assertion.match !== "exact" && assertion.match !== "contains") throw new Error("invalid title assertion"); validString(assertion.value, "title assertion", MAX_STRING); } else if (assertion.kind === "visibleText") { if (assertion.match !== "contains") throw new Error("invalid visible-text assertion"); validString(assertion.value, "visible-text assertion", MAX_STRING); } else throw new Error("invalid page assertion kind"); break; case "navigate": exactKeys(operation as unknown as Record, ["op", "request"], "navigate request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid navigation request"); exactKeys(operation.request as unknown as Record, ["url", "tabId"].filter((key) => key !== "tabId" || operation.request.tabId !== undefined), "navigate request"); optionalTabId(operation.request.tabId); const destination = validString(operation.request.url, "navigation URL", MAX_STRING); if (!approvedOrigin(destination, allowed) || new URL(destination).username || new URL(destination).password) throw new Error("navigation URL is outside the approved origins"); break; case "reload": exactKeys(operation as unknown as Record, ["op", "request"], "reload request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid reload request"); exactKeys(operation.request as unknown as Record, ["tabId"].filter((key) => operation.request.tabId !== undefined), "reload request"); optionalTabId(operation.request.tabId); break; case "selectTab": exactKeys(operation as unknown as Record, ["op", "request"], "select-tab request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid select-tab request"); exactKeys(operation.request as unknown as Record, ["tabId"], "select-tab request"); validateTabId(operation.request.tabId); break; case "waitPopup": exactKeys(operation as unknown as Record, ["op", "request"], "popup request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid popup request"); exactKeys(operation.request as unknown as Record, ["openerTabId"], "popup request"); validateTabId(operation.request.openerTabId, "opener tab id"); break; case "closeTab": exactKeys(operation as unknown as Record, ["op", "request"], "close-tab request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid close-tab request"); exactKeys(operation.request as unknown as Record, ["tabId"], "close-tab request"); validateTabId(operation.request.tabId); break; case "captureEvidence": exactKeys(operation as unknown as Record, ["op", "request"], "evidence request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid evidence request"); const evidenceRequest = operation.request; exactKeys(evidenceRequest as unknown as Record, ["runId", "reason", "operation"].filter((key) => key !== "operation" || evidenceRequest.operation !== undefined), "evidence request"); validString(evidenceRequest.runId, "evidence run id", 200); if (evidenceRequest.reason !== "failure" && evidenceRequest.reason !== "requested") throw new Error("invalid evidence reason"); if (evidenceRequest.operation !== undefined && !BROWSER_RUNTIME_OPERATIONS.includes(evidenceRequest.operation)) throw new Error("invalid evidence operation"); break; case "narrowOriginPolicy": exactKeys(operation as unknown as Record, ["op", "request"], "origin policy request"); if (!operation.request || typeof operation.request !== "object") throw new Error("invalid origin policy request"); exactKeys(operation.request as unknown as Record, ["allowedOrigins"], "origin policy request"); const requested = normalizePolicy(operation.request.allowedOrigins, "origin policy request"); if (!policyContains(base, requested)) throw new Error("origin policy cannot broaden the session policy"); break; default: throw new Error("unsupported browser operation"); } return allowed; } function validateOperationResult(operation: Operation, value: unknown, allowed: OriginPolicy): BrowserTransportResultFor { if (!value || typeof value !== "object") throw new Error("worker returned an invalid typed result envelope"); const envelope = value as Record; exactKeys(envelope, ["op", "result"], "typed result envelope"); if (envelope.op !== operation.op) throw new Error("worker returned a result for the wrong operation"); value = envelope.result; let result: { op: BrowserOperation["op"]; result: unknown }; switch (operation.op) { case "observe": result = { op: "observe", result: validateObservation(value, allowed) }; break; case "act": result = { op: "act", result: validateActResult(value) }; break; case "read": result = { op: "read", result: validateReadResult(value, allowed) }; break; case "assert": result = { op: "assert", result: validateAssertResult(value, allowed) }; break; case "navigate": result = { op: "navigate", result: validateCommittedResult(value, "navigate", allowed) }; break; case "reload": result = { op: "reload", result: validateCommittedResult(value, "reload", allowed) }; break; case "listTabs": result = { op: "listTabs", result: validateListTabsResult(value, allowed) }; break; case "selectTab": result = { op: "selectTab", result: validateSelectTabResult(value, allowed) }; break; case "waitPopup": result = { op: "waitPopup", result: validateWaitPopupResult(value, allowed) }; break; case "closeTab": result = { op: "closeTab", result: validateCloseTabResult(value) }; break; case "captureEvidence": result = { op: "captureEvidence", result: validateEvidence(value, allowed) }; break; case "narrowOriginPolicy": { if (!value || typeof value !== "object") throw new Error("worker returned an invalid origin policy context"); const contextResult = value as Record; exactKeys(contextResult, ["allowedOrigins"], "origin policy context"); const returned = normalizePolicy(contextResult.allowedOrigins, "origin policy context"); if (!policyContains(allowed, returned)) throw new Error("worker returned a broadened origin policy"); result = { op: "narrowOriginPolicy", result: { allowedOrigins: returned } }; break; } case "close": { if (!value || typeof value !== "object") throw new Error("worker returned an invalid close result"); const closeResult = value as Record; exactKeys(closeResult, ["closed"], "close result"); if (closeResult.closed !== true) throw new Error("worker did not confirm close"); result = { op: "close", result: { closed: true } }; break; } } if (!result) throw new Error("worker returned an unsupported operation result"); return result as BrowserTransportResultFor; } function validateWorkerErrorDetails(value: unknown): BrowserErrorDetails | undefined { if (!value || typeof value !== "object") return undefined; const details = value as Record; if (Object.keys(details).sort().join(",") !== "code,message,mutationState,operation,phase,retry") return undefined; if (!BROWSER_RUNTIME_OPERATIONS.includes(details.operation as typeof BROWSER_RUNTIME_OPERATIONS[number])) return undefined; const codes: BrowserErrorCode[] = ["aborted", "timeout", "stale", "policy_denied", "invalid_request", "no_tab", "ambiguous_tab", "browser_closed", "unsupported", "worker_failure"]; if (!codes.includes(details.code as BrowserErrorCode) || typeof details.phase !== "string" || !details.phase || details.phase.length > 100 || typeof details.message !== "string" || !details.message || details.message.length > MAX_STRING) return undefined; if (details.mutationState !== "not_attempted" && details.mutationState !== "verified" && details.mutationState !== "unknown") return undefined; if (details.retry !== "never" && details.retry !== "safe") return undefined; return { code: details.code as BrowserErrorCode, operation: details.operation as BrowserErrorDetails["operation"], phase: details.phase, message: details.message, mutationState: details.mutationState, retry: details.retry, }; } function parseWorkerFailure(response: Record): Error | undefined { const rawError = response.error; const nested = rawError && typeof rawError === "object" ? rawError as Record : undefined; const details = validateWorkerErrorDetails(response.details) ?? validateWorkerErrorDetails(nested?.details) ?? validateWorkerErrorDetails(nested); if (details) return new RuntimeError(details); if (typeof rawError === "string" && rawError.length <= MAX_STRING) return new WorkerOperationError(rawError); return undefined; } function rejectPending(pending: Map, error: Error): void { for (const [id, item] of pending) { clearTimeout(item.timer); if (item.signal && item.onAbort) item.signal.removeEventListener("abort", item.onAbort); if (item.parentSignal && item.onAbort) item.parentSignal.removeEventListener("abort", item.onAbort); item.reject(error); pending.delete(id); } } /** * Start one persistent restricted worker. The returned bridge serializes all * calls because browser-harness has one mutable attached tab per daemon. */ export function createBrowserBridge(options: BrowserBridgeOptions, parentSignal?: AbortSignal): BrowserBridge & BrowserTransport { const cdpUrl = loopbackCdpUrl(options.cdpUrl); const origins: OriginPolicy = options.allowedOrigins === "*" ? "*" : [...new Set(options.allowedOrigins.map(exactOrigin))]; if (Array.isArray(origins) && origins.length === 0) throw new Error("at least one allowed origin is required"); if (parentSignal?.aborted) throw abortError(); const timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; if (!Number.isInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 120_000) throw new Error("request timeout is out of bounds"); const spawnProcess = options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, [...args], spawnOptions)); const workerPath = options.workerPath ?? join(dirname(fileURLToPath(import.meta.url)), "browser_worker.py"); const python = options.python ?? globalThis.process?.env.FAST_BROWSER_PYTHON ?? "python3"; const process = spawnProcess(python, [workerPath, "--cdp-url", cdpUrl, "--allowed-origins", JSON.stringify(origins)], { env: { ...processEnvWithoutBrowserCloud(), BH_TELEMETRY: "0", BH_UPDATE_CHECK: "0", BH_RECORD: "0", BH_TAB_MARKER: "0", }, stdio: ["pipe", "pipe", "pipe"], }); const pending = new Map(); let nextId = 1; let closed = false; let closing = false; let fatal: Error | undefined; let outputBuffer = Buffer.alloc(0); let stderrBytes = 0; let childExited = false; let killEscalation: ReturnType | undefined; let lane: Promise = Promise.resolve(); let resolveExit!: () => void; const exited = new Promise((resolve) => { resolveExit = resolve; }); let closePromise: Promise | undefined; const fail = (error: Error): void => { if (!fatal) fatal = error; if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort); rejectPending(pending, fatal); }; const stopChild = (): void => { if (childExited || killEscalation) return; try { process.stdin.end(); } catch { /* already closed */ } try { process.kill("SIGTERM"); } catch { /* already exited */ } killEscalation = setTimeout(() => { if (!childExited) { try { process.kill("SIGKILL"); } catch { /* already exited */ } } }, 2_000); killEscalation.unref?.(); }; const terminate = (reason: Error): void => { fail(reason); stopChild(); }; process.stdout.setEncoding?.("utf8"); process.stdout.on("data", (chunk: string | Buffer) => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); outputBuffer = Buffer.concat([outputBuffer, bytes]); if (outputBuffer.length > MAX_RESPONSE_LINE_BYTES * 2) { terminate(new Error("browser worker output exceeds protocol limit")); return; } while (true) { const newline = outputBuffer.indexOf(10); if (newline < 0) break; const line = outputBuffer.subarray(0, newline); outputBuffer = outputBuffer.subarray(newline + 1); if (line.length > MAX_RESPONSE_LINE_BYTES) { terminate(new Error("browser worker output line exceeds protocol limit")); return; } let message: unknown; try { message = JSON.parse(line.toString("utf8")); } catch { terminate(new Error("browser worker emitted malformed JSON")); return; } if (!message || typeof message !== "object") { terminate(new Error("browser worker emitted an invalid response")); return; } const response = message as Record; const responseKeys = Object.keys(response).sort().join(","); if (responseKeys !== "error,id,ok" && responseKeys !== "details,error,id,ok" && responseKeys !== "id,ok,result") { terminate(new Error("browser worker emitted an invalid response shape")); return; } if (typeOfInteger(response.id) === false || typeof response.ok !== "boolean") { terminate(new Error("browser worker emitted an invalid response envelope")); return; } const item = pending.get(response.id as number); if (!item) { terminate(new Error("browser worker emitted an unknown response id")); return; } pending.delete(response.id as number); clearTimeout(item.timer); if (item.signal && item.onAbort) item.signal.removeEventListener("abort", item.onAbort); if (item.parentSignal && item.onAbort) item.parentSignal.removeEventListener("abort", item.onAbort); if (response.ok && "result" in response) item.resolve(response.result); else if (!response.ok) { const failure = parseWorkerFailure(response); if (failure) item.reject(failure); else { const error = new Error("browser worker emitted an invalid result envelope"); item.reject(error); terminate(error); } } else { const error = new Error("browser worker emitted an invalid result envelope"); item.reject(error); terminate(error); } } }); process.stderr?.on("data", (chunk: string | Buffer) => { stderrBytes += Buffer.byteLength(String(chunk)); if (stderrBytes > MAX_REQUEST_LINE_BYTES) terminate(new Error("browser worker diagnostics exceed protocol limit")); }); process.stdin.once("error", () => terminate(new Error("browser worker input stream failed"))); process.once("error", () => { resolveExit(); fail(new Error("Browser worker could not start. Set FAST_BROWSER_PYTHON to a Python environment with browser-harness==0.1.13.")); }); process.once("close", (code: number | null, signal: string | null) => { childExited = true; resolveExit(); if (killEscalation) clearTimeout(killEscalation); if (!closed && !fatal) fail(new Error(`Browser worker exited (${code ?? "signal " + (signal ?? "unknown")}). Check the local endpoint, one approved tab, and FAST_BROWSER_PYTHON with browser-harness==0.1.13.`)); }); const requestWire = (wire: Record, signal?: AbortSignal): Promise => { const op = typeof wire.op === "string" ? wire.op : undefined; const operationName = op ?? (wire.operation && typeof wire.operation === "object" && "op" in wire.operation ? String((wire.operation as Record).op) : undefined); if (closed || (closing && operationName !== "close")) return Promise.reject(new Error("browser bridge is closed")); if (fatal) return Promise.reject(fatal); if (signal?.aborted || parentSignal?.aborted) return Promise.reject(abortError()); const id = nextId++; const payload = JSON.stringify({ id, ...wire }) + "\n"; if (Buffer.byteLength(payload) > MAX_REQUEST_LINE_BYTES) return Promise.reject(new Error("browser request exceeds protocol limit")); return new Promise((resolve, reject) => { const timer = setTimeout(() => { const item = pending.get(id); if (!item) return; pending.delete(id); if (signal) signal.removeEventListener("abort", onAbort); if (parentSignal && parentSignal !== signal) parentSignal.removeEventListener("abort", onAbort); const error = new Error("browser worker request timed out; worker was torn down"); reject(error); terminate(error); }, timeoutMs); const item: Pending = { resolve, reject, timer, signal, parentSignal }; const onAbort = (): void => { if (!pending.delete(id)) return; clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); if (parentSignal && parentSignal !== signal) parentSignal.removeEventListener("abort", onAbort); const error = abortError(); reject(error); terminate(error); }; item.onAbort = onAbort; if (signal) signal.addEventListener("abort", onAbort, { once: true }); if (parentSignal && parentSignal !== signal) parentSignal.addEventListener("abort", onAbort, { once: true }); pending.set(id, item); try { if (!process.stdin.write(payload)) { // Backpressure is not an error; the stream will flush. A closed // stdin, however, emits close/error and rejects this request. } } catch (error) { pending.delete(id); clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); if (parentSignal && parentSignal !== signal) parentSignal.removeEventListener("abort", onAbort); reject(error instanceof Error ? error : new Error(String(error))); } }); }; const serialized = (operation: (signal?: AbortSignal) => Promise, signal?: AbortSignal): Promise => { const result = lane.then(() => { if (signal?.aborted || parentSignal?.aborted) throw abortError(); return operation(signal); }); lane = result.catch(() => undefined); return result; }; const onParentAbort = (): void => { terminate(abortError()); }; const close = (): Promise => { if (closePromise) return closePromise; closePromise = (async () => { closing = true; if (!fatal) { try { await lane; await requestWire({ operation: { op: "close" } }, undefined); } catch { // Teardown remains best-effort after an already failed worker; it is // never retried because an action may have completed before a timeout. } } closed = true; if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort); stopChild(); rejectPending(pending, new Error("browser bridge closed")); // Do not release the caller's browser lane while its worker is still live. let deadline: ReturnType | undefined; try { await Promise.race([exited, new Promise((resolve) => { deadline = setTimeout(resolve, 2_500); })]); } finally { if (deadline) clearTimeout(deadline); } })(); return closePromise; }; const bridge: BrowserBridge & BrowserTransport = { dispatch(transportRequest: BrowserTransportRequest, signal: AbortSignal): Promise> { if (!transportRequest || typeof transportRequest !== "object" || !transportRequest.operation) { return Promise.reject(transportFailure(new Error("invalid browser transport request"), "observe", "request", false, "invalid_request")); } const operationName = BROWSER_RUNTIME_OPERATIONS.includes(transportRequest.operation.op as typeof BROWSER_RUNTIME_OPERATIONS[number]) ? transportRequest.operation.op : "observe"; const transportKeys = Object.keys(transportRequest as unknown as Record).sort().join(","); if (transportKeys !== "operation" && transportKeys !== "context,operation") { return Promise.reject(transportFailure(new Error("invalid browser transport request"), operationName, "request", false, "invalid_request")); } if (transportRequest.context !== undefined) { if (!transportRequest.context || typeof transportRequest.context !== "object") return Promise.reject(transportFailure(new Error("invalid browser operation context"), operationName, "request", false, "invalid_request")); try { exactKeys(transportRequest.context as unknown as Record, ["allowedOrigins"], "operation context"); } catch (error) { return Promise.reject(transportFailure(error, operationName, "request", false, "invalid_request")); } } let allowed: OriginPolicy; try { allowed = validateOperation(transportRequest.operation, origins, transportRequest.context); } catch (error) { return Promise.reject(transportFailure(error, operationName, "request", false, "invalid_request")); } const operation = transportRequest.operation; const dispatched = serialized(async (requestSignal) => { try { const value = await requestWire({ operation: operation, ...(transportRequest.context === undefined ? {} : { context: { allowedOrigins: allowed } }) }, requestSignal); return validateOperationResult(operation, value, allowed); } catch (error) { if (!(error instanceof RuntimeError) && !(error instanceof WorkerOperationError) && !(error instanceof Error && error.name === "AbortError")) { terminate(error instanceof Error ? error : new Error("browser worker operation failed")); } throw transportFailure(error, operation.op, "transport", true); } }, signal); return dispatched.catch((error) => { if (error instanceof RuntimeError) throw error; throw transportFailure(error, operation.op, "request", false); }); }, observe(signal) { return serialized(async (requestSignal) => { const value = await requestWire({ operation: { op: "observe" } }, requestSignal); try { return validateOperationResult({ op: "observe" }, value, origins).result; } catch (error) { const failure = error instanceof Error ? error : new Error(String(error)); terminate(failure); throw failure; } }, signal); }, act(action, signal) { validateActRequest(action, origins); return serialized(async (requestSignal) => { const value = await requestWire({ operation: { op: "act", request: action } }, requestSignal); try { return validateOperationResult({ op: "act", request: action }, value, origins).result; } catch (error) { const failure = error instanceof Error ? error : new Error(String(error)); terminate(failure); throw failure; } }, signal); }, close, }; if (parentSignal) parentSignal.addEventListener("abort", onParentAbort, { once: true }); return bridge; } /** Create the typed, persistent JSONL transport. It owns one worker until * close; callers reuse it for every operation in one browser session. */ export function createBrowserTransport( options: BrowserTransportOptions & Partial>, parentSignal?: AbortSignal, ): BrowserTransport { return createBrowserBridge({ cdpUrl: options.cdpUrl, allowedOrigins: options.allowedOrigins === "*" ? "*" : [...options.allowedOrigins], ...(options.python === undefined ? {} : { python: options.python }), ...(options.spawn === undefined ? {} : { spawn: options.spawn }), ...(options.workerPath === undefined ? {} : { workerPath: options.workerPath }), ...(options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: options.requestTimeoutMs }), }, parentSignal); } export const browserTransportFactory: BrowserTransportFactory = { create(options, parentSignal) { return createBrowserTransport(options, parentSignal); }, }; function typeOfInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function processEnvWithoutBrowserCloud(): NodeJS.ProcessEnv { // Do not inherit the Pi process environment wholesale: browser-harness // loads its own .env files and a child daemon would otherwise receive model, // cloud-browser, or provider credentials. These are the only values needed // to locate Python and create the worker's private harness directories. const env: NodeJS.ProcessEnv = {}; const names = [ "PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR", "SYSTEMROOT", "COMSPEC", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", ]; for (const name of names) { const value = globalThis.process?.env[name]; if (value !== undefined) env[name] = value; } for (const [name, value] of Object.entries(globalThis.process?.env ?? {})) { if (name.startsWith("LC_") && value !== undefined) env[name] = value; } env.PYTHONNOUSERSITE = "1"; env.PYTHONUTF8 = "1"; return env; }