/** * Configuration for the bounded local-browser adapter. * * Configuration contains connection policy and references to credentials only; * it never reads or stores an API key. The adapter resolves a referenced key * only when a semantic decision is actually needed. */ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeSync, } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { OriginPolicy } from "./origin-policy.ts"; export type { OriginPolicy } from "./origin-policy.ts"; export const FAST_BROWSER_CDP_URL = "FAST_BROWSER_CDP_URL" as const; export const FAST_BROWSER_ALLOWED_ORIGINS = "FAST_BROWSER_ALLOWED_ORIGINS" as const; export const FAST_BROWSER_TEXT_MODEL = "FAST_BROWSER_TEXT_MODEL" as const; export const FAST_BROWSER_PYTHON = "FAST_BROWSER_PYTHON" as const; export const FAST_BROWSER_TEXT_REASONING = "FAST_BROWSER_TEXT_REASONING" as const; export const FAST_BROWSER_TYPESAFE_KEY_ENV = "FAST_BROWSER_TYPESAFE_KEY_ENV" as const; export const FAST_BROWSER_TYPESAFE_KEY_FILE = "FAST_BROWSER_TYPESAFE_KEY_FILE" as const; export const FAST_BROWSER_TYPESAFE_KEY_PATH = "FAST_BROWSER_TYPESAFE_KEY_PATH" as const; export const FAST_BROWSER_CONFIG_FILE = "fast-browser.json" as const; export const FAST_BROWSER_PROJECT_CONFIG_DIR = ".pi" as const; const MAX_CONFIG_STRING = 4_000; const MAX_ALLOWED_ORIGINS = 20; const MAX_CONFIG_BYTES = 64 * 1024; const MAX_ENV_NAME = 256; const DEFAULT_TEXT_REASONING = "off" as const; export type TextReasoning = "off" | "minimal" | "low" | "medium" | "high"; export type FastBrowserBrowserMode = "attach" | "launch"; export type FastBrowserBrowserProduct = "vivaldi" | "chrome" | "chromium"; export interface FastBrowserBrowserConfig { mode: FastBrowserBrowserMode; product?: FastBrowserBrowserProduct; executablePath?: string; headless?: boolean; startUrl?: string; /** Persistent managed profile name; omitted means a disposable profile. */ profile?: string; } export interface FastBrowserConfig { /** Explicit loopback HTTP CDP endpoint; required for attach mode. */ cdpUrl?: string; /** Any HTTP(S) origin, or an exact non-empty origin allowlist. */ allowedOrigins: OriginPolicy; /** Optional Python executable; the bridge supplies its safe default. */ python?: string; /** Optional Pi model in provider/model or unambiguous bare-id form. */ textModel?: string; /** Reasoning level for the optional text model. */ textReasoning?: TextReasoning; /** Reference to a credential source; never a literal credential. */ typesafeKey?: { env: string } | { file: string; key?: string }; /** Browser connection/launch policy. Defaults to attach. */ browser?: FastBrowserBrowserConfig; } export type ConfigOverrides = Partial; type ConfigKey = keyof FastBrowserConfig; type ConfigSourceName = "override" | "project" | "global" | "env" | "default"; export interface FastBrowserConfigSource { source: ConfigSourceName; path?: string; } export interface FastBrowserConfigResolution { config: FastBrowserConfig; sources: Partial>; projectPath: string; globalPath: string; } /** Raw merged settings used by the native editor before required fields exist. */ export interface FastBrowserConfigSettings { values: ConfigOverrides; sources: Partial>; projectPath: string; globalPath: string; } export class FastBrowserConfigError extends Error { override name = "FastBrowserConfigError"; } const CONFIG_KEYS: readonly ConfigKey[] = [ "cdpUrl", "allowedOrigins", "python", "textModel", "textReasoning", "typesafeKey", "browser", ]; function configError(message: string): FastBrowserConfigError { // Never include JSON parser messages or values: they can contain a secret // accidentally pasted into an invalid config file. return new FastBrowserConfigError(message); } function nonEmptyString(value: unknown, field: string, limit = MAX_CONFIG_STRING): string { if (typeof value !== "string" || value.length === 0 || value.length > limit || /[\0\r\n]/.test(value)) { throw configError(`${field} must be a bounded string`); } return value; } function optionalString(value: unknown, field: string, limit = MAX_CONFIG_STRING): string { const string = nonEmptyString(value, field, limit).trim(); if (!string) throw configError(`${field} must not be empty`); return string; } function requiredEnv(env: NodeJS.ProcessEnv, name: string): string { const value = env[name]?.trim(); if (!value) throw configError(`${name} must be set`); return value; } function isLoopbackHostname(hostname: string): boolean { const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; } /** Validate and preserve an explicitly configured CDP endpoint. */ function parseCdpUrl(value: unknown): string { const input = nonEmptyString(value, FAST_BROWSER_CDP_URL); let parsed: URL; try { parsed = new URL(input); } catch { throw configError(`${FAST_BROWSER_CDP_URL} must be a valid URL`); } if ( parsed.protocol !== "http:" || !isLoopbackHostname(parsed.hostname) || !parsed.port || !Number.isInteger(Number(parsed.port)) || Number(parsed.port) < 1 || Number(parsed.port) > 65_535 || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.username || parsed.password ) { throw configError(`${FAST_BROWSER_CDP_URL} must use an http loopback endpoint`); } return input; } function parseOrigin(value: unknown, field: string): string { if (typeof value !== "string" || value.length === 0 || value.length > MAX_CONFIG_STRING || value.trim() !== value) { throw configError(`${field} entries must be exact origin strings`); } let origin: URL; try { origin = new URL(value); } catch { throw configError(`${field} entries must be valid origins`); } if ( origin.protocol !== "http:" && origin.protocol !== "https:" ) { throw configError(`${field} entries must use http or https`); } if ( origin.username || origin.password || origin.hostname.includes("*") || origin.pathname !== "/" || origin.search || origin.hash ) { throw configError(`${field} entries must be exact origins without paths or credentials`); } // URL.origin canonicalizes default ports and host spelling. A trailing slash // is accepted for compatibility with the existing environment parser. const canonical = origin.origin; if (canonical !== value && `${canonical}/` !== value) { throw configError(`${field} entries must be exact origin strings`); } return canonical; } function parseAllowedOrigins(value: unknown, field: string = FAST_BROWSER_ALLOWED_ORIGINS): OriginPolicy { let parsed = value; if (parsed === "*") return "*"; if (typeof value === "string") { try { parsed = JSON.parse(value) as unknown; } catch { throw configError(`${field} must be '*' or a JSON array of exact origins`); } } if (parsed === "*") return "*"; if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > MAX_ALLOWED_ORIGINS) { throw configError( `${field} must be a non-empty array of at most ${MAX_ALLOWED_ORIGINS} exact origins`, ); } const origins: string[] = []; const seen = new Set(); for (const item of parsed) { const canonical = parseOrigin(item, field); if (seen.has(canonical)) throw configError(`${field} must not contain duplicate origins`); seen.add(canonical); origins.push(canonical); } return origins; } function parseTextReasoning(value: unknown, field: string = "textReasoning"): TextReasoning { if (value !== "off" && value !== "minimal" && value !== "low" && value !== "medium" && value !== "high") { throw configError(`${field} must be off, minimal, low, medium, or high`); } return value; } function parseEnvName(value: unknown, field: string): string { const name = optionalString(value, field, MAX_ENV_NAME); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { throw configError(`${field} must be an environment variable name`); } return name; } function parseTypesafeKey(value: unknown, field = "typesafeKey"): FastBrowserConfig["typesafeKey"] { if (!value || typeof value !== "object" || Array.isArray(value)) { throw configError(`${field} must be an env or file reference`); } const object = value as Record; const keys = Object.keys(object); if (keys.includes("env")) { if (keys.length !== 1) throw configError(`${field} env references cannot contain other fields`); return { env: parseEnvName(object.env, `${field}.env`) }; } if (keys.includes("file")) { if (keys.some((key) => key !== "file" && key !== "key")) { throw configError(`${field} file references contain an unknown field`); } const file = optionalString(object.file, `${field}.file`); const key = object.key === undefined ? undefined : optionalString(object.key, `${field}.key`); return key === undefined ? { file } : { file, key }; } throw configError(`${field} must contain exactly one env or file reference`); } function parseStartUrl(value: unknown, field: string): string { const input = nonEmptyString(value, field); let url: URL; try { url = new URL(input); } catch { throw configError(`${field} must be a valid http(s) URL`); } if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.hostname.includes("*")) { throw configError(`${field} must be an http(s) URL without credentials or wildcards`); } return input; } export function validateBrowserProfileName(value: unknown): string { if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) { throw configError("browser.profile must be a 1–64 character name starting with a letter or digit and containing only letters, digits, dots, underscores or hyphens"); } return value; } function parseBrowser(value: unknown, field = "browser"): FastBrowserBrowserConfig { if (!value || typeof value !== "object" || Array.isArray(value)) { throw configError(`${field} must be an object`); } const object = value as Record; const allowed = new Set(["mode", "product", "executablePath", "headless", "startUrl", "profile"]); if (Object.keys(object).some((key) => !allowed.has(key))) { throw configError(`${field} contains an unknown configuration key`); } if (object.mode !== "attach" && object.mode !== "launch") { throw configError(`${field}.mode must be attach or launch`); } let product: FastBrowserBrowserProduct | undefined; if (object.product !== undefined) { if (object.product === "firefox") { throw configError("Firefox is not supported; use Vivaldi, Chrome, or Chromium"); } if (object.product !== "vivaldi" && object.product !== "chrome" && object.product !== "chromium") { throw configError(`${field}.product must be vivaldi, chrome, or chromium`); } product = object.product; } const executablePath = object.executablePath === undefined ? undefined : optionalString(object.executablePath, `${field}.executablePath`); if (object.headless !== undefined && typeof object.headless !== "boolean") { throw configError(`${field}.headless must be a boolean`); } const startUrl = object.startUrl === undefined ? undefined : parseStartUrl(object.startUrl, `${field}.startUrl`); const profile = object.profile === undefined ? undefined : validateBrowserProfileName(object.profile); if (object.mode === "attach" && (executablePath !== undefined || object.headless !== undefined || startUrl !== undefined || profile !== undefined)) { throw configError(`${field} launch settings require mode launch; attach uses cdpUrl`); } return { mode: object.mode, ...(product === undefined ? {} : { product }), ...(executablePath === undefined ? {} : { executablePath }), ...(object.headless === undefined ? {} : { headless: object.headless }), ...(startUrl === undefined ? {} : { startUrl }), ...(profile === undefined ? {} : { profile }), }; } function parseField(key: ConfigKey, value: unknown, field: string = key): FastBrowserConfig[ConfigKey] { switch (key) { case "cdpUrl": return parseCdpUrl(value); case "allowedOrigins": return parseAllowedOrigins(value, field); case "python": return optionalString(value, field); case "textModel": return optionalString(value, field); case "textReasoning": return parseTextReasoning(value, field); case "typesafeKey": return parseTypesafeKey(value, field); case "browser": return parseBrowser(value, field); } } function hasOwn(value: object, key: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(value, key); } function assertKnownKeys(value: Record, source: string): void { for (const key of Object.keys(value)) { if (!CONFIG_KEYS.includes(key as ConfigKey)) { throw configError(`${source} contains an unknown configuration key`); } } } function parsePartialObject(value: unknown, source: string): ConfigOverrides { if (!value || typeof value !== "object" || Array.isArray(value)) { throw configError(`${source} must be a JSON object`); } const object = value as Record; assertKnownKeys(object, source); const parsed: ConfigOverrides = {}; for (const key of CONFIG_KEYS) { if (hasOwn(object, key)) { (parsed as Record)[key] = parseField(key, object[key], `${source}.${key}`); } } return parsed; } function parseEnvironment(env: NodeJS.ProcessEnv): ConfigOverrides { const values: Record = {}; if (env[FAST_BROWSER_CDP_URL]?.trim()) values.cdpUrl = env[FAST_BROWSER_CDP_URL]!.trim(); if (env[FAST_BROWSER_ALLOWED_ORIGINS]?.trim()) values.allowedOrigins = env[FAST_BROWSER_ALLOWED_ORIGINS]!.trim(); if (env[FAST_BROWSER_PYTHON]?.trim()) values.python = env[FAST_BROWSER_PYTHON]!.trim(); if (env[FAST_BROWSER_TEXT_MODEL]?.trim()) values.textModel = env[FAST_BROWSER_TEXT_MODEL]!.trim(); if (env[FAST_BROWSER_TEXT_REASONING]?.trim()) values.textReasoning = env[FAST_BROWSER_TEXT_REASONING]!.trim(); const keyEnv = env[FAST_BROWSER_TYPESAFE_KEY_ENV]?.trim(); const keyFile = env[FAST_BROWSER_TYPESAFE_KEY_FILE]?.trim(); const keyPath = env[FAST_BROWSER_TYPESAFE_KEY_PATH]?.trim(); if (keyEnv && keyFile) throw configError("typesafe key environment references conflict"); if (keyEnv) values.typesafeKey = { env: keyEnv }; else if (keyFile) values.typesafeKey = { file: keyFile, ...(keyPath ? { key: keyPath } : {}) }; return parsePartialObject(values, "environment"); } /** * Parse the environment without side effects. This remains strict and sync for * callers that used the original env-only API. */ export function parseFastBrowserConfig(env: NodeJS.ProcessEnv = process.env): FastBrowserConfig { const textModel = env[FAST_BROWSER_TEXT_MODEL]?.trim(); const python = env[FAST_BROWSER_PYTHON]?.trim(); const parsed: FastBrowserConfig = { cdpUrl: parseCdpUrl(requiredEnv(env, FAST_BROWSER_CDP_URL)), allowedOrigins: env[FAST_BROWSER_ALLOWED_ORIGINS]?.trim() ? parseAllowedOrigins(env[FAST_BROWSER_ALLOWED_ORIGINS]!.trim()) : "*", }; if (textModel) parsed.textModel = optionalString(textModel, FAST_BROWSER_TEXT_MODEL); if (python) parsed.python = optionalString(python, FAST_BROWSER_PYTHON); if (env[FAST_BROWSER_TEXT_REASONING]?.trim()) { parsed.textReasoning = parseTextReasoning(env[FAST_BROWSER_TEXT_REASONING]!.trim(), FAST_BROWSER_TEXT_REASONING); } return parsed; } function agentDirectory(env: NodeJS.ProcessEnv, explicit?: string): string { const value = explicit ?? env.PI_CODING_AGENT_DIR?.trim(); if (value) return value; return join(env.HOME?.trim() || homedir(), ".pi", "agent"); } function sourcePath(cwd: string, agentDir: string): { projectPath: string; globalPath: string } { return { projectPath: join(cwd, FAST_BROWSER_PROJECT_CONFIG_DIR, FAST_BROWSER_CONFIG_FILE), globalPath: join(agentDir, FAST_BROWSER_CONFIG_FILE), }; } function readConfigFile(path: string, label: string): ConfigOverrides { if (!existsSync(path)) return {}; let fd: number | undefined; try { // Open nonblocking and inspect the descriptor before reading. This rejects // FIFOs/devices without ever waiting on them, while the bounded read also // handles a regular file growing after the initial stat. fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); const info = fstatSync(fd); if (!info.isFile() || info.size > MAX_CONFIG_BYTES) { throw configError(`${label} fast-browser configuration must be a bounded regular file`); } const bytes = Buffer.alloc(MAX_CONFIG_BYTES + 1); let used = 0; while (used <= MAX_CONFIG_BYTES) { const bytesRead = readSync(fd, bytes, used, bytes.length - used, null); if (bytesRead === 0) break; used += bytesRead; if (used > MAX_CONFIG_BYTES) { throw configError(`${label} fast-browser configuration is too large`); } } let value: unknown; try { const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used)); value = JSON.parse(text) as unknown; } catch (error) { if (error instanceof FastBrowserConfigError) throw error; throw configError(`invalid ${label} fast-browser configuration JSON`); } return parsePartialObject(value, label); } catch (error) { if (error instanceof FastBrowserConfigError) throw error; throw configError(`unable to read ${label} fast-browser configuration`); } finally { if (fd !== undefined) { try { closeSync(fd); } catch { /* best effort close */ } } } } /** Read one optional scope file for the native settings editor. */ export function readFastBrowserConfigFile(path: string, label = "fast-browser"): ConfigOverrides { return readConfigFile(path, label); } function mergeSource( target: Partial, sources: Partial>, values: ConfigOverrides, source: ConfigSourceName, path?: string, ): void { for (const key of CONFIG_KEYS) { if (hasOwn(values, key) && values[key] !== undefined) { (target as Record)[key] = Array.isArray(values[key]) ? [...values[key] as string[]] : values[key]; sources[key] = path ? { source, path } : { source }; } } } function assertComplete( fields: Partial, sources: Partial>, ): FastBrowserConfig { const allowedOrigins = fields.allowedOrigins ?? "*"; if (!sources.allowedOrigins) sources.allowedOrigins = { source: "default" }; const browser = fields.browser; const mode = browser?.mode ?? "attach"; if (mode === "attach" && fields.cdpUrl === undefined) { throw configError(`${FAST_BROWSER_CDP_URL} must be set for attach mode`); } if (mode === "launch") { if (!browser?.product) throw configError("browser.product must be set for launch mode"); if (!browser.startUrl) throw configError("browser.startUrl must be set for launch mode"); let start: URL; try { start = new URL(browser.startUrl); } catch { throw configError("browser.startUrl must be a valid URL"); } if (allowedOrigins !== "*" && !allowedOrigins.includes(start.origin)) { throw configError("browser.startUrl must use an origin in allowedOrigins"); } } const config: FastBrowserConfig = { allowedOrigins: allowedOrigins === "*" ? "*" : [...allowedOrigins], textReasoning: fields.textReasoning ?? DEFAULT_TEXT_REASONING, }; if (fields.cdpUrl !== undefined) config.cdpUrl = fields.cdpUrl; if (fields.python !== undefined) config.python = fields.python; if (fields.textModel !== undefined) config.textModel = fields.textModel; if (fields.typesafeKey !== undefined) config.typesafeKey = fields.typesafeKey; if (browser !== undefined) config.browser = { ...browser }; if (!sources.textReasoning) sources.textReasoning = { source: "default" }; return config; } /** Read all scopes and merge them without contacting a provider or reading keys. */ function collectFastBrowserConfig( cwd: string, overrides: ConfigOverrides, options: { agentDir?: string; env?: NodeJS.ProcessEnv }, ): FastBrowserConfigSettings { const env = options.env ?? process.env; const paths = sourcePath(cwd, agentDirectory(env, options.agentDir)); const overrideValues = parsePartialObject(overrides, "per-run overrides"); const projectValues = readConfigFile(paths.projectPath, "project"); const globalValues = readConfigFile(paths.globalPath, "global"); const envValues = parseEnvironment(env); const fields: Partial = {}; const sources: Partial> = {}; // Merge low to high precedence. Defaults are applied by assertComplete. mergeSource(fields, sources, envValues, "env"); mergeSource(fields, sources, globalValues, "global", paths.globalPath); mergeSource(fields, sources, projectValues, "project", paths.projectPath); mergeSource(fields, sources, overrideValues, "override"); if (!sources.textReasoning) sources.textReasoning = { source: "default" }; const values: ConfigOverrides = {}; for (const key of CONFIG_KEYS) { if (fields[key] !== undefined) (values as Record)[key] = fields[key]; } if (values.textReasoning === undefined) values.textReasoning = DEFAULT_TEXT_REASONING; if (values.allowedOrigins === undefined) { values.allowedOrigins = "*"; sources.allowedOrigins = { source: "default" }; } return { values, sources, projectPath: paths.projectPath, globalPath: paths.globalPath }; } /** Inspect merged settings without requiring endpoint/origin fields. */ export function inspectFastBrowserConfigSettings( cwd = process.cwd(), overrides: ConfigOverrides = {}, options: { agentDir?: string; env?: NodeJS.ProcessEnv } = {}, ): FastBrowserConfigSettings { return collectFastBrowserConfig(cwd, overrides, options); } export function inspectFastBrowserConfig( cwd = process.cwd(), overrides: ConfigOverrides = {}, options: { agentDir?: string; env?: NodeJS.ProcessEnv } = {}, ): FastBrowserConfigResolution { const settings = collectFastBrowserConfig(cwd, overrides, options); return { config: assertComplete(settings.values, settings.sources), sources: settings.sources, projectPath: settings.projectPath, globalPath: settings.globalPath, }; } /** Resolve configuration for a tool invocation without contacting providers or reading credential values. */ export async function loadFastBrowserConfig( cwd = process.cwd(), overrides: ConfigOverrides = {}, options: { agentDir?: string; env?: NodeJS.ProcessEnv } = {}, ): Promise { return inspectFastBrowserConfig(cwd, overrides, options).config; } function serializedScopeValues(values: ConfigOverrides): string { const ordered: Record = {}; for (const key of CONFIG_KEYS) { if (hasOwn(values, key) && values[key] !== undefined) ordered[key] = values[key]; } return `${JSON.stringify(ordered, null, 2)}\n`; } /** Write a valid scope object atomically with restrictive permissions. */ export function writeFastBrowserConfigFile(path: string, values: ConfigOverrides): void { const parsed = parsePartialObject(values, "saved configuration"); const content = serializedScopeValues(parsed); const directory = dirname(path); mkdirSync(directory, { recursive: true, mode: 0o700 }); const temporary = join(directory, `.${FAST_BROWSER_CONFIG_FILE}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`); let fd: number | undefined; try { fd = openSync(temporary, "wx", 0o600); writeSync(fd, content, undefined, "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined; chmodSync(temporary, 0o600); // rename preserves the temporary file's mode on POSIX. The chmod before // rename keeps the replacement atomic even on platforms where open's mode // argument is advisory. renameSync(temporary, path); } catch (error) { if (fd !== undefined) { try { closeSync(fd); } catch { /* best effort cleanup */ } } try { unlinkSync(temporary); } catch { /* preserve the old file */ } throw error instanceof FastBrowserConfigError ? error : configError("unable to save fast-browser configuration"); } } export function removeFastBrowserConfigFile(path: string): void { if (!existsSync(path)) return; const temporary = join(dirname(path), `.${FAST_BROWSER_CONFIG_FILE}.${process.pid}.${Date.now()}.reset`); try { renameSync(path, temporary); unlinkSync(temporary); } catch { // Best effort restoration keeps a failed reset from losing settings. if (!existsSync(path) && existsSync(temporary)) { try { renameSync(temporary, path); } catch { /* nothing safe to do */ } } throw configError("unable to reset fast-browser project configuration"); } }