973 lines
52 KiB
TypeScript
973 lines
52 KiB
TypeScript
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<typeof setTimeout>;
|
|
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<BrowserErrorCode, string> = {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>, 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>, ["op"], `${operation.op} request`);
|
|
break;
|
|
case "act":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["op", "request"], "act request");
|
|
validateActRequest(operation.request, allowed);
|
|
break;
|
|
case "read":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["op", "request"], "read request");
|
|
if (!operation.request || typeof operation.request !== "object") throw new Error("invalid read request");
|
|
exactKeys(operation.request as unknown as Record<string, unknown>, operation.request.tabId === undefined ? [] : ["tabId"], "read request");
|
|
optionalTabId(operation.request.tabId);
|
|
break;
|
|
case "assert":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["op", "request"], "assert request");
|
|
if (!operation.request || typeof operation.request !== "object") throw new Error("invalid assertion request");
|
|
exactKeys(operation.request as unknown as Record<string, unknown>, ["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<string, unknown>, ["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<string, unknown>, ["op", "request"], "navigate request");
|
|
if (!operation.request || typeof operation.request !== "object") throw new Error("invalid navigation request");
|
|
exactKeys(operation.request as unknown as Record<string, unknown>, ["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<string, unknown>, ["op", "request"], "reload request");
|
|
if (!operation.request || typeof operation.request !== "object") throw new Error("invalid reload request");
|
|
exactKeys(operation.request as unknown as Record<string, unknown>, ["tabId"].filter((key) => operation.request.tabId !== undefined), "reload request");
|
|
optionalTabId(operation.request.tabId);
|
|
break;
|
|
case "selectTab":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["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<string, unknown>, ["tabId"], "select-tab request");
|
|
validateTabId(operation.request.tabId);
|
|
break;
|
|
case "waitPopup":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["op", "request"], "popup request");
|
|
if (!operation.request || typeof operation.request !== "object") throw new Error("invalid popup request");
|
|
exactKeys(operation.request as unknown as Record<string, unknown>, ["openerTabId"], "popup request");
|
|
validateTabId(operation.request.openerTabId, "opener tab id");
|
|
break;
|
|
case "closeTab":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["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<string, unknown>, ["tabId"], "close-tab request");
|
|
validateTabId(operation.request.tabId);
|
|
break;
|
|
case "captureEvidence":
|
|
exactKeys(operation as unknown as Record<string, unknown>, ["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<string, unknown>, ["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<string, unknown>, ["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<string, unknown>, ["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 extends BrowserOperation>(operation: Operation, value: unknown, allowed: OriginPolicy): BrowserTransportResultFor<Operation> {
|
|
if (!value || typeof value !== "object") throw new Error("worker returned an invalid typed result envelope");
|
|
const envelope = value as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<Operation>;
|
|
}
|
|
|
|
function validateWorkerErrorDetails(value: unknown): BrowserErrorDetails | undefined {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const details = value as Record<string, unknown>;
|
|
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<string, unknown>): Error | undefined {
|
|
const rawError = response.error;
|
|
const nested = rawError && typeof rawError === "object" ? rawError as Record<string, unknown> : 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<number, Pending>, 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<number, Pending>();
|
|
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<typeof setTimeout> | undefined;
|
|
let lane: Promise<unknown> = Promise.resolve();
|
|
let resolveExit!: () => void;
|
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
let closePromise: Promise<void> | 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<string, unknown>;
|
|
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<string, unknown>, signal?: AbortSignal): Promise<unknown> => {
|
|
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<string, unknown>).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 = <T>(operation: (signal?: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
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<void> => {
|
|
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<typeof setTimeout> | undefined;
|
|
try {
|
|
await Promise.race([exited, new Promise<void>((resolve) => { deadline = setTimeout(resolve, 2_500); })]);
|
|
} finally { if (deadline) clearTimeout(deadline); }
|
|
})();
|
|
return closePromise;
|
|
};
|
|
|
|
const bridge: BrowserBridge & BrowserTransport = {
|
|
dispatch<Operation extends BrowserOperation>(transportRequest: BrowserTransportRequest<Operation>, signal: AbortSignal): Promise<BrowserTransportResultFor<Operation>> {
|
|
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<string, unknown>).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<string, unknown>, ["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<Pick<BrowserBridgeOptions, "spawn" | "workerPath" | "requestTimeoutMs">>,
|
|
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;
|
|
}
|