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