654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
/**
|
||
* 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<FastBrowserConfig>;
|
||
|
||
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<Record<ConfigKey, FastBrowserConfigSource>>;
|
||
projectPath: string;
|
||
globalPath: string;
|
||
}
|
||
|
||
/** Raw merged settings used by the native editor before required fields exist. */
|
||
export interface FastBrowserConfigSettings {
|
||
values: ConfigOverrides;
|
||
sources: Partial<Record<ConfigKey, FastBrowserConfigSource>>;
|
||
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<string>();
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>, 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<string, unknown>;
|
||
assertKnownKeys(object, source);
|
||
const parsed: ConfigOverrides = {};
|
||
for (const key of CONFIG_KEYS) {
|
||
if (hasOwn(object, key)) {
|
||
(parsed as Record<string, unknown>)[key] = parseField(key, object[key], `${source}.${key}`);
|
||
}
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseEnvironment(env: NodeJS.ProcessEnv): ConfigOverrides {
|
||
const values: Record<string, unknown> = {};
|
||
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<FastBrowserConfig>,
|
||
sources: Partial<Record<ConfigKey, FastBrowserConfigSource>>,
|
||
values: ConfigOverrides,
|
||
source: ConfigSourceName,
|
||
path?: string,
|
||
): void {
|
||
for (const key of CONFIG_KEYS) {
|
||
if (hasOwn(values, key) && values[key] !== undefined) {
|
||
(target as Record<string, unknown>)[key] = Array.isArray(values[key]) ? [...values[key] as string[]] : values[key];
|
||
sources[key] = path ? { source, path } : { source };
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertComplete(
|
||
fields: Partial<FastBrowserConfig>,
|
||
sources: Partial<Record<ConfigKey, FastBrowserConfigSource>>,
|
||
): 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<FastBrowserConfig> = {};
|
||
const sources: Partial<Record<ConfigKey, FastBrowserConfigSource>> = {};
|
||
// 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<string, unknown>)[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<FastBrowserConfig> {
|
||
return inspectFastBrowserConfig(cwd, overrides, options).config;
|
||
}
|
||
|
||
function serializedScopeValues(values: ConfigOverrides): string {
|
||
const ordered: Record<string, unknown> = {};
|
||
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");
|
||
}
|
||
}
|
||
|