Files
harness-tools/extensions/fast-browser/index.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

1188 lines
49 KiB
TypeScript

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<typeof runParameters>;
export type FastBrowserObserveInput = Static<typeof observeParameters>;
export type FastBrowserSettings = Static<typeof settingsSchema>;
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> = []): string {
const message = error instanceof Error ? error.message : String(error);
const values = new Set<string>(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 = <T>(sessionId: string, operation: () => Promise<T>) => Promise<T>;
function createSessionLane(): SessionLane {
const tails = new Map<string, Promise<unknown>>();
return <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
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<FastBrowserConfig> {
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<FastBrowserConfig> {
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<SessionLease> {
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<string, BrowserRuntime>;
create(session: BrowserSession): BrowserRuntime;
get(session: BrowserSession): BrowserRuntime;
has(sessionId: string): boolean;
close(sessionId: string): Promise<void>;
closeAll(): Promise<void>;
}
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<string, BrowserRuntime>();
const abortHandlers = new Map<string, () => void>();
const closing = new Map<string, Promise<void>>();
const cleanupFailures = new Map<string, unknown>();
// Keep only the signal lookup needed to detach abort listeners.
const sessions = new Map<string, BrowserSession>();
const closeRuntime = (sessionId: string, runtime: BrowserRuntime): Promise<void> => {
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<void> {
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<RuntimeOperationContext | undefined> {
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<typeof openParameters>,
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<typeof closeParameters>,
) {
// 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<string>();
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<string> => {
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<CreateDecider> | 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<T>(
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<T>,
): Promise<T> {
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<unknown>) => {
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;