import { StringEnum } from "@earendil-works/pi-ai"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { Static } from "typebox"; import { createBrowserBridge, type BrowserBridgeOptions } from "./bridge.ts"; import { registerFastBrowserCommand } from "./command.ts"; import { createBrowserSessionManager, type BrowserSession, type BrowserSessionManager, } from "./browser-session.ts"; import type { ControllerDeps } from "./controller.ts"; import { runSteps, validateSteps, TextGenerationError } from "./controller.ts"; import { createCredentialResolver } from "./credentials.ts"; import { createJevDecider, DecisionError } from "./jev.ts"; import { createBrowserRuntime, createBrowserRuntimeFactory, createInMemoryArtifactStore, } from "./runtime.ts"; import { BROWSER_EVIDENCE_OPERATIONS } from "./contracts.ts"; import type { BrowserBridge, BrowserRuntime, BrowserRuntimeOptions, BrowserTransportFactory, Candidate, CredentialBindings, PrivateArtifactStore, RunResult, RuntimeOperationContext, Step, } from "./contracts.ts"; import { loadFastBrowserConfig, type FastBrowserConfig, } from "./config.ts"; const MAX_STEPS = 10; const MAX_RETURN_TEXT = 512; const MAX_RETURN_CANDIDATES = 60; const MAX_INSTRUCTION = 2_000; const MAX_COMPOSE = 2_000; const MAX_TEXT = 8_000; const MAX_POSTCONDITION = 2_000; const MAX_TASK = 4_000; const MAX_GENERATION_TOKENS = 512; const TYPESAFE_BINDING = "__fast_browser_typesafe_key"; const TEXT_REASONING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; type TextReasoning = (typeof TEXT_REASONING_LEVELS)[number]; const TEXT_GENERATION_SYSTEM_PROMPT = "Return only the plain field value. Do not add quotes, Markdown, labels, explanations, or code fences. Use only facts explicitly supplied in the user request; never invent or infer facts."; const postconditionSchema = Type.Object({ kind: StringEnum(["url", "text"] as const), value: Type.String({ minLength: 1, maxLength: MAX_POSTCONDITION }), }); const stepSchema = Type.Object({ instruction: Type.String({ minLength: 1, maxLength: MAX_INSTRUCTION }), action: StringEnum(["click", "fill"] as const), text: Type.Optional(Type.String({ maxLength: MAX_TEXT })), compose: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_COMPOSE })), credential: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })), postcondition: Type.Optional(postconditionSchema), }); const credentialSourceSchema = Type.Union([ Type.String({ minLength: 1, maxLength: MAX_TEXT }), Type.Object({ env: Type.String({ minLength: 1, maxLength: 200 }), }), Type.Object({ file: Type.String({ minLength: 1, maxLength: 4_000 }), key: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), }), ]); const typesafeKeySchema = Type.Union([ Type.Object({ env: Type.String({ minLength: 1, maxLength: 200 }), }), Type.Object({ file: Type.String({ minLength: 1, maxLength: 4_000 }), key: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), }), ]); const browserSettingsSchema = Type.Object({ mode: StringEnum(["attach", "launch"] as const), product: Type.Optional(StringEnum(["vivaldi", "chrome", "chromium"] as const)), executablePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000 })), headless: Type.Optional(Type.Boolean()), startUrl: Type.Optional(Type.String({ minLength: 1, maxLength: 8_000 })), profile: Type.Optional(Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", })), }); /** * Per-call settings are validated by loadFastBrowserConfig before a bridge is * created. `typesafeKey` is intentionally represented as a source reference * in saved configuration; callers may instead bind a source in `credentials`. */ export const settingsSchema = Type.Object({ cdpUrl: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000 })), allowedOrigins: Type.Optional(Type.Union([ Type.Literal("*"), Type.Array(Type.String({ minLength: 1, maxLength: 4_000 }), { minItems: 1, maxItems: 20 }), ])), browser: Type.Optional(browserSettingsSchema), python: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000 })), textModel: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })), textReasoning: Type.Optional(StringEnum(TEXT_REASONING_LEVELS)), typesafeKey: Type.Optional(typesafeKeySchema), }); const budgetsSchema = Type.Object({ maxSteps: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_STEPS })), timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: 120_000 })), minConfidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), }); export const openParameters = Type.Object({ settings: Type.Optional(settingsSchema), }); export const closeParameters = Type.Object({ sessionId: Type.String({ minLength: 1, maxLength: 200 }), }); export const observeParameters = Type.Object({ sessionId: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), settings: Type.Optional(settingsSchema), }); const runtimeSessionParameters = Type.Object({ sessionId: Type.String({ minLength: 1, maxLength: 200 }), settings: Type.Optional(settingsSchema), }); const tabIdSchema = Type.String({ minLength: 1, maxLength: 200 }); const readParameters = Type.Intersect([ runtimeSessionParameters, Type.Object({ tabId: Type.Optional(tabIdSchema) }), ]); const assertionSchema = Type.Union([ Type.Object({ kind: Type.Literal("url"), match: StringEnum(["exact", "origin"] as const), value: Type.String({ minLength: 1, maxLength: MAX_POSTCONDITION }) }), Type.Object({ kind: Type.Literal("title"), match: Type.Literal("exact"), value: Type.String({ minLength: 1, maxLength: MAX_POSTCONDITION }) }), Type.Object({ kind: Type.Literal("title"), match: Type.Literal("contains"), value: Type.String({ minLength: 1, maxLength: MAX_POSTCONDITION }) }), Type.Object({ kind: Type.Literal("visibleText"), match: Type.Literal("contains"), value: Type.String({ minLength: 1, maxLength: MAX_POSTCONDITION }) }), ]); const assertParameters = Type.Intersect([ runtimeSessionParameters, Type.Object({ tabId: Type.Optional(tabIdSchema), assertion: assertionSchema, wait: StringEnum(["none", "bounded"] as const), }), ]); const navigateOperation = Type.Object({ op: Type.Literal("navigate"), request: Type.Object({ tabId: Type.Optional(tabIdSchema), url: Type.String({ minLength: 1, maxLength: 8_000 }) }), }); const reloadOperation = Type.Object({ op: Type.Literal("reload"), request: Type.Object({ tabId: Type.Optional(tabIdSchema) }), }); const listTabsOperation = Type.Object({ op: Type.Literal("listTabs") }); const selectTabOperation = Type.Object({ op: Type.Literal("selectTab"), request: Type.Object({ tabId: tabIdSchema }), }); const waitPopupOperation = Type.Object({ op: Type.Literal("waitPopup"), request: Type.Object({ openerTabId: tabIdSchema }), }); const closeTabOperation = Type.Object({ op: Type.Literal("closeTab"), request: Type.Object({ tabId: tabIdSchema }), }); const controlParameters = Type.Intersect([ runtimeSessionParameters, Type.Object({ operation: Type.Union([ navigateOperation, reloadOperation, listTabsOperation, selectTabOperation, waitPopupOperation, closeTabOperation, ]) }), ]); const evidenceParameters = Type.Intersect([ runtimeSessionParameters, Type.Object({ runId: Type.String({ minLength: 1, maxLength: 200 }), reason: StringEnum(["failure", "requested"] as const), operation: Type.Optional(StringEnum(BROWSER_EVIDENCE_OPERATIONS)) }), ]); export const runParameters = Type.Object({ sessionId: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), steps: Type.Array(stepSchema, { minItems: 1, maxItems: MAX_STEPS, description: `A bounded sequence of at most ${MAX_STEPS} click/fill steps.`, }), /** Optional per-call settings, including the caller-authorized origin allowlist. */ settings: Type.Optional(settingsSchema), /** Human-readable bounded task context; it does not expand the supplied plan. */ task: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_TASK })), /** Credential source declarations. Values are resolved only for credential fills. */ credentials: Type.Optional(Type.Record(Type.String({ minLength: 1, maxLength: 100 }), credentialSourceSchema, { maxProperties: 40 })), /** A lower per-call cap is useful for callers that want an early stop. */ maxSteps: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_STEPS })), timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: 120_000 })), minConfidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), budgets: Type.Optional(budgetsSchema), }); export type FastBrowserRunInput = Static; export type FastBrowserObserveInput = Static; export type FastBrowserSettings = Static; type CreateBridge = (options: BrowserBridgeOptions, parentSignal?: AbortSignal) => BrowserBridge; type CreateDecider = typeof createJevDecider; type RunSteps = typeof runSteps; type CreateSessionManager = typeof createBrowserSessionManager; type CreateRuntime = typeof createBrowserRuntime; /** Injectable only for offline adapter tests and CLI composition. */ export interface FastBrowserAdapters { createBrowserBridge?: CreateBridge; createBrowserRuntime?: CreateRuntime; transportFactory?: BrowserTransportFactory; createArtifactStore?: (sessionId: string) => PrivateArtifactStore; createJevDecider?: CreateDecider; createSessionManager?: CreateSessionManager; runSteps?: RunSteps; } function bounded(value: string, max = MAX_RETURN_TEXT): string { return value.length > max ? `${value.slice(0, max)}…` : value; } function isAlreadyClosedSessionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /unknown browser session|session is no longer available|unknown session/i.test(message); } function errorMessage(error: unknown, secrets: Iterable = []): string { const message = error instanceof Error ? error.message : String(error); const values = new Set(secrets); const ambient = process.env.TYPESAFE_API_KEY?.trim(); if (ambient) values.add(ambient); let redacted = message; for (const value of values) { if (value) redacted = redacted.split(value).join("[redacted]"); } return bounded(redacted); } function publicObservation(observation: { observationId: string; url: string; title: string; candidates: Candidate[]; }) { let publicUrl = observation.url; try { const parsed = new URL(observation.url); // Keep paths, query strings, and fragments private to the local worker. publicUrl = parsed.origin; } catch { // The bridge validates URLs; retain a bounded fallback for injected tests. } return { observationId: bounded(observation.observationId), url: bounded(publicUrl, 2_048), title: bounded(observation.title, MAX_RETURN_TEXT), candidates: observation.candidates.slice(0, MAX_RETURN_CANDIDATES).map((candidate) => ({ id: bounded(candidate.id), role: bounded(candidate.role), name: bounded(candidate.name), actions: candidate.actions.slice(), })), }; } function publicRunResult(result: RunResult) { const safetyResult = result as RunResult & { actionMayHaveExecuted?: boolean }; const actionMayHaveExecuted = safetyResult.actionMayHaveExecuted ?? result.steps.some((step) => step.status === "verified" || step.status === "unverified" || step.status === "error", ); return { status: result.status, reason: bounded(result.reason), actionMayHaveExecuted, elapsedMs: Number.isFinite(result.elapsedMs) ? Math.max(0, result.elapsedMs) : 0, steps: result.steps.slice(0, MAX_STEPS).map((step) => { const safetyStep = step as typeof step & { mutationState?: string }; const mutationState = ["not_attempted", "verified", "unknown"].includes( safetyStep.mutationState ?? "", ) ? safetyStep.mutationState : step.status === "verified" ? "verified" : step.status === "unverified" || step.status === "error" ? "unknown" : "not_attempted"; return { step: step.step, status: step.status, timingsMs: Object.fromEntries( Object.entries(step.timingsMs).map(([name, value]) => [ bounded(name, 64), Number.isFinite(value) ? Math.max(0, value) : 0, ]), ), ...(step.candidateId ? { candidateId: bounded(step.candidateId) } : {}), ...(step.confidence === undefined ? {} : { confidence: step.confidence }), ...(step.model ? { model: bounded(step.model) } : {}), mutationState, ...(step.evidence ? { evidence: bounded(step.evidence) } : {}), }; }), }; } function jsonText(value: unknown): string { try { return JSON.stringify(value); } catch { return "{}"; } } function toolResult(text: string, details: unknown) { return { content: [{ type: "text" as const, text }], details }; } function abortSignal(signal: AbortSignal | undefined): AbortSignal { return signal ?? new AbortController().signal; } function resolveTextModel(ctx: ExtensionContext, modelHint: string) { const available = ctx.modelRegistry.getAvailable(); const slash = modelHint.indexOf("/"); const matches = slash > 0 ? available.filter( (model) => model.provider === modelHint.slice(0, slash) && model.id === modelHint.slice(slash + 1), ) : available.filter((model) => model.id === modelHint); // A bare id must be unambiguous, and catalogue presence is not enough: // Pi must have configured authentication for the selected model. if (matches.length !== 1) return undefined; const model = matches[0]; return ctx.modelRegistry.hasConfiguredAuth(model) ? model : undefined; } function validateGeneratedText(value: string): string { const text = value.trim(); if (!text || text.length > MAX_TEXT) { throw new TextGenerationError("Auxiliary text model returned empty or oversized text"); } if ( text.startsWith("```") || text.endsWith("```") || /^(?:#{1,6}\s|[-*+]\s|\d+[.)]\s)/.test(text) || ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) ) { throw new TextGenerationError("Auxiliary text model returned formatted text"); } return text; } function configTextReasoning(config: FastBrowserConfig): TextReasoning { return config.textReasoning ?? "off"; } function makeTextGenerator( ctx: ExtensionContext, config: FastBrowserConfig, ): ControllerDeps["generateText"] { if (!config.textModel) return undefined; // Model catalogue/auth checks are deliberately lazy. A configured compose // model must not break literal or credential-only fills that never call it. return async (prompt, signal) => { const model = resolveTextModel(ctx, config.textModel!); if (!model) { throw new TextGenerationError( "Configured auxiliary text model is unavailable or unauthenticated", ); } const reasoning = configTextReasoning(config); // Codex is the only provider with an explicit mapping here. For a // non-Codex reasoning model, even off must fail rather than silently use a // provider default. Models explicitly marked non-reasoning are fine. if (model.api !== "openai-codex-responses" && (reasoning !== "off" || model.reasoning === true)) { throw new TextGenerationError( `Text reasoning level ${reasoning} is unsupported for provider ${model.provider}`, ); } const response = await ctx.modelRegistry.complete( model, { systemPrompt: TEXT_GENERATION_SYSTEM_PROMPT, messages: [ { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now(), }, ], }, { signal, maxTokens: MAX_GENERATION_TOKENS, ...(model.api === "openai-codex-responses" ? { // Codex has no distinct minimal effort; this documented mapping // is intentionally explicit rather than silently pretending it // is an exact provider level. reasoningEffort: reasoning === "off" ? ("none" as const) : (reasoning === "minimal" ? ("low" as const) : reasoning), } : {}), }, ).catch(() => { throw new TextGenerationError( "Auxiliary text provider request failed; check model access and authentication", ); }); // Only an actual complete stop is acceptable. Never use partial content // from a provider error, length limit, tool call, or abort. if (response.stopReason !== "stop") { if ( response.stopReason === "error" && /not supported.*ChatGPT account/i.test(response.errorMessage ?? "") ) { throw new TextGenerationError( "Configured auxiliary model is not supported by the current ChatGPT account", ); } throw new TextGenerationError( "Auxiliary text model did not complete successfully; check model access or output limit", ); } const text = response.content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join("\n"); return validateGeneratedText(text); }; } function asSteps(steps: FastBrowserRunInput["steps"]): Step[] { return steps.map((step) => ({ instruction: step.instruction, action: step.action, ...(step.text === undefined ? {} : { text: step.text }), ...(step.compose === undefined ? {} : { compose: step.compose }), ...(step.credential === undefined ? {} : { credential: step.credential }), ...(step.postcondition === undefined ? {} : { postcondition: step.postcondition }), })); } type SessionLane = (sessionId: string, operation: () => Promise) => Promise; function createSessionLane(): SessionLane { const tails = new Map>(); return (sessionId: string, operation: () => Promise): Promise => { const previous = tails.get(sessionId) ?? Promise.resolve(); const current = previous.catch(() => undefined).then(operation); tails.set(sessionId, current); const cleanup = () => { if (tails.get(sessionId) === current) tails.delete(sessionId); }; // Do not use an unobserved finally promise: a rejected operation must // remain the caller's rejection, not become an unhandled rejection. void current.then(cleanup, cleanup); return current; }; } async function loadConfig( ctx: ExtensionContext, settings: FastBrowserSettings | undefined, ): Promise { return await loadFastBrowserConfig( ctx.cwd || process.cwd(), settings ?? {}, { agentDir: getAgentDir(), env: process.env }, ); } function credentialBindings( input: FastBrowserRunInput, config: FastBrowserConfig, ): { bindings: CredentialBindings; configuredTypesafeKey: boolean } { const bindings = { ...((input.credentials ?? {}) as CredentialBindings) }; const typesafeKey = config.typesafeKey; if (typesafeKey !== undefined) { if (Object.hasOwn(bindings, TYPESAFE_BINDING)) { throw new Error(`Credential name ${TYPESAFE_BINDING} is reserved`); } bindings[TYPESAFE_BINDING] = typesafeKey; } return { bindings, configuredTypesafeKey: typesafeKey !== undefined }; } function canonicalOrigin(value: string): string { let parsed: URL; try { parsed = new URL(value); } catch { throw new Error("allowedOrigins must contain exact http(s) origins"); } if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) { throw new Error("allowedOrigins must contain exact http(s) origins"); } return parsed.origin; } type OriginPolicy = FastBrowserConfig["allowedOrigins"]; function canonicalPolicy(value: OriginPolicy): OriginPolicy { if (value === "*") return "*"; return value.map(canonicalOrigin); } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } /** * A session owns its connection policy. Per-call settings may narrow origins * and override text-model/API-key sources, but never retarget the browser. */ async function configForSession( ctx: ExtensionContext, session: BrowserSession, settings: FastBrowserSettings | undefined, ): Promise { if (!settings) return session.config; if (settings.cdpUrl !== undefined && settings.cdpUrl !== session.config.cdpUrl) { throw new Error("sessionId owns cdpUrl; per-call settings cannot retarget it"); } if (settings.browser !== undefined && !sameJson(settings.browser, session.config.browser)) { throw new Error("sessionId owns browser settings; per-call settings cannot retarget it"); } if (settings.python !== undefined && settings.python !== session.config.python) { throw new Error("sessionId owns the browser worker; per-call settings cannot retarget Python"); } const storedPolicy = canonicalPolicy(session.config.allowedOrigins); const requestedPolicy = settings.allowedOrigins === undefined ? storedPolicy : canonicalPolicy(settings.allowedOrigins); if (storedPolicy !== "*" && requestedPolicy === "*") { throw new Error("sessionId allowedOrigins cannot be broadened"); } if (storedPolicy !== "*" && requestedPolicy !== "*") { const sessionOrigins = new Set(storedPolicy); if (requestedPolicy.some((origin) => !sessionOrigins.has(origin))) { throw new Error("sessionId allowedOrigins cannot be broadened"); } } const narrowed = storedPolicy === "*" ? requestedPolicy !== "*" : requestedPolicy !== "*" && ( requestedPolicy.length !== storedPolicy.length || requestedPolicy.some((origin, index) => origin !== storedPolicy[index]) ); const merged = { ...session.config, allowedOrigins: requestedPolicy, // A launched session is already running. When a per-call allowlist is // narrower than its launch policy, validate the active endpoint as attach // mode so the original startUrl is not incorrectly required to remain in // the narrower action allowlist. ...(narrowed && session.config.browser?.mode === "launch" ? { browser: { mode: "attach" as const } } : {}), ...(settings.textModel === undefined ? {} : { textModel: settings.textModel }), ...(settings.textReasoning === undefined ? {} : { textReasoning: settings.textReasoning }), ...(settings.typesafeKey === undefined ? {} : { typesafeKey: settings.typesafeKey }), } as FastBrowserSettings; return loadConfig(ctx, merged); } function bridgeOptions(config: FastBrowserConfig): { cdpUrl: string; allowedOrigins: OriginPolicy; python?: string } { if (!config.cdpUrl) throw new Error("browser session has no CDP endpoint"); return { cdpUrl: config.cdpUrl, allowedOrigins: config.allowedOrigins, ...(config.python ? { python: config.python } : {}), }; } interface SessionLease { session: BrowserSession; config: FastBrowserConfig; signal: AbortSignal; ephemeral: boolean; } async function leaseSession( manager: BrowserSessionManager, ctx: ExtensionContext, sessionId: string | undefined, settings: FastBrowserSettings | undefined, signal: AbortSignal, ): Promise { if (sessionId !== undefined) { const session = manager.get(sessionId); const config = await configForSession(ctx, session, settings); return { session, config, signal: AbortSignal.any([signal, session.signal]), ephemeral: false, }; } const config = await loadConfig(ctx, settings); if (config.browser?.mode === "launch") { throw new Error("browser launch mode requires fast_browser_open before observe or run"); } const session = await manager.open(config, signal); return { session, config: session.config, signal: AbortSignal.any([signal, session.signal]), ephemeral: true, }; } function publicSession(session: BrowserSession) { const browser = session.config.browser; return { sessionId: bounded(session.id, 200), owned: session.owned, browser: { mode: browser?.mode ?? "attach", ...(browser?.product ? { product: browser.product } : {}), ...(browser?.headless === undefined ? {} : { headless: browser.headless }), ...(browser?.profile === undefined ? {} : { profile: bounded(browser.profile, 64) }), }, }; } interface RuntimeRegistry { readonly enabled: boolean; readonly runtimes: Map; create(session: BrowserSession): BrowserRuntime; get(session: BrowserSession): BrowserRuntime; has(sessionId: string): boolean; close(sessionId: string): Promise; closeAll(): Promise; } function createRuntimeRegistry(adapters: FastBrowserAdapters): RuntimeRegistry { // Existing bridge-only adapter tests and embeddings retain their legacy // seam. Production/CLI uses the persistent runtime by default. const legacy = Boolean(adapters.createBrowserBridge && !adapters.createBrowserRuntime && !adapters.transportFactory); const runtimes = new Map(); const abortHandlers = new Map void>(); const closing = new Map>(); const cleanupFailures = new Map(); // Keep only the signal lookup needed to detach abort listeners. const sessions = new Map(); const closeRuntime = (sessionId: string, runtime: BrowserRuntime): Promise => { if (runtimes.get(sessionId) !== runtime) return closing.get(sessionId) ?? Promise.resolve(); runtimes.delete(sessionId); const handler = abortHandlers.get(sessionId); if (handler) { abortHandlers.delete(sessionId); // The session signal may be aborting right now; removing the listener // keeps explicit cleanup and natural browser-exit cleanup idempotent. const session = sessions.get(sessionId); session?.signal.removeEventListener("abort", handler); } sessions.delete(sessionId); const existing = closing.get(sessionId); if (existing) return existing; const done = runtime.close().finally(() => closing.delete(sessionId)); closing.set(sessionId, done); return done; }; const create = (session: BrowserSession): BrowserRuntime => { const existing = runtimes.get(session.id); if (existing) return existing; if (session.signal.aborted || closing.has(session.id) || cleanupFailures.has(session.id)) { throw new Error("browser session lease is closed"); } const artifacts = adapters.createArtifactStore?.(session.id) ?? createInMemoryArtifactStore(); const options: BrowserRuntimeOptions = { sessionId: session.id, cdpUrl: session.config.cdpUrl, allowedOrigins: session.config.allowedOrigins, ...(session.config.python ? { python: session.config.python } : {}), artifacts, ...(adapters.transportFactory ? { transportFactory: adapters.transportFactory } : {}), }; const runtime = adapters.createBrowserRuntime ? adapters.createBrowserRuntime(options, session.signal) : createBrowserRuntimeFactory(adapters.transportFactory).create(options, session.signal); const onAbort = () => { void closeRuntime(session.id, runtime).catch((error) => { // Abort listeners cannot throw into the event dispatcher. Retain the // failure for the next explicit close/shutdown to report. cleanupFailures.set(session.id, error); }); }; sessions.set(session.id, session); abortHandlers.set(session.id, onAbort); session.signal.addEventListener("abort", onAbort, { once: true }); runtimes.set(session.id, runtime); if (session.signal.aborted) onAbort(); return runtime; }; return { enabled: !legacy, runtimes, create, get(session) { return create(session); }, has(sessionId) { return runtimes.has(sessionId) || closing.has(sessionId) || cleanupFailures.has(sessionId); }, async close(sessionId) { const runtime = runtimes.get(sessionId); try { if (runtime) await closeRuntime(sessionId, runtime); else await (closing.get(sessionId) ?? Promise.resolve()); } catch (error) { cleanupFailures.delete(sessionId); throw error; } const failure = cleanupFailures.get(sessionId); if (failure) { cleanupFailures.delete(sessionId); throw failure; } }, async closeAll() { const entries = [...runtimes.entries()]; const pending = [...closing.entries()]; const failures: unknown[] = []; await Promise.all(entries.map(async ([id, runtime]) => { try { await closeRuntime(id, runtime); } catch (error) { failures.push(error); } finally { sessions.delete(id); } })); await Promise.all(pending.map(async ([, promise]) => { try { await promise; } catch (error) { failures.push(error); } })); for (const [id, failure] of cleanupFailures) { failures.push(failure); cleanupFailures.delete(id); } if (failures.length) throw new AggregateError(failures, "fast-browser runtime cleanup failed"); }, }; } async function closeAllBrowserResources(manager: BrowserSessionManager, registry: RuntimeRegistry): Promise { const failures: unknown[] = []; try { await registry.closeAll(); } catch (error) { failures.push(error); } try { await manager.closeAll(); } catch (error) { failures.push(error); } if (failures.length) throw new AggregateError(failures, "fast-browser shutdown cleanup failed"); } function runtimeContext( runtime: BrowserRuntime, config: FastBrowserConfig, settings: FastBrowserSettings | undefined, signal: AbortSignal, ): Promise { if (settings?.allowedOrigins === undefined) return Promise.resolve(undefined); const requested = canonicalPolicy(settings.allowedOrigins); const base = canonicalPolicy(config.allowedOrigins); if (base !== "*" && (requested === "*" || requested.some((origin) => !base.includes(origin)))) { return Promise.reject(new Error("sessionId allowedOrigins cannot be broadened")); } return runtime.narrowOriginPolicy({ allowedOrigins: requested }, signal); } function publicTab(tab: any): any { const publicValue: any = { tabId: bounded(String(tab.tabId), 200), kind: tab.kind, selected: Boolean(tab.selected), owned: true, origin: bounded(String(tab.origin), 2_048), title: bounded(String(tab.title), MAX_RETURN_TEXT), }; if (tab.openerTabId) publicValue.openerTabId = bounded(String(tab.openerTabId), 200); return publicValue; } function publicRead(read: any): any { return { tab: publicTab(read.tab), documentId: bounded(String(read.documentId), 200), url: bounded(new URL(read.url).origin, 2_048), title: bounded(String(read.title), MAX_RETURN_TEXT), visibleText: bounded(String(read.visibleText), 50 * 1024), visibleTextTruncated: Boolean(read.visibleTextTruncated), }; } function publicEvidence(evidence: any): any { const metadata = (value: any) => value ? { artifactId: bounded(String(value.artifactId), 200), kind: value.kind, contentType: value.contentType, bytes: Number.isFinite(value.bytes) ? Math.max(0, value.bytes) : 0, owner: value.owner, permissions: "0600", localOnly: true, modelForwarded: false, } : undefined; return { status: evidence.status, ...(metadata(evidence.screenshot) ? { screenshot: metadata(evidence.screenshot) } : {}), ...(metadata(evidence.diagnostics) ? { diagnostics: metadata(evidence.diagnostics) } : {}), console: Array.isArray(evidence.console) ? evidence.console.slice(0, 64) : [], failedRequests: Array.isArray(evidence.failedRequests) ? evidence.failedRequests.slice(0, 64) : [], }; } function registerOpenTool(pi: ExtensionAPI, manager: BrowserSessionManager, registry: RuntimeRegistry): void { pi.registerTool({ name: "fast_browser_open", label: "Fast Browser Open", description: "Open a caller-owned browser session lease and return an opaque sessionId. Launch sessions create isolated persistent state; attach sessions lease an external endpoint. No user dialog is shown.", promptSnippet: "Open an isolated caller-owned browser session", parameters: openParameters, executionMode: "parallel", async execute( _toolCallId: string, params: Static, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ) { const session = await manager.open(await loadConfig(ctx, params.settings), abortSignal(signal)); try { if (registry.enabled) registry.create(session); } catch (error) { await manager.close(session.id).catch(() => {}); throw error; } const details = publicSession(session); return toolResult(jsonText(details), details); }, }); } function registerCloseTool(pi: ExtensionAPI, manager: BrowserSessionManager, registry: RuntimeRegistry): void { pi.registerTool({ name: "fast_browser_close", label: "Fast Browser Close", description: "Close a caller-owned fast-browser session lease by opaque sessionId. This releases attached leases and stops launched browsers; externally attached browser processes are never closed.", promptSnippet: "Close an owned isolated browser session", parameters: closeParameters, executionMode: "parallel", async execute( _toolCallId: string, params: Static, ) { // close() releases the manager lease and aborts its signal. Do not call // manager.get(): setup sessions are intentionally hidden from automation // lookup but remain explicitly closable, and crashed sessions may already // have disappeared from the manager record. const runtimeWasKnown = registry.has(params.sessionId); let managerFailure: unknown; try { await manager.close(params.sessionId); } catch (error) { managerFailure = error; } let runtimeFailure: unknown; try { await registry.close(params.sessionId); } catch (error) { runtimeFailure = error; } const alreadyExited = managerFailure && runtimeWasKnown && isAlreadyClosedSessionError(managerFailure); if (runtimeFailure) throw runtimeFailure; if (managerFailure && !alreadyExited) throw managerFailure; const details = { sessionId: bounded(params.sessionId, 200), closed: true }; return toolResult(jsonText(details), details); }, }); } function registerObserveTool( pi: ExtensionAPI, manager: BrowserSessionManager, adapters: FastBrowserAdapters, lane: SessionLane, registry: RuntimeRegistry, ): void { const bridgeFactory = adapters.createBrowserBridge ?? createBrowserBridge; pi.registerTool({ name: "fast_browser_observe", label: "Fast Browser Observe", description: "Observe actionable metadata from the caller-authorized local browser and its allowed-origin policy. Pass sessionId to reuse a caller-owned session. No user dialog is shown; this tool works in print and JSON modes.", promptSnippet: "Inspect the caller-authorized local browser for bounded click/fill actions", parameters: observeParameters, executionMode: "parallel", async execute( _toolCallId: string, params: FastBrowserObserveInput, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ) { const effectiveSignal = abortSignal(signal); let lease: SessionLease | undefined; try { lease = await leaseSession(manager, ctx, params.sessionId, params.settings, effectiveSignal); return await lane(lease.session.id, async () => { if (registry.enabled) { const runtime = registry.get(lease!.session); const context = await runtimeContext(runtime, lease!.config, params.settings, lease!.signal); const observation = await runtime.observe(lease!.signal, context); const details = publicObservation(observation); return toolResult(jsonText(details), details); } let bridge: BrowserBridge | undefined; try { bridge = bridgeFactory(bridgeOptions(lease!.config), lease!.signal); const observation = await bridge.observe(lease!.signal); const details = publicObservation(observation); return toolResult(jsonText(details), details); } finally { if (bridge) await bridge.close(); } }); } catch (error) { throw new Error(`fast_browser_observe failed: ${errorMessage(error)}`); } finally { if (lease?.ephemeral) { await registry.close(lease.session.id); await manager.close(lease.session.id); } } }, }); } function registerRunTool( pi: ExtensionAPI, manager: BrowserSessionManager, adapters: FastBrowserAdapters, lane: SessionLane, registry: RuntimeRegistry, ): void { const bridgeFactory = adapters.createBrowserBridge ?? createBrowserBridge; const deciderFactory = adapters.createJevDecider ?? createJevDecider; const runStepsAdapter = adapters.runSteps ?? runSteps; pi.registerTool({ name: "fast_browser_run", label: "Fast Browser Run", description: `Run at most ${MAX_STEPS} caller-authorized local-browser click/fill steps under the supplied allowed-origin policy. Pass sessionId to reuse a caller-owned session. No user dialog or per-action approval is used; the bounded task works in print and JSON modes.`, promptSnippet: "Execute the caller-authorized bounded local-browser click/fill task", parameters: runParameters, executionMode: "parallel", async execute( _toolCallId: string, params: FastBrowserRunInput, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ) { const effectiveSignal = abortSignal(signal); let lease: SessionLease | undefined; const secrets = new Set(); try { const steps = asSteps(params.steps); const budgets = params.budgets; const maxSteps = Math.min(budgets?.maxSteps ?? params.maxSteps ?? MAX_STEPS, MAX_STEPS); validateSteps(steps, maxSteps); lease = await leaseSession(manager, ctx, params.sessionId, params.settings, effectiveSignal); return await lane(lease.session.id, async () => { let bridge: BrowserBridge | undefined; try { const config = lease!.config; let runtime: BrowserRuntime | undefined; let context: RuntimeOperationContext | undefined; if (registry.enabled) { runtime = registry.get(lease!.session); context = await runtimeContext(runtime, config, params.settings, lease!.signal); } const generateText = makeTextGenerator(ctx, config); if (steps.some((step) => step.compose !== undefined) && !generateText) { throw new Error("FAST_BROWSER_TEXT_MODEL must name an available authenticated model when compose is used"); } const { bindings, configuredTypesafeKey } = credentialBindings(params, config); const resolveCredential = createCredentialResolver(bindings, { cwd: ctx.cwd || process.cwd(), env: process.env, }); let typesafeKey: string | undefined; const resolveTypesafeKey = async (keySignal: AbortSignal): Promise => { if (typesafeKey !== undefined) return typesafeKey; const value = configuredTypesafeKey ? await resolveCredential(TYPESAFE_BINDING, keySignal) : process.env.TYPESAFE_API_KEY?.trim(); if (!value) throw new DecisionError("TYPESAFE_API_KEY must be set when a semantic browser decision is needed"); typesafeKey = value; secrets.add(value); return value; }; if (!runtime) bridge = bridgeFactory(bridgeOptions(config), lease!.signal); const browser: BrowserBridge = runtime ? { observe: (browserSignal) => runtime!.observe(browserSignal, context), act: (request, browserSignal) => runtime!.act(request, browserSignal, context), close: async () => {}, } : bridge!; let decider: ReturnType | undefined; const decide: ControllerDeps["decide"] = async (input, decideSignal) => { if (!decider) decider = deciderFactory({ apiKey: await resolveTypesafeKey(decideSignal) }); return decider(input, decideSignal); }; const result = await runStepsAdapter(steps, { browser, decide, ...(generateText ? { generateText } : {}), resolveCredential, ...(runtime ? { captureEvidence: (request, evidenceSignal) => runtime!.captureEvidence(request, evidenceSignal, context) } : {}), }, { signal: lease!.signal, maxSteps, ...(budgets?.timeoutMs ?? params.timeoutMs) === undefined ? {} : { timeoutMs: budgets?.timeoutMs ?? params.timeoutMs }, ...(budgets?.minConfidence ?? params.minConfidence) === undefined ? {} : { minConfidence: budgets?.minConfidence ?? params.minConfidence }, runId: _toolCallId, }); const details = publicRunResult(result); return toolResult(jsonText(details), details); } finally { if (bridge) await bridge.close(); } }); } catch (error) { throw new Error(`fast_browser_run failed: ${errorMessage(error, secrets)}`); } finally { if (lease?.ephemeral) { await registry.close(lease.session.id); await manager.close(lease.session.id); } } }, }); } async function executeRuntimeOperation( manager: BrowserSessionManager, registry: RuntimeRegistry, lane: SessionLane, params: { sessionId: string; settings?: FastBrowserSettings }, signal: AbortSignal | undefined, ctx: ExtensionContext, operation: (runtime: BrowserRuntime, context: RuntimeOperationContext | undefined, lease: SessionLease) => Promise, ): Promise { const effectiveSignal = abortSignal(signal); let lease: SessionLease | undefined; try { lease = await leaseSession(manager, ctx, params.sessionId, params.settings, effectiveSignal); if (!registry.enabled) throw new Error("persistent browser runtime is unavailable in this embedding"); return await lane(lease.session.id, async () => { const runtime = registry.get(lease!.session); const context = await runtimeContext(runtime, lease!.config, params.settings, lease!.signal); return operation(runtime, context, lease!); }); } finally { if (lease?.ephemeral) { await registry.close(lease.session.id); await manager.close(lease.session.id); } } } function registerRuntimeTools( pi: ExtensionAPI, manager: BrowserSessionManager, registry: RuntimeRegistry, lane: SessionLane, ): void { const common = "Pass the explicit opaque sessionId returned by fast_browser_open. Operations are bounded, origin-policy checked, and work without Pi UI."; const register = (name: string, label: string, description: string, parameters: any, execute: (params: any, signal: AbortSignal | undefined, ctx: ExtensionContext) => Promise) => { pi.registerTool({ name, label, description: `${description} ${common}`, parameters, executionMode: "parallel", async execute(_toolCallId: string, params: any, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) { try { const details = await execute(params, signal, ctx); return toolResult(jsonText(details), details); } catch (error) { throw new Error(`${name} failed: ${errorMessage(error)}`); } }, }); }; register("fast_browser_read", "Fast Browser Read", "Read bounded rendered visible text, title, URL origin and document identity without form values, hidden text, HTML, or script output.", readParameters, (params, signal, ctx) => executeRuntimeOperation(manager, registry, lane, params, signal, ctx, async (runtime, context) => publicRead(await runtime.read({ ...(params.tabId === undefined ? {} : { tabId: params.tabId }) }, abortSignal(signal), context)))); register("fast_browser_assert", "Fast Browser Assert", "Assert URL, title, or bounded visible text using one read or the fixed bounded wait; assertions are read-only.", assertParameters, (params, signal, ctx) => executeRuntimeOperation(manager, registry, lane, params, signal, ctx, async (runtime, context) => { const result = await runtime.assert({ ...(params.tabId === undefined ? {} : { tabId: params.tabId }), assertion: params.assertion, wait: params.wait }, abortSignal(signal), context); return { status: result.status, readOnly: true, tab: publicTab(result.tab), documentId: bounded(String(result.documentId), 200) }; })); register("fast_browser_control", "Fast Browser Control", "Navigate, reload, list tabs, select a tab, wait for a popup, or close a tab using one typed discriminated control operation.", controlParameters, (params, signal, ctx) => executeRuntimeOperation(manager, registry, lane, params, signal, ctx, async (runtime, context) => { const operation = params.operation; const operationSignal = abortSignal(signal); switch (operation.op) { case "navigate": { const result = await runtime.navigate(operation.request, operationSignal, context); return { status: result.status, mutationState: result.mutationState, tab: publicTab(result.tab), documentId: bounded(String(result.documentId), 200) }; } case "reload": { const result = await runtime.reload(operation.request, operationSignal, context); return { status: result.status, mutationState: result.mutationState, tab: publicTab(result.tab), documentId: bounded(String(result.documentId), 200) }; } case "listTabs": return (await runtime.listTabs(operationSignal, context)).map(publicTab); case "selectTab": return publicTab((await runtime.selectTab(operation.request, operationSignal, context)).tab); case "waitPopup": { const result = await runtime.waitPopup(operation.request, operationSignal, context); return { status: result.status, autoSwitched: false, ...(result.tab ? { tab: publicTab(result.tab) } : {}) }; } case "closeTab": return runtime.closeTab(operation.request, operationSignal, context); } })); register("fast_browser_capture_evidence", "Fast Browser Capture Evidence", "Capture bounded local-only screenshot/diagnostic metadata, sanitized console summaries, and origin-only failed requests; bytes are never returned to the model.", evidenceParameters, (params, signal, ctx) => executeRuntimeOperation(manager, registry, lane, params, signal, ctx, async (runtime, context) => publicEvidence(await runtime.captureEvidence({ runId: params.runId, reason: params.reason, ...(params.operation === undefined ? {} : { operation: params.operation }) }, abortSignal(signal), context)))); } async function launchBrowserFromCommand( manager: BrowserSessionManager, ctx: ExtensionContext, purpose: "setup" | "automation", ): Promise<{ sessionId: string; profile?: string }> { // Command-launched sessions outlive this callback. Their parent signal is // intentionally independent of a single command invocation; close/closeAll // remains the explicit lifetime boundary. const signal = new AbortController().signal; const config = await loadConfig(ctx, undefined); // Setup is always visible and never exposes CDP. Normalize the saved // headless preference here as well as in the manager so this command remains // compatible with older managers while the setup contract is enforced there. const setupConfig = purpose === "setup" && config.browser ? { ...config, browser: { ...config.browser, headless: false } } : config; const session = purpose === "setup" ? await manager.openSetup(setupConfig, signal) : await manager.open(config, signal); return { sessionId: session.id, ...(session.config.browser?.profile ? { profile: session.config.browser.profile } : {}), }; } /** Register browser tools, session lifecycle, and the companion command. */ export function registerFastBrowser( pi: ExtensionAPI, adapters: FastBrowserAdapters = {}, ): void { const manager = (adapters.createSessionManager ?? createBrowserSessionManager)(); const registry = createRuntimeRegistry(adapters); const lane = createSessionLane(); registerOpenTool(pi, manager, registry); registerCloseTool(pi, manager, registry); registerObserveTool(pi, manager, adapters, lane, registry); registerRunTool(pi, manager, adapters, lane, registry); registerRuntimeTools(pi, manager, registry, lane); pi.on("session_shutdown", async () => { await closeAllBrowserResources(manager, registry); }); registerFastBrowserCommand(pi, { stopBrowser: async () => { await closeAllBrowserResources(manager, registry); }, launchBrowser: (ctx, purpose) => launchBrowserFromCommand(manager, ctx, purpose), }); } export default registerFastBrowser;