fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user