Files
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

654 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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");
}
}