fast-browser-with-jev
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s

This commit is contained in:
jb
2026-09-20 12:24:08 -04:00
parent fd0edb1511
commit fd63b0ccec
73 changed files with 21212 additions and 2 deletions
+653
View File
@@ -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");
}
}