import assert from "node:assert/strict"; import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { FAST_BROWSER_ALLOWED_ORIGINS, FAST_BROWSER_CDP_URL, FAST_BROWSER_PYTHON, FAST_BROWSER_TEXT_MODEL, FastBrowserConfigError, inspectFastBrowserConfigSettings, loadFastBrowserConfig, parseFastBrowserConfig, writeFastBrowserConfigFile, validateBrowserProfileName, } from "./config.ts"; function env(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { [FAST_BROWSER_CDP_URL]: "http://127.0.0.1:9222", [FAST_BROWSER_ALLOWED_ORIGINS]: '["https://example.test"]', ...overrides, }; } test("parses explicit loopback endpoint and exact origin allowlist", () => { assert.deepEqual(parseFastBrowserConfig(env()), { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["https://example.test"], }); }); test("keeps optional model and Python settings separate from credentials", () => { const config = parseFastBrowserConfig( env({ [FAST_BROWSER_TEXT_MODEL]: "anthropic/claude-haiku-4-5", [FAST_BROWSER_PYTHON]: "/usr/bin/python3.12", TYPESAFE_API_KEY: "must-not-be-read-into-config", }), ); assert.equal(config.textModel, "anthropic/claude-haiku-4-5"); assert.equal(config.python, "/usr/bin/python3.12"); assert.equal("apiKey" in config, false); assert.equal(JSON.stringify(config).includes("must-not-be-read"), false); }); test("requires CDP endpoint but defaults missing origins to unrestricted HTTP(S)", () => { assert.throws( () => parseFastBrowserConfig(env({ [FAST_BROWSER_CDP_URL]: "" })), (error: unknown) => error instanceof FastBrowserConfigError && /CDP_URL/.test(error.message), ); assert.equal(parseFastBrowserConfig({ [FAST_BROWSER_CDP_URL]: "http://127.0.0.1:9222" }).allowedOrigins, "*"); }); test("rejects non-loopback, non-root, and credential-bearing CDP URLs", () => { for (const value of [ "https://127.0.0.1:9222", "http://192.0.2.1:9222", "http://user:pass@127.0.0.1:9222", "http://127.0.0.1:9222/json/version", "http://127.0.0.1:9222?query=1", ]) { assert.throws(() => parseFastBrowserConfig(env({ [FAST_BROWSER_CDP_URL]: value })), FastBrowserConfigError); } }); test("supports explicit unrestricted policy and rejects malformed wildcard policies", () => { assert.equal(parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: "*" })).allowedOrigins, "*"); assert.equal(parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: '"*"' })).allowedOrigins, "*"); for (const value of ["[]", "not-json", '"https://example.test"', '["*"]', '["https://*.example.test"]']) { assert.throws(() => parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: value })), FastBrowserConfigError); } }); test("allows remote exact origins but rejects malformed, wildcard, and path-bearing entries", () => { assert.deepEqual( parseFastBrowserConfig( env({ [FAST_BROWSER_ALLOWED_ORIGINS]: '["https://example.test", "https://remote.example.org:8443"]', }), ).allowedOrigins, ["https://example.test", "https://remote.example.org:8443"], ); for (const value of [ "not-json", '["*"]', '["https://*.example.test"]', '["https://example.test/path"]', '["file:///tmp/fixture"]', '["https://example.test", "https://example.test"]', ]) { assert.throws( () => parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: value })), FastBrowserConfigError, ); } const tooMany = JSON.stringify( Array.from({ length: 21 }, (_, index) => `https://host-${index}.example.test`), ); assert.throws( () => parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: tooMany })), FastBrowserConfigError, ); const tooLong = JSON.stringify([`https://${"a".repeat(4_001)}.example`]); assert.throws( () => parseFastBrowserConfig(env({ [FAST_BROWSER_ALLOWED_ORIGINS]: tooLong })), FastBrowserConfigError, ); }); test("merges env, global, project, and per-run settings in documented precedence", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-config-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); await mkdir(join(cwd, ".pi"), { recursive: true }); await writeFile(join(agentDir, "fast-browser.json"), JSON.stringify({ cdpUrl: "http://127.0.0.1:9333", allowedOrigins: ["https://global.example"], python: "/global/python", textModel: "global/model", })); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ allowedOrigins: ["https://project.example"], textModel: "project/model", })); const config = await loadFastBrowserConfig(cwd, { textModel: "override/model" }, { agentDir, env: env({ python: "/env/python" }), }); assert.deepEqual(config, { cdpUrl: "http://127.0.0.1:9333", allowedOrigins: ["https://project.example"], python: "/global/python", textModel: "override/model", textReasoning: "off", }); const info = inspectFastBrowserConfigSettings(cwd, {}, { agentDir, env: env() }); assert.equal(info.sources.cdpUrl?.source, "global"); assert.equal(info.sources.allowedOrigins?.source, "project"); assert.equal(info.sources.textReasoning?.source, "default"); }); test("allows an inherited attach endpoint to coexist with launch mode", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-launch-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); await mkdir(join(cwd, ".pi"), { recursive: true }); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ browser: { mode: "launch", product: "vivaldi", startUrl: "https://example.test/login", headless: true, }, })); const config = await loadFastBrowserConfig(cwd, {}, { agentDir, env: env(), }); assert.equal(config.browser?.mode, "launch"); assert.equal(config.browser?.product, "vivaldi"); assert.equal(config.cdpUrl, "http://127.0.0.1:9222"); }); test("rejects unsupported Firefox and launch URLs outside allowed origins", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-invalid-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); await mkdir(join(cwd, ".pi"), { recursive: true }); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ browser: { mode: "launch", product: "firefox", startUrl: "https://example.test" }, })); await assert.rejects( loadFastBrowserConfig(cwd, {}, { agentDir, env: env() }), /Firefox is not supported/, ); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ browser: { mode: "launch", product: "chrome", startUrl: "https://outside.test" }, })); await assert.rejects( loadFastBrowserConfig(cwd, {}, { agentDir, env: env() }), /startUrl must use an origin/, ); }); test("invalid JSON is sanitized and atomic writes keep restrictive permissions", async () => { const directory = await mkdtemp(join(tmpdir(), "fast-browser-write-")); const path = join(directory, "fast-browser.json"); await writeFile(path, '{"textModel":"secret-model",'); await assert.rejects( loadFastBrowserConfig(directory, {}, { agentDir: directory, env: { HOME: directory } }), (error: unknown) => error instanceof FastBrowserConfigError && !error.message.includes("secret-model"), ); writeFastBrowserConfigFile(path, { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["https://example.test"], typesafeKey: { env: "TYPESAFE_API_KEY" }, }); const content = await readFile(path, "utf8"); assert.equal(content.includes("TYPESAFE_API_KEY"), true); assert.equal(content.includes("secret-model"), false); assert.equal((await stat(path)).mode & 0o777, 0o600); }); test("launch mode may omit cdpUrl when startUrl is allowed", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-launch-only-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); await mkdir(join(cwd, ".pi"), { recursive: true }); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ allowedOrigins: ["https://example.test"], browser: { mode: "launch", product: "chromium", startUrl: "https://example.test/start" }, })); const config = await loadFastBrowserConfig(cwd, {}, { agentDir, env: {} }); assert.equal(config.cdpUrl, undefined); assert.equal(config.browser?.mode, "launch"); }); test("omitted layered origins default to unrestricted policy", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-default-origins-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); await mkdir(join(cwd, ".pi"), { recursive: true }); await writeFile(join(cwd, ".pi", "fast-browser.json"), JSON.stringify({ browser: { mode: "launch", product: "chromium", startUrl: "https://any.example/start" }, })); const config = await loadFastBrowserConfig(cwd, {}, { agentDir, env: {} }); assert.equal(config.allowedOrigins, "*"); const settings = inspectFastBrowserConfigSettings(cwd, {}, { agentDir, env: {} }); assert.equal(settings.values.allowedOrigins, "*"); assert.deepEqual(settings.sources.allowedOrigins, { source: "default" }); }); test("named profiles use bounded basenames, never arbitrary profile paths", () => { for (const name of ["evebot", "project-agent-2", "Login_Profile.v1", "x".repeat(64)]) { assert.equal(validateBrowserProfileName(name), name); } for (const name of ["", ".", "..", "../evebot", "a/b", "a\\b", "/tmp/profile", "~/.config/chromium", " name", "name\n", "x".repeat(65), null, 1]) { assert.throws(() => validateBrowserProfileName(name), /browser.profile/); } }); test("named profile config roundtrips and an explicit disposable override does not inherit it", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-named-config-")); t.after(() => rm(cwd, { recursive: true, force: true })); const agentDir = join(cwd, "agent"); const path = join(cwd, ".pi", "fast-browser.json"); writeFastBrowserConfigFile(path, { allowedOrigins: ["https://example.test"], browser: { mode: "launch", product: "chromium", profile: "evebot", startUrl: "https://example.test" }, }); const saved = await loadFastBrowserConfig(cwd, {}, { agentDir, env: {} }); assert.equal(saved.browser?.profile, "evebot"); const disposable = await loadFastBrowserConfig(cwd, { browser: { mode: "launch", product: "chromium", startUrl: "https://example.test" }, }, { agentDir, env: {} }); assert.equal(disposable.browser?.profile, undefined); await assert.rejects(loadFastBrowserConfig(cwd, { cdpUrl: "http://127.0.0.1:9222", browser: { mode: "attach", profile: "evebot" }, }, { agentDir, env: {} }), /launch settings require mode launch/); assert.equal(JSON.parse(await readFile(path, "utf8")).browser.profile, "evebot"); }); test("rejects nonregular and oversized configuration files before reading them", async () => { const cwd = await mkdtemp(join(tmpdir(), "fast-browser-file-")); const agentDir = await mkdtemp(join(tmpdir(), "fast-browser-agent-")); const path = join(cwd, ".pi", "fast-browser.json"); await mkdir(path, { recursive: true }); await assert.rejects( loadFastBrowserConfig(cwd, {}, { agentDir, env: env() }), /bounded regular file/, ); await rm(path, { recursive: true }); await writeFile(path, "x".repeat(64 * 1024 + 1)); await assert.rejects( loadFastBrowserConfig(cwd, {}, { agentDir, env: env() }), /too large|bounded regular file/, ); });