1188 lines
49 KiB
TypeScript
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;
|