279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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/,
|
|
);
|
|
});
|