server/browser-engine.test.ts
Rama main · réplica de lectura del repositorio original.
import { createHash, randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { credentialStoreFlags } from "./browser/egress/gate.ts";
import { browserRoot } from "./browser/chrome/profile.ts";
import { strays } from "./browser/chrome/processes.ts";
import {
agentBrowserDevToolsPorts,
agentBrowserIntegration,
browserProfileDirectory,
browserProfilesRoot,
browserEngineEncryptionKey,
cachedAgentBrowserVersion,
pendingAgentBrowserVersionProbe,
browserEngineStatus,
describeBrowserEngine,
browserDaemonFile,
browserRestoreKey,
browserSessionId,
clearBrowserSessionState,
closeBrowserSession,
ensureChrome,
inRunningBrowser,
installAgentBrowserBinary,
isMusl,
isTemporaryBrowserSession,
pinnedBinaryPath,
prepareBrowserSessionState,
resolveAgentBrowserBinary,
setBrowserViewport,
} from "./browser-engine.ts";
import { AGENT_BROWSER_VERSION, agentBrowserReleaseUrl, agentBrowserReleaseVersion, resolveAgentBrowserReleaseAsset } from "./browser-engine-release.ts";
import { browserBundlePaths, browserBundleSpec, SUPPORTED_BROWSER_TARGETS } from "./browser-bundle-release.ts";
import { spawnBotWork } from "./oom-score.ts";
import { removeTempDir } from "./testing/cleanup.ts";
const posix = process.platform !== "win32";
const scratch: string[] = [];
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: vi.fn(actual.spawn) };
});
vi.mock("./oom-score.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./oom-score.ts")>();
return { ...actual, spawnBotWork: vi.fn(actual.spawnBotWork) };
});
afterEach(async () => {
vi.unstubAllEnvs();
vi.mocked(spawn).mockReset();
for (const dir of scratch.splice(0)) await removeTempDir(dir);
});
function lifecycleChild(args: readonly string[] = [], options: { code?: number; onClose?: () => void; sessions?: string[]; inventory?: string } = {}): ReturnType<typeof spawn> {
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), kill: () => true });
queueMicrotask(() => {
if (args[0] === "session") child.stdout.emit("data", options.inventory ?? JSON.stringify({ success: true, data: { sessions: options.sessions ?? [] } }));
else options.onClose?.();
child.emit("exit", args[0] === "session" ? 0 : options.code ?? 0);
child.emit("close", args[0] === "session" ? 0 : options.code ?? 0);
});
return child as ReturnType<typeof spawn>;
}
/** The engine over a set of running daemons: `session list` lists them, and
* a `close` that succeeds (code 0) stops the session it was sent for. */
function runningDaemons(running: Set<string>, options: { code?: number; onClose?: () => void } = {}) {
return ((_binary: string, args: readonly string[], spawnOptions?: { env?: NodeJS.ProcessEnv }) => lifecycleChild(args, {
...options, sessions: [...running],
onClose: () => { options.onClose?.(); if (!options.code) running.delete(spawnOptions?.env?.AGENT_BROWSER_SESSION ?? ""); },
})) as unknown as typeof spawn;
}
describe("sizing the page of a launched browser", () => {
function viewportChild(output: string, code = 0): ReturnType<typeof spawn> {
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), kill: () => true });
queueMicrotask(() => { child.stdout.emit("data", output); child.emit("close", code); });
return child as ReturnType<typeof spawn>;
}
const env = { AGENT_BROWSER_SESSION: "bot-a", AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_RESTORE: "omb-key", PATH: "/bin" };
it("sets the standard viewport through the session's own launch environment", async () => {
vi.mocked(spawn).mockImplementation(() => viewportChild(JSON.stringify({ success: true, data: { width: 1280, height: 720 } })));
vi.mocked(spawnBotWork).mockClear();
expect(await setBrowserViewport("/engine", env)).toBe(true);
// A call can start the session's daemon and its Chrome: they are the bot's, first in line when memory runs out.
expect(spawnBotWork).toHaveBeenCalledTimes(1);
expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe("/engine");
expect(vi.mocked(spawn).mock.calls[0]?.[1]).toEqual(["set", "viewport", "1280", "720", "--json", "--no-webmcp"]);
expect(vi.mocked(spawn).mock.calls[0]?.[2]?.env).toMatchObject(env);
});
it.runIf(process.platform === "linux")("starts the engine, and the daemon and Chrome it starts, first in line when memory runs out", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
vi.mocked(spawn).mockImplementation(actual.spawn);
const dir = mkdtempSync(join(tmpdir(), "omb-engine-score-"));
scratch.push(dir);
const engine = join(dir, "agent-browser");
writeFileSync(engine, `#!/bin/sh\n[ "$(cat /proc/self/oom_score_adj)" = 1000 ] && echo '{"success":true}' || echo '{"success":false}'\n`, { mode: 0o755 });
expect(await setBrowserViewport(engine, env)).toBe(true);
});
it("reports failure instead of throwing", async () => {
vi.mocked(spawn).mockImplementation(() => viewportChild(JSON.stringify({ success: false, error: "no page" })));
expect(await setBrowserViewport("/engine", env)).toBe(false);
vi.mocked(spawn).mockImplementation(() => viewportChild("", 1));
expect(await setBrowserViewport("/engine", env)).toBe(false);
});
it("leaves an attached or headed browser alone", async () => {
expect(await setBrowserViewport("/engine", { ...env, AGENT_BROWSER_CDP: "http://127.0.0.1:9222" })).toBe(true);
expect(await setBrowserViewport("/engine", { ...env, AGENT_BROWSER_AUTO_CONNECT: "1" })).toBe(true);
expect(await setBrowserViewport("/engine", { ...env, AGENT_BROWSER_HEADLESS: undefined })).toBe(true);
expect(spawn).not.toHaveBeenCalled();
});
});
describe("asking a running browser", () => {
function engine(running: string[][], answer: unknown = { success: true, data: { cdpUrl: "ws://127.0.0.1:9/devtools/browser/x" } }) {
const home = mkdtempSync(join(tmpdir(), "omb-browser-running-"));
scratch.push(home);
vi.mocked(spawn).mockImplementation((_binary, args) => {
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), kill: () => true });
queueMicrotask(() => {
const sessions = args[0] === "session" ? running.shift() ?? [] : null;
child.stdout.emit("data", JSON.stringify(sessions ? { success: true, data: { sessions } } : answer));
child.emit("close", 0);
});
return child as ReturnType<typeof spawn>;
});
return { HOME: home, USERPROFILE: home, PATH: "", AGENT_BROWSER_SESSION: "work", AGENT_BROWSER_RESTORE: "omb-key", AGENT_BROWSER_HEADLESS: "1" };
}
it("never launches a stopped browser just to ask it something", async () => {
const env = engine([["other"]]);
expect(await inRunningBrowser("/engine", env, ["get", "cdp-url"])).toBeNull();
expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toEqual([["session", "list", "--json"]]);
expect(vi.mocked(spawn).mock.calls[0]?.[2]?.env).not.toHaveProperty("AGENT_BROWSER_HEADLESS");
});
it("asks a running one with the session's own launch environment", async () => {
const env = engine([["work"]]);
expect(await inRunningBrowser("/engine", env, ["get", "cdp-url"])).toEqual({ cdpUrl: "ws://127.0.0.1:9/devtools/browser/x" });
expect(vi.mocked(spawn).mock.calls[1]?.[1]).toEqual(["get", "cdp-url", "--json", "--no-webmcp"]);
expect(vi.mocked(spawn).mock.calls[1]?.[2]?.env).toMatchObject({ AGENT_BROWSER_SESSION: "work", AGENT_BROWSER_RESTORE: "omb-key", AGENT_BROWSER_HEADLESS: "1" });
});
it("treats a browser that closed while being asked as stopped, and any other failure as an error", async () => {
expect(await inRunningBrowser("/engine", engine([["work"], []], { success: false }), ["state", "save", "/x"])).toBeNull();
await expect(inRunningBrowser("/engine", engine([["work"], ["work"]], { success: false }), ["state", "save", "/x"])).rejects.toThrow("did not answer");
});
});
describe("deleting one browser session's saved logins", () => {
function fixture(closeCode = 0) {
const home = mkdtempSync(join(tmpdir(), "omb-browser-cleanup-"));
scratch.push(home);
const directory = join(home, ".agent-browser", "sessions");
mkdirSync(join(directory, ".tmp"), { recursive: true });
const running = new Set(["work"]);
vi.mocked(spawn).mockImplementation(runningDaemons(running, { code: closeCode }));
return { home, directory, running, options: { env: { HOME: home, USERPROFILE: home, PATH: "" }, dataDir: home } };
}
it("deletes the session's kept profile with its saved state, and no other profile", async () => {
const { home, options } = fixture();
for (const session of ["work", "work-client"]) {
mkdirSync(join(browserProfileDirectory(session, home), "Default"), { recursive: true });
writeFileSync(join(browserProfileDirectory(session, home), "Default", "Cookies"), "signed in");
}
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(true);
expect(existsSync(browserProfileDirectory("work", home))).toBe(false);
expect(readFileSync(join(browserProfileDirectory("work-client", home), "Default", "Cookies"), "utf8")).toBe("signed in");
});
it("keeps the kept profile when the browser would not close", async () => {
const { home, options } = fixture(1);
mkdirSync(browserProfileDirectory("work", home), { recursive: true });
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(false);
expect(existsSync(browserProfileDirectory("work", home))).toBe(true);
});
it("removes only exact state/backup/candidate files and never calls state clear", async () => {
const { directory, options } = fixture();
const removed = ["work-work.json", "work-work.json.enc", "work-work.json.previous", "work-work.json.enc.previous", ".tmp/work-work-candidate-123.json.enc", `${browserRestoreKey("work")}-work.json.enc`, `${browserRestoreKey("work")}-work.json.enc.previous`, `.tmp/${browserRestoreKey("work")}-work-candidate-123.json.enc`];
const preserved = ["work-client-work-client.json.enc", "personal-personal.json", "work-another.json", "notes.json", ".tmp/work-client-work-client-candidate-123.json.enc", `${browserRestoreKey("work-client")}-work-client.json.enc`];
for (const name of [...removed, ...preserved]) writeFileSync(join(directory, name), "fixture state");
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(true);
for (const name of removed) expect(existsSync(join(directory, name)), name).toBe(false);
for (const name of preserved) expect(readFileSync(join(directory, name), "utf8"), name).toBe("fixture state");
expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toEqual([["session", "list", "--json"], ["close"], ["session", "list", "--json"]]);
});
it("never sends close to a browser that is not running", async () => {
const { options, running } = fixture();
running.clear();
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(true);
expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toEqual([["session", "list", "--json"]]);
});
it("cannot launch a browser from close through inherited launch flags or native user config", async () => {
const { home, options } = fixture();
options.env = {
...options.env, AGENT_BROWSER_EXECUTABLE_PATH: "/fixture/chrome", AGENT_BROWSER_NO_WEBMCP: "1",
AGENT_BROWSER_PROFILE: "/fixture/shared", AGENT_BROWSER_CONFIG: "/fixture/unsafe-config.json",
AGENT_BROWSER_SOCKET_DIR: "/fixture/sockets",
} as typeof options.env;
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(true);
expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toContainEqual(["close"]);
for (const [, , spawned] of vi.mocked(spawn).mock.calls) {
const env = spawned?.env;
expect(env).not.toHaveProperty("AGENT_BROWSER_EXECUTABLE_PATH");
expect(env).not.toHaveProperty("AGENT_BROWSER_NO_WEBMCP");
expect(env).not.toHaveProperty("AGENT_BROWSER_PROFILE");
expect(env?.AGENT_BROWSER_SOCKET_DIR).toBe("/fixture/sockets");
expect(env?.AGENT_BROWSER_CONFIG).toBe(join(home, ".agent-browser", "omb-managed-config.json"));
expect(readFileSync(env!.AGENT_BROWSER_CONFIG!, "utf8")).toBe("{}\n");
}
});
it("preserves state when closing the daemon fails", async () => {
const { directory, options } = fixture(1);
const path = join(directory, "work-work.json.enc");
writeFileSync(path, "saved login");
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(false);
expect(readFileSync(path, "utf8")).toBe("saved login");
});
it("waits for actual daemon disappearance after close acknowledgement", async () => {
const { options } = fixture();
let polls = 0;
vi.mocked(spawn).mockImplementation((_binary, args) => lifecycleChild(args, { sessions: args?.[0] === "session" && ++polls <= 2 ? ["work", "work-client"] : ["work-client"] }));
expect(await closeBrowserSession("fixture-browser", { ...options.env, AGENT_BROWSER_SESSION: "work" })).toBe(true);
expect(polls).toBe(3);
});
it("finishes when its daemon is gone even though a new one took the session's name", async () => {
const { options } = fixture();
const env = { ...options.env, AGENT_BROWSER_SESSION: "work" };
const pidFile = browserDaemonFile(env, ".pid");
mkdirSync(join(pidFile, ".."), { recursive: true });
writeFileSync(pidFile, "2147483000");
vi.mocked(spawn).mockImplementation((_binary, args) => lifecycleChild(args, { sessions: ["work"], onClose: () => writeFileSync(pidFile, "2147483001") }));
const started = Date.now();
expect(await closeBrowserSession("fixture-browser", env, 10_000)).toBe(true);
expect(Date.now() - started).toBeLessThan(5_000);
expect(readFileSync(pidFile, "utf8")).toBe("2147483001");
});
it("joins a close of the same browser already in flight instead of sending a second", async () => {
const { options } = fixture();
const env = { ...options.env, AGENT_BROWSER_SESSION: "work" };
expect(await Promise.all([closeBrowserSession("fixture-browser", env), closeBrowserSession("fixture-browser", env)])).toEqual([true, true]);
expect(vi.mocked(spawn).mock.calls.filter((call) => call[1]?.[0] === "close")).toHaveLength(1);
});
it("waits for inventory stdout to finish after the process exits", async () => {
const { options } = fixture();
let polls = 0;
vi.mocked(spawn).mockImplementation((_binary, args) => {
if (args?.[0] !== "session") return lifecycleChild(args);
const sessions = ++polls === 1 ? ["work"] : [];
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), kill: () => true });
queueMicrotask(() => {
child.emit("exit", 0);
queueMicrotask(() => {
child.stdout.emit("data", JSON.stringify({ success: true, data: { sessions } }));
child.emit("close", 0);
});
});
return child as ReturnType<typeof spawn>;
});
expect(await closeBrowserSession("fixture-browser", { ...options.env, AGENT_BROWSER_SESSION: "work" })).toBe(true);
expect(polls).toBe(2);
});
it.each(['not-json', '{"success":true}', '{"success":false,"data":{"sessions":[]}}'])("refuses an invalid shutdown inventory %s", async (inventory) => {
const { options } = fixture();
vi.mocked(spawn).mockImplementation((_binary, args) => lifecycleChild(args, { inventory }));
expect(await closeBrowserSession("fixture-browser", { ...options.env, AGENT_BROWSER_SESSION: "work" })).toBe(false);
});
it("keeps saved state when the daemon remains alive past the close deadline", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-work.json.enc"), "saved login");
vi.mocked(spawn).mockImplementation((_binary, args) => lifecycleChild(args, { sessions: ["work"] }));
expect(await clearBrowserSessionState("fixture-browser", "work", { ...options, timeoutMs: 35 })).toBe(false);
expect(readFileSync(join(directory, "work-work.json.enc"), "utf8")).toBe("saved login");
});
it("does not recursively remove a directory with a state-like name", async () => {
const { directory, options } = fixture();
const path = join(directory, "work-work.json");
mkdirSync(path);
writeFileSync(join(path, "keep"), "unrelated data");
expect(await clearBrowserSessionState("fixture-browser", "work", options)).toBe(false);
expect(readFileSync(join(path, "keep"), "utf8")).toBe("unrelated data");
});
it("handles an already-empty session without changing other state", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "other-other.json"), "keep");
expect(await clearBrowserSessionState("fixture-browser", "guest-123", options)).toBe(true);
expect(readFileSync(join(directory, "other-other.json"), "utf8")).toBe("keep");
});
// Dotted ids are legitimate browserSessionId output; only path-shaped, glob-shaped, empty and over-long names are invalid.
it.each(["", "../personal", "work/client", "work*", "x".repeat(97)])("rejects invalid session %j before invoking a process", async (session) => {
const { options } = fixture();
expect(await clearBrowserSessionState("fixture-browser", session, options)).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
});
describe("a browser daemon that will not close (#2603)", () => {
it.each([
[{ AGENT_BROWSER_SOCKET_DIR: "/s", XDG_RUNTIME_DIR: "/x", AGENT_BROWSER_NAMESPACE: "ns" }, "/s/work.pid"],
[{ XDG_RUNTIME_DIR: "/x" }, "/x/agent-browser/work.pid"],
[{ XDG_RUNTIME_DIR: "/x", AGENT_BROWSER_NAMESPACE: "ns" }, "/x/agent-browser/namespaces/ns/run/work.pid"],
[{ AGENT_BROWSER_NAMESPACE: "ns" }, "/h/.agent-browser/namespaces/ns/run/work.pid"],
[{}, "/h/.agent-browser/work.pid"],
])("finds the daemon's pid file where agent-browser writes it (%j)", (extra, expected) => {
if (!posix) return;
expect(browserDaemonFile({ HOME: "/h", USERPROFILE: "/h", AGENT_BROWSER_SESSION: "work", ...extra }, ".pid")).toBe(expected);
});
// A symlink to node stands in for the agent-browser binary: `ps` shows the
// path a daemon was started through. The inventory and `close` are faked;
// the daemon, its browser and the process listing are real.
const groups: number[] = [];
afterEach(() => { for (const group of groups.splice(0)) { try { process.kill(-group, "SIGKILL"); } catch {} } });
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
const gone = (actual: typeof import("node:child_process"), pid: number) => {
try { return actual.execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim().startsWith("Z"); } catch { return true; }
};
// The daemon's browser leads a process group of its own, as Chrome does, with
// a helper inside it; each writes its pid where the test can read it.
// On USR1 it starts one more helper, as Chrome does for a new page.
const BROWSER = `const { spawn } = require('node:child_process'); const fs = require('node:fs');
const helper = spawn('sleep', ['30'], { stdio: 'ignore' });
process.on('SIGUSR1', () => { const late = spawn('sleep', ['30'], { stdio: 'ignore' }); fs.writeFileSync(process.env.HELPER_PID + '.late', String(late.pid)); });
fs.writeFileSync(process.env.HELPER_PID, String(helper.pid)); setInterval(() => {}, 1000);`;
const DAEMON = `const { spawn } = require('node:child_process');
const browser = spawn(process.execPath, ['-e', ${JSON.stringify(BROWSER)}, '--', '--remote-debugging-port=0', '--user-data-dir=' + process.env.PROFILE_DIR], { detached: true, stdio: 'ignore' });
require('node:fs').writeFileSync(process.env.BROWSER_PID, String(browser.pid)); setInterval(() => {}, 1000);`;
// agent-browser's daemon on TERM: it saves the session's sign-ins over its
// browser's DevTools connection, which takes a moment, then stops that
// browser's group itself and exits. It records whether the browser was
// still there to save from.
const SAVING_DAEMON = `const { spawn } = require('node:child_process'); const fs = require('node:fs');
const browser = spawn(process.execPath, ['-e', ${JSON.stringify(BROWSER)}, '--', '--remote-debugging-port=0', '--user-data-dir=' + process.env.PROFILE_DIR], { detached: true, stdio: 'ignore' });
fs.writeFileSync(process.env.BROWSER_PID, String(browser.pid));
process.on('SIGTERM', () => setTimeout(() => {
let running = true; try { process.kill(browser.pid, 0); } catch { running = false; }
fs.writeFileSync(process.env.BROWSER_PID + '.saved', running ? 'saved' : 'browser already gone');
try { process.kill(-browser.pid, 'SIGKILL'); } catch {}
process.exit(0);
}, 200));
setInterval(() => {}, 1000);`;
async function engine(options: { close: (pids: { daemon: number; browser: number; helper: number }) => number | Promise<number>; via?: "symlink" | "resolved" | "unreaped"; daemon?: string }) {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
const home = mkdtempSync(join(tmpdir(), "omb-browser-hung-"));
scratch.push(home);
const binaryPath = join(home, "agent-browser");
symlinkSync(process.execPath, binaryPath);
const env = { HOME: home, USERPROFILE: home, PATH: process.env.PATH ?? "", AGENT_BROWSER_SESSION: "work" };
mkdirSync(join(home, ".agent-browser"), { recursive: true });
const files = { BROWSER_PID: join(home, "browser.pid"), HELPER_PID: join(home, "helper.pid"), PROFILE_DIR: join(home, `agent-browser-chrome-${randomUUID()}`) };
mkdirSync(join(files.PROFILE_DIR, "Default"), { recursive: true });
// What agent-browser hands each daemon, and its Chrome inherits.
const daemonEnv = { ...process.env, ...files, AGENT_BROWSER_DAEMON: "1", AGENT_BROWSER_SESSION: "work" };
let daemon: number;
if (options.via === "unreaped") {
// No init: the daemon's parent never collects it, so once killed it stays a zombie.
const parent = actual.spawn("/bin/sh", ["-c", `"$0" -e "$1" & echo $! > "$2"; exec sleep 30`, binaryPath, options.daemon ?? DAEMON, join(home, "daemon.pid")], { detached: true, stdio: "ignore", env: daemonEnv });
groups.push(parent.pid!);
await vi.waitFor(() => expect(existsSync(join(home, "daemon.pid")) && readFileSync(join(home, "daemon.pid"), "utf8").trim()).toBeTruthy());
daemon = Number(readFileSync(join(home, "daemon.pid"), "utf8").trim());
} else {
// agent-browser starts its daemon through its canonical path, which a
// global npm install reaches through a symlink.
const started = actual.spawn(options.via === "resolved" ? realpathSync(process.execPath) : binaryPath, ["-e", options.daemon ?? DAEMON], { detached: true, stdio: "ignore", env: daemonEnv });
started.unref();
daemon = started.pid!;
}
groups.push(daemon);
await vi.waitFor(() => expect(existsSync(files.BROWSER_PID) && existsSync(files.HELPER_PID) && readFileSync(files.HELPER_PID, "utf8").trim()).toBeTruthy(), { timeout: 5_000 });
const pids = { daemon, browser: Number(readFileSync(files.BROWSER_PID, "utf8")), helper: Number(readFileSync(files.HELPER_PID, "utf8")) };
groups.push(pids.browser);
writeFileSync(browserDaemonFile(env, ".pid"), String(daemon));
const running = new Set(["work"]);
vi.mocked(spawn).mockImplementation(((binary: string, args: readonly string[], spawnOptions: object) => {
if (args[0] === "session") return lifecycleChild(args, { sessions: [...running] });
if (args[0] !== "close") return actual.spawn(binary, args, spawnOptions);
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), kill: () => true });
void Promise.resolve(options.close(pids)).then((code) => {
if (code === 0) running.delete("work");
child.emit("exit", code); child.emit("close", code);
});
return child;
}) as unknown as typeof spawn);
return { actual, binaryPath, env, pids, profile: files.PROFILE_DIR, lateHelper: () => Number(readFileSync(`${files.HELPER_PID}.late`, "utf8")) };
}
it.skipIf(!posix)("stops the daemon and the browser under it when close fails, and removes the profile folder it would have", async () => {
const { actual, binaryPath, env, pids, profile } = await engine({ close: () => 1 });
expect(existsSync(profile)).toBe(true);
expect(await closeBrowserSession(binaryPath, env, 2_000)).toBe(true);
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
expect(existsSync(profile)).toBe(false);
});
it.skipIf(!posix)("lets a daemon that will not close save its sign-ins from its browser before anything else is stopped", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: () => 1, daemon: SAVING_DAEMON });
expect(await closeBrowserSession(binaryPath, env, 1_000)).toBe(true);
expect(readFileSync(join(env.HOME, "browser.pid.saved"), "utf8")).toBe("saved");
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("does not report closed while the browser it started still runs", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: ({ daemon }) => { process.kill(daemon, "SIGKILL"); return 0; } });
expect(await closeBrowserSession(binaryPath, env, 500)).toBe(true);
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("stops what the browser started after the close began, by its process group", async () => {
let late = "";
const { actual, binaryPath, env, pids } = await engine({ close: async ({ browser }) => {
process.kill(browser, "SIGUSR1");
await vi.waitFor(() => { late = readFileSync(join(env.HOME, "helper.pid.late"), "utf8"); expect(late).toBeTruthy(); });
return 1;
} });
expect(await closeBrowserSession(binaryPath, env, 1_000)).toBe(true);
for (const pid of [...Object.values(pids), Number(late)]) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("returns as soon as the daemon and its browser have exited", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: ({ daemon, browser }) => { process.kill(-browser, "SIGTERM"); process.kill(daemon, "SIGTERM"); return 0; } });
const started = Date.now();
expect(await closeBrowserSession(binaryPath, env, 10_000)).toBe(true);
expect(Date.now() - started).toBeLessThan(5_000);
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("finds a daemon started through the binary's resolved path", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: () => 1, via: "resolved" });
expect(await closeBrowserSession(binaryPath, env, 1_000)).toBe(true);
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("counts a stopped daemon nothing collects as gone", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: () => 1, via: "unreaped" });
expect(await closeBrowserSession(binaryPath, env, 1_000)).toBe(true);
expect(alive(pids.daemon)).toBe(true); // a zombie still answers signal 0
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("stops a daemon that ignores TERM in no process group of its own by its pid", async () => {
const { actual, binaryPath, env, pids } = await engine({ close: () => 1, via: "unreaped", daemon: `process.on('SIGTERM', () => {}); ${DAEMON}` });
expect(await closeBrowserSession(binaryPath, env, 500)).toBe(true);
for (const pid of Object.values(pids)) expect(gone(actual, pid), String(pid)).toBe(true);
});
it.skipIf(!posix)("leaves alone a process the pid file names that is not this agent-browser", async () => {
const { actual, binaryPath, env } = await engine({ close: () => 1 });
const stranger = actual.spawn("sleep", ["30"], { stdio: "ignore" });
try {
writeFileSync(browserDaemonFile(env, ".pid"), String(stranger.pid));
expect(await closeBrowserSession(binaryPath, env, 1_000)).toBe(false);
expect(alive(stranger.pid!)).toBe(true);
} finally {
stranger.kill("SIGKILL");
}
});
it.skipIf(!posix)("leaves alone another session's daemon, or an engine command, that took the pid of this one", async () => {
const { actual, binaryPath, env } = await engine({ close: () => 1 });
for (const taken of [{ AGENT_BROWSER_DAEMON: "1", AGENT_BROWSER_SESSION: "play" }, { AGENT_BROWSER_SESSION: "work" }]) {
const other = actual.spawn(binaryPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore", env: { PATH: process.env.PATH, ...taken } });
groups.push(other.pid!);
writeFileSync(browserDaemonFile(env, ".pid"), String(other.pid));
expect(await closeBrowserSession(binaryPath, env, 500)).toBe(false);
expect(alive(other.pid!)).toBe(true);
}
});
});
describe("restoring exactly one browser profile", () => {
function fixture() {
const home = mkdtempSync(join(tmpdir(), "omb-browser-restore-"));
scratch.push(home);
const directory = join(home, ".agent-browser", "sessions");
mkdirSync(directory, { recursive: true });
vi.mocked(spawn).mockImplementation((_binary, args) => lifecycleChild(args));
return { directory, options: { env: { HOME: home, USERPROFILE: home } } };
}
it("uses fixed-length restore identities even for overlapping legacy names", () => {
const keys = ["work", "work-client", "work-work-client", "WORK", "work_"].map(browserRestoreKey);
expect(new Set(keys).size).toBe(keys.length);
expect(keys.every((key) => /^omb-[a-f0-9]{64}$/.test(key))).toBe(true);
for (const a of keys) for (const b of keys) if (a !== b) expect(b.startsWith(a + "-")).toBe(false);
});
it("copies only the exact legacy profile, preserves originals, and keeps launch settings stable", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-work.json.enc"), "work login");
writeFileSync(join(directory, "work-client-work-client.json.enc"), "client login");
const before = agentBrowserIntegration({ binaryPath: "fixture-browser", session: "work", encryptionKey: "key", env: options.env });
await prepareBrowserSessionState("fixture-browser", "work", options);
expect(readFileSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`), "utf8")).toBe("work login");
if (posix) expect(statSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`)).mode & 0o777).toBe(0o600);
expect(readFileSync(join(directory, "work-work.json.enc"), "utf8")).toBe("work login");
expect(readFileSync(join(directory, "work-client-work-client.json.enc"), "utf8")).toBe("client login");
expect(agentBrowserIntegration({ binaryPath: "fixture-browser", session: "work", encryptionKey: "key", env: options.env })).toEqual(before);
expect(vi.mocked(spawn).mock.calls[0][2]?.env?.AGENT_BROWSER_RESTORE).toBe("work");
});
it.skipIf(!posix)("frees a kept profile a lock from another computer holds before the browser starts on it", async () => {
const { directory, options } = fixture();
const kept = join(directory, "..", "kept-profile");
mkdirSync(kept, { recursive: true });
symlinkSync("another-host.example-12345", join(kept, "SingletonLock"));
await prepareBrowserSessionState("fixture-browser", "work", { env: { ...options.env, AGENT_BROWSER_PROFILE: kept } });
expect(() => lstatSync(join(kept, "SingletonLock"))).toThrow();
expect(existsSync(kept)).toBe(true);
});
it("never imports a prefix match when the exact legacy profile is absent", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-client-work-client.json.enc"), "client login");
await prepareBrowserSessionState("fixture-browser", "work", options);
expect(existsSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`))).toBe(false);
expect(readFileSync(join(directory, "work-client-work-client.json.enc"), "utf8")).toBe("client login");
});
it("prefers the latest exact state format but never overwrites current state", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-work.json"), "new plain state");
writeFileSync(join(directory, "work-work.json.enc"), "old encrypted state");
utimesSync(join(directory, "work-work.json.enc"), 1, 1);
await prepareBrowserSessionState("fixture-browser", "work", options);
expect(readFileSync(join(directory, `${browserRestoreKey("work")}-work.json`), "utf8")).toBe("new plain state");
writeFileSync(join(directory, `${browserRestoreKey("client")}-client.json.enc`), "current state");
writeFileSync(join(directory, "client-client.json.enc"), "old state");
await prepareBrowserSessionState("fixture-browser", "client", options);
expect(readFileSync(join(directory, `${browserRestoreKey("client")}-client.json.enc`), "utf8")).toBe("current state");
expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toEqual([["session", "list", "--json"]]);
});
it("deduplicates preparations by home and session, and skips guest persistence", async () => {
const a = fixture();
const b = fixture();
await Promise.all([prepareBrowserSessionState("fixture-browser", "work", a.options), prepareBrowserSessionState("fixture-browser", "work", a.options)]);
await prepareBrowserSessionState("fixture-browser", "work", b.options);
await prepareBrowserSessionState("fixture-browser", "guest", { ...a.options, persistent: false });
expect(spawn).toHaveBeenCalledTimes(2);
});
it("flushes a running legacy daemon before copying its newly saved login", async () => {
const { directory, options } = fixture();
vi.mocked(spawn).mockImplementation(runningDaemons(new Set(["work"]), { onClose: () => { writeFileSync(join(directory, "work-work.json.enc"), "just-flushed login"); } }));
await prepareBrowserSessionState("fixture-browser", "work", options);
expect(readFileSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`), "utf8")).toBe("just-flushed login");
});
it("refuses changed access before closing and after flushing, without resurrecting deleted state", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-work.json.enc"), "old login");
await expect(prepareBrowserSessionState("fixture-browser", "work", { ...options, isCurrent: () => false })).rejects.toThrow(/access changed/);
expect(spawn).not.toHaveBeenCalled();
let current = true;
vi.mocked(spawn).mockImplementation(runningDaemons(new Set(["work"]), { onClose: () => { current = false; } }));
await expect(prepareBrowserSessionState("fixture-browser", "work", { ...options, isCurrent: () => current })).rejects.toThrow(/access changed/);
expect(existsSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`))).toBe(false);
});
it("does not migrate after a failed native close and allows a later retry", async () => {
const { directory, options } = fixture();
writeFileSync(join(directory, "work-work.json.enc"), "saved login");
const running = new Set(["work"]);
vi.mocked(spawn).mockImplementation(runningDaemons(running, { code: 1 }));
await expect(prepareBrowserSessionState("fixture-browser", "work", options)).rejects.toThrow(/safely prepare/);
expect(existsSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`))).toBe(false);
vi.mocked(spawn).mockImplementation(runningDaemons(running));
await prepareBrowserSessionState("fixture-browser", "work", options);
expect(readFileSync(join(directory, `${browserRestoreKey("work")}-work.json.enc`), "utf8")).toBe("saved login");
});
it("does not overwrite an unexpected managed config or start a browser with it", async () => {
const { directory, options } = fixture();
const path = join(directory, "..", "omb-managed-config.json");
writeFileSync(path, '{"profile":"/fixture/shared"}');
await expect(prepareBrowserSessionState("fixture-browser", "work", options)).rejects.toThrow(/configuration was changed/);
expect(readFileSync(path, "utf8")).toBe('{"profile":"/fixture/shared"}');
expect(spawn).not.toHaveBeenCalled();
});
});
describe("finding the browser engine", () => {
it("resolves Android to the statically linked Linux build instead of reporting it missing", () => {
// Android is bionic, not musl, so isMusl() is false there; the asset that actually
// runs is the statically linked musl build, which needs no interpreter.
expect(isMusl("android")).toBe(false);
const android = resolveAgentBrowserReleaseAsset("android", "arm64");
expect(android).not.toBeNull();
expect(android!.asset).toBe("agent-browser-linux-musl-arm64");
expect(android!.target).toBe("linux-musl-arm64");
// Every other platform keeps the key it had before.
expect(resolveAgentBrowserReleaseAsset("linux", "arm64", true)!.target).toBe("linux-musl-arm64");
expect(resolveAgentBrowserReleaseAsset("linux", "arm64", false)!.target).toBe("linux-arm64");
expect(resolveAgentBrowserReleaseAsset("linux", "x64", false)!.target).toBe("linux-x64");
expect(resolveAgentBrowserReleaseAsset("darwin", "arm64")!.target).toBe("darwin-arm64");
});
it("pins the native-verified Windows revision in both download and desktop manifests", () => {
const asset = resolveAgentBrowserReleaseAsset("win32", "x64")!;
expect(asset).toEqual({
target: "win32-x64", version: "0.36.0-omb.1",
asset: "agent-browser-win32-x64-0.36.0-omb.1.exe",
url: "https://github.com/milind-soni/OpenMausBot/releases/download/browser-engine-v0.36.0-omb.1/agent-browser-win32-x64-0.36.0-omb.1.exe",
bytes: 13806080, sha256: "33bee834f6a6072ec8688b0914726e0262874d758f69f27e8baf7eaac6b5ed15",
});
expect(agentBrowserReleaseVersion(asset)).toBe("0.36.0-omb.1");
expect(browserBundleSpec("win32-x64").engine).toEqual({
version: asset.version, asset: asset.asset, url: asset.url,
bytes: asset.bytes, sha256: asset.sha256, executable: "agent-browser.exe",
});
for (const [platform, arch] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["linux", "x64"]] as const) {
expect(agentBrowserReleaseVersion(resolveAgentBrowserReleaseAsset(platform, arch))).toBe("0.37.0");
}
});
it("does not reuse a pre-fix Windows managed install for a revised release", () => {
const dataDir = join(tmpdir(), "omb-versioned-browser-fixture");
const old = join(dataDir, "tools", "agent-browser", "0.36.0", "agent-browser.exe");
const revised = pinnedBinaryPath(dataDir, "win32", "x64");
expect(revised).toBe(join(dataDir, "tools", "agent-browser", "0.36.0-omb.1", "agent-browser.exe"));
const files = new Set([old]);
const options = { dataDir, platform: "win32" as const, arch: "x64", env: { PATH: "" }, exists: (file: string) => files.has(file) };
expect(resolveAgentBrowserBinary(options)).toBeNull();
files.add(revised);
expect(browserEngineStatus(options)).toMatchObject({ kind: "ready", binaryPath: revised, version: "0.36.0-omb.1" });
expect(resolveAgentBrowserBinary({ ...options, env: { PATH: "", OMB_AGENT_BROWSER_PATH: old } })).toBe(old);
});
it("retains default upstream versions and permits a pinned platform-specific asset URL", () => {
const official = resolveAgentBrowserReleaseAsset("linux", "x64")!;
expect(agentBrowserReleaseVersion(official)).toBe(AGENT_BROWSER_VERSION);
const revised = { ...official, version: "0.36.0-omb.1", url: "https://example.invalid/releases/download/fixed/fixture.exe" };
expect(agentBrowserReleaseVersion(revised)).toBe("0.36.0-omb.1");
expect(agentBrowserReleaseUrl(revised)).toBe(revised.url);
});
it.each(SUPPORTED_BROWSER_TARGETS)("uses the complete %s desktop bundle before old downloaded engines", (target) => {
const [platform, arch] = target.split("-");
const env = { OMB_RESOURCES_PATH: join(tmpdir(), "OMB resources"), PATH: "" };
const bundle = browserBundlePaths(join(env.OMB_RESOURCES_PATH, "browser-engine"), target);
const files = new Set([bundle.directory, bundle.engine, bundle.chrome, bundle.manifest, bundle.licenses]);
const options = { env, platform: platform as NodeJS.Platform, arch, exists: (p: string) => files.has(p) };
expect(resolveAgentBrowserBinary(options)).toBe(bundle.engine);
expect(browserEngineStatus(options)).toMatchObject({ kind: "ready", binaryPath: bundle.engine });
files.delete(bundle.chrome);
expect(resolveAgentBrowserBinary(options)).toBeNull();
expect(browserEngineStatus(options)).toMatchObject({ kind: "unavailable", installable: false, reason: expect.stringContaining("Reinstall") });
files.add(bundle.chrome);
files.delete(bundle.licenses);
expect(resolveAgentBrowserBinary(options)).toBeNull();
files.add(bundle.licenses);
files.delete(bundle.manifest);
expect(resolveAgentBrowserBinary(options)).toBeNull();
const external = join(tmpdir(), "external-engine");
files.add(external);
expect(resolveAgentBrowserBinary({ ...options, env: { ...env, OMB_AGENT_BROWSER_PATH: external } })).toBe(external);
});
it("mounts the bundled browser with no download and keeps explicit Chrome overrides", async () => {
const resources = mkdtempSync(join(tmpdir(), "omb-browser-resources-"));
scratch.push(resources);
const host = { platform: "linux" as const, arch: "x64" };
const env = { OMB_RESOURCES_PATH: resources, PATH: "" };
const bundle = browserBundlePaths(join(resources, "browser-engine"), "linux-x64");
mkdirSync(bundle.licenses, { recursive: true });
for (const file of [bundle.engine, bundle.chrome, bundle.manifest]) {
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, "fixture, not executable");
}
expect(browserEngineStatus({ env, ...host })).toMatchObject({ kind: "ready", binaryPath: bundle.engine });
const spec = agentBrowserIntegration({ binaryPath: bundle.engine, session: "isolated", encryptionKey: "key", env, ...host });
expect(spec.env.AGENT_BROWSER_EXECUTABLE_PATH).toBe(bundle.chrome);
expect(spec.env.AGENT_BROWSER_SESSION).toBe("isolated");
expect(spec.env.AGENT_BROWSER_NO_WEBMCP).toBe("1");
expect(spec.env).not.toHaveProperty("OMB_RESOURCES_PATH");
const override = agentBrowserIntegration({ binaryPath: bundle.engine, session: "isolated", encryptionKey: "key", env: { ...env, AGENT_BROWSER_EXECUTABLE_PATH: "/explicit/chrome" }, ...host });
expect(override.env.AGENT_BROWSER_EXECUTABLE_PATH).toBe("/explicit/chrome");
// A spawn would fail because the fixture engine is not executable.
await expect(ensureChrome(bundle.engine, { env, ...host })).resolves.toBeUndefined();
});
it("prefers the explicit path, then the pinned download, then PATH, and reports why when nothing is there", () => {
const dataDir = mkdtempSync(join(tmpdir(), "omb-engine-"));
scratch.push(dataDir);
const pinned = pinnedBinaryPath(dataDir);
const name = process.platform === "win32" ? "agent-browser.exe" : "agent-browser";
const pathDir = join(dataDir, "bin");
const pathBinary = join(pathDir, name);
const override = join(dataDir, "override", name);
const files = new Set<string>();
const exists = (p: string) => files.has(p);
const env = { PATH: [join(dataDir, "empty"), pathDir].join(delimiter) };
expect(resolveAgentBrowserBinary({ dataDir, env, exists })).toBeNull();
expect(browserEngineStatus({ dataDir, env, exists })).toMatchObject({ kind: "unavailable", installable: true });
files.add(pathBinary);
expect(resolveAgentBrowserBinary({ dataDir, env, exists })).toBe(pathBinary);
files.add(pinned);
expect(resolveAgentBrowserBinary({ dataDir, env, exists })).toBe(pinned);
files.add(override);
expect(resolveAgentBrowserBinary({ dataDir, env: { ...env, OMB_AGENT_BROWSER_PATH: override }, exists })).toBe(override);
expect(resolveAgentBrowserBinary({ dataDir, env: { ...env, OMB_AGENT_BROWSER_PATH: join(dataDir, "missing", name) }, exists })).toBeNull();
expect(browserEngineStatus({ dataDir, env, exists })).toMatchObject({ kind: "ready", binaryPath: pinned, version: agentBrowserReleaseVersion(resolveAgentBrowserReleaseAsset()), managed: true });
expect(browserEngineStatus({ dataDir, env: { ...env, OMB_AGENT_BROWSER_PATH: override }, exists, versionOf: () => AGENT_BROWSER_VERSION })).toMatchObject({ kind: "ready", binaryPath: override, managed: false });
files.delete(pinned);
expect(browserEngineStatus({ dataDir, env, exists, versionOf: () => AGENT_BROWSER_VERSION })).toMatchObject({ kind: "ready", binaryPath: pathBinary, managed: false });
});
it("hands agent-browser the Chrome OMB prepared instead of the bundled shell, never when it attaches to the person's Chrome", () => {
const resources = mkdtempSync(join(tmpdir(), "omb-browser-resources-"));
scratch.push(resources);
const host = { platform: "darwin" as const, arch: "arm64" };
const env = { OMB_RESOURCES_PATH: resources, PATH: "" };
const bundle = browserBundlePaths(join(resources, "browser-engine"), "darwin-arm64");
mkdirSync(bundle.licenses, { recursive: true });
for (const file of [bundle.engine, bundle.chrome, bundle.manifest]) {
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, "fixture");
}
expect(browserEngineStatus({ env, ...host })).toMatchObject({ kind: "ready", binaryPath: bundle.engine, managed: true });
const launcher = { AGENT_BROWSER_EXECUTABLE_PATH: "/data/browser/chrome-launcher.sh", AGENT_BROWSER_HIDE_SCROLLBARS: "0" };
const spec = agentBrowserIntegration({ binaryPath: bundle.engine, session: "bot-1", encryptionKey: "key", env, launcher, ...host });
expect(spec.env).toMatchObject(launcher);
expect(agentBrowserIntegration({ binaryPath: bundle.engine, session: "bot-1", encryptionKey: "key", env, launcher: {}, ...host }).env.AGENT_BROWSER_EXECUTABLE_PATH).toBe(bundle.chrome);
const mine = agentBrowserIntegration({ binaryPath: bundle.engine, session: "mine", encryptionKey: "key", env, launcher, attach: {}, ...host });
expect(mine.env.AGENT_BROWSER_EXECUTABLE_PATH).toBe(bundle.chrome);
expect(mine.env).not.toHaveProperty("AGENT_BROWSER_HIDE_SCROLLBARS");
});
it("names the browser the engine starts in its status line", () => {
const ready = { kind: "ready" as const, binaryPath: "/e/agent-browser", version: AGENT_BROWSER_VERSION, managed: true };
expect(describeBrowserEngine(ready, "Google Chrome 153 (installed)")).toBe(`browser engine: agent-browser ${AGENT_BROWSER_VERSION} at /e/agent-browser; browser: Google Chrome 153 (installed)`);
expect(describeBrowserEngine(ready)).toBe(`browser engine: agent-browser ${AGENT_BROWSER_VERSION} at /e/agent-browser`);
});
it("reports an unmanaged agent-browser's real version and warns when it is not the pin", () => {
const dataDir = mkdtempSync(join(tmpdir(), "omb-engine-unmanaged-"));
scratch.push(dataDir);
const name = process.platform === "win32" ? "agent-browser.exe" : "agent-browser";
const binary = resolve(join(dataDir, "bin"), name);
const exists = (file: string) => file === binary;
const env = { PATH: join(dataDir, "bin") };
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const mismatched = browserEngineStatus({ dataDir, env, exists, versionOf: () => "0.38.1" });
expect(mismatched).toMatchObject({
kind: "ready",
binaryPath: binary,
version: "0.38.1",
warning: expect.stringContaining(AGENT_BROWSER_VERSION),
});
expect(describeBrowserEngine(mismatched)).toContain("0.38.1");
expect(describeBrowserEngine(mismatched)).toContain(AGENT_BROWSER_VERSION);
expect(warn).toHaveBeenCalledOnce();
warn.mockClear();
const matched = browserEngineStatus({ dataDir, env, exists, versionOf: () => AGENT_BROWSER_VERSION });
expect(matched).toEqual({ kind: "ready", binaryPath: binary, version: AGENT_BROWSER_VERSION, managed: false });
expect(warn).not.toHaveBeenCalled();
const unknown = browserEngineStatus({ dataDir, env, exists, versionOf: () => null });
expect(unknown).toMatchObject({ kind: "ready", version: "unknown", warning: expect.stringContaining("unknown") });
warn.mockClear();
const probing = browserEngineStatus({ dataDir, env, exists, versionOf: () => undefined });
expect(probing).toEqual({ kind: "ready", binaryPath: binary, version: "unknown", managed: false });
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
it("probes an unmanaged binary's version in the background, once per mtime, without blocking on a stalled binary", async () => {
const dir = mkdtempSync(join(tmpdir(), "omb-engine-probe-"));
scratch.push(dir);
const binary = join(dir, "agent-browser");
writeFileSync(binary, "fixture");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
let answer: ((output: string) => void) | undefined;
const run = vi.fn(() => new Promise<string>((resolve) => { answer = resolve; }));
const started = Date.now();
expect(cachedAgentBrowserVersion(binary, run)).toBeUndefined();
expect(cachedAgentBrowserVersion(binary, run)).toBeUndefined();
expect(run).toHaveBeenCalledTimes(1);
expect(Date.now() - started).toBeLessThan(500);
answer!("agent-browser 0.38.1\n");
await expect(pendingAgentBrowserVersionProbe(binary)).resolves.toBe("0.38.1");
expect(cachedAgentBrowserVersion(binary, run)).toBe("0.38.1");
expect(run).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("0.38.1"));
utimesSync(binary, new Date(), new Date(Date.now() + 5_000));
const failing = vi.fn(async () => { throw new Error("timed out"); });
expect(cachedAgentBrowserVersion(binary, failing)).toBeUndefined();
await expect(pendingAgentBrowserVersionProbe(binary)).resolves.toBeNull();
expect(cachedAgentBrowserVersion(binary, failing)).toBeNull();
expect(failing).toHaveBeenCalledTimes(1);
expect(cachedAgentBrowserVersion(join(dir, "missing"), run)).toBeNull();
} finally {
warn.mockRestore();
}
});
it("knows every target Vercel publishes, and picks the musl build on Alpine", () => {
for (const [platform, arch] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]] as const) {
const asset = resolveAgentBrowserReleaseAsset(platform, arch);
expect(asset, `${platform}-${arch}`).not.toBeNull();
expect(asset?.sha256).toMatch(/^[0-9a-f]{64}$/u);
if (asset?.url) expect(agentBrowserReleaseUrl(asset)).toBe(asset.url);
else expect(agentBrowserReleaseUrl(asset!)).toContain(`/v${AGENT_BROWSER_VERSION}/`);
}
expect(resolveAgentBrowserReleaseAsset("linux", "x64", true)?.target).toBe("linux-musl-x64");
expect(resolveAgentBrowserReleaseAsset("freebsd", "x64")).toBeNull();
expect(isMusl("linux", (p) => p === "/lib/ld-musl-x86_64.so.1")).toBe(true);
expect(isMusl("linux", () => false)).toBe(false);
expect(isMusl("darwin", () => true)).toBe(false);
});
});
describe("installing the browser engine", () => {
it("downloads the pinned asset, verifies size and digest, and only then names the file", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omb-engine-install-"));
scratch.push(dataDir);
const body = Buffer.from("#!/bin/sh\necho agent-browser\n");
const asset = { target: "linux-x64", asset: "agent-browser-linux-x64", sha256: createHash("sha256").update(body).digest("hex"), bytes: body.length };
const fetched: string[] = [];
const installed = await installAgentBrowserBinary({
dataDir,
platform: "linux",
asset,
fetchImpl: async (input) => {
fetched.push(String(input));
return new Response(body);
},
});
expect(installed).toBe(pinnedBinaryPath(dataDir, "linux"));
expect(fetched[0]).toBe(agentBrowserReleaseUrl(asset));
expect(readFileSync(installed, "utf8")).toContain("agent-browser");
if (posix) expect(statSync(installed).mode & 0o111).not.toBe(0);
});
it("refuses a download whose bytes do not match the pin, and leaves nothing behind", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omb-engine-bad-"));
scratch.push(dataDir);
const asset = { target: "linux-x64", asset: "agent-browser-linux-x64", sha256: "a".repeat(64), bytes: 5 };
await expect(installAgentBrowserBinary({ dataDir, platform: "linux", asset, fetchImpl: async () => new Response(Buffer.from("hello")) })).rejects.toThrow(/SHA-256/u);
await expect(installAgentBrowserBinary({ dataDir, platform: "linux", asset, fetchImpl: async () => new Response(Buffer.from("hi")) })).rejects.toThrow(/pinned size/u);
expect(() => statSync(pinnedBinaryPath(dataDir, "linux"))).toThrow();
});
});
describe("what a bot gets", () => {
// Without a gate the browser connects nowhere: Chrome fails every connection through port 0.
const gateArgs = ["--webrtc-ip-handling-policy=disable_non_proxied_udp", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", ...credentialStoreFlags()];
const cacheArg = `--disk-cache-size=${64 * 1024 * 1024}`;
/** A saved session's browser: its own kept profile, behind no gate. */
const keptNoGate = (session: string) => ({
AGENT_BROWSER_PROXY: "socks5://127.0.0.1:0", AGENT_BROWSER_PROXY_BYPASS: "<-loopback>",
AGENT_BROWSER_PROFILE: browserProfileDirectory(session), AGENT_BROWSER_ARGS: [cacheArg, ...gateArgs].join("\n"),
});
it("gives each saved session a profile of its own that outlives Chrome, and a temporary or My Chrome browser none", () => {
const dataDir = join(tmpdir(), "omb-profile-fixture");
const kept = (session: string, extra: Partial<Parameters<typeof agentBrowserIntegration>[0]> = {}) =>
agentBrowserIntegration({ binaryPath: "/x", session, encryptionKey: "k", env: {}, dataDir, ...extra }).env;
const work = kept("bot-1").AGENT_BROWSER_PROFILE!;
expect(work).toBe(browserProfileDirectory("bot-1", dataDir));
expect(resolve(work, "..")).toBe(browserProfilesRoot(dataDir));
expect(kept("bot-1").AGENT_BROWSER_PROFILE).toBe(work);
expect(kept("bot-2").AGENT_BROWSER_PROFILE).not.toBe(work);
expect(kept("bot-1").AGENT_BROWSER_ARGS!.split("\n")).toContain(cacheArg);
for (const env of [kept("guest-1", { persistent: false }), kept("work", { attach: {} })]) {
expect(env).not.toHaveProperty("AGENT_BROWSER_PROFILE");
expect(env.AGENT_BROWSER_ARGS ?? "").not.toContain(cacheArg);
}
expect(() => browserProfileDirectory("../escape", dataDir)).toThrow();
});
it("keeps a saved session's profile out of the new engine's folder, whose sweep stops every Chrome there it did not start", () => {
const dataDir = join(tmpdir(), "omb-profile-fixture");
const chrome = { pid: 4242, ppid: 4241, startTime: null, args: `/opt/chrome --remote-debugging-port=0 --user-data-dir=${browserProfileDirectory("bot-1", dataDir)} about:blank` };
expect(strays({ processes: [chrome], registered: [], root: browserRoot(dataDir), alive: () => true })).toEqual([]);
});
it("counts a saved session's own Chrome among the DevTools ports no page may reach", () => {
const temp = mkdtempSync(join(tmpdir(), "omb-devtools-"));
scratch.push(temp);
const profiles = join(temp, "profiles");
mkdirSync(join(temp, "agent-browser-chrome-1"), { recursive: true });
writeFileSync(join(temp, "agent-browser-chrome-1", "DevToolsActivePort"), "41001\n/devtools/browser/a");
mkdirSync(join(profiles, "0123abcd"), { recursive: true });
writeFileSync(join(profiles, "0123abcd", "DevToolsActivePort"), "41002\n/devtools/browser/b");
expect(agentBrowserDevToolsPorts(temp, profiles).sort()).toEqual([41001, 41002]);
});
it("launches Chrome only behind its egress gate, off the person's keychain, and never for the person's own Chrome", () => {
const gated = agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "k", env: { PATH: "/usr/bin" }, egressPort: 45123, platform: "darwin" });
expect(gated.env).toMatchObject({ AGENT_BROWSER_PROXY: "socks5://127.0.0.1:45123", AGENT_BROWSER_PROXY_BYPASS: "<-loopback>" });
expect(gated.env.AGENT_BROWSER_ARGS!.split("\n")).toEqual([cacheArg, "--webrtc-ip-handling-policy=disable_non_proxied_udp", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--use-mock-keychain"]);
expect(agentBrowserIntegration({ binaryPath: "/x", session: "bot-1", encryptionKey: "k", env: {}, egressPort: 45123, platform: "linux" }).env.AGENT_BROWSER_ARGS).toContain("--password-store=basic");
expect(agentBrowserIntegration({ binaryPath: "/x", session: "bot-1", encryptionKey: "k", env: {} }).env.AGENT_BROWSER_PROXY).toBe("socks5://127.0.0.1:0");
const mine = agentBrowserIntegration({ binaryPath: "/x", session: "work", encryptionKey: "k", env: {}, attach: {}, egressPort: 45123 });
for (const key of ["AGENT_BROWSER_PROXY", "AGENT_BROWSER_PROXY_BYPASS", "AGENT_BROWSER_ARGS"]) expect(mine.env).not.toHaveProperty(key);
});
it("keeps the prepared Chrome launcher and the gate together, and no launcher setting routes Chrome around the gate", () => {
const launcher = { AGENT_BROWSER_EXECUTABLE_PATH: "/d/chrome-launcher.sh", AGENT_BROWSER_HIDE_SCROLLBARS: "0" };
const gate = { AGENT_BROWSER_PROXY: "socks5://127.0.0.1:45123", AGENT_BROWSER_PROXY_BYPASS: "<-loopback>" };
const spec = agentBrowserIntegration({ binaryPath: "/x", session: "bot-1", encryptionKey: "k", env: {}, launcher, egressPort: 45123, platform: "darwin" });
expect(spec.env).toMatchObject({ ...launcher, ...gate });
const pushy = agentBrowserIntegration({
binaryPath: "/x", session: "bot-1", encryptionKey: "k", env: {}, egressPort: 45123, platform: "darwin",
launcher: { ...launcher, AGENT_BROWSER_PROXY: "direct://", AGENT_BROWSER_PROXY_BYPASS: "*", AGENT_BROWSER_ARGS: "--lang=en-IN" },
});
expect(pushy.env).toMatchObject({ AGENT_BROWSER_EXECUTABLE_PATH: "/d/chrome-launcher.sh", ...gate });
expect(pushy.env.AGENT_BROWSER_ARGS!.split("\n")).toEqual(["--lang=en-IN", cacheArg, "--webrtc-ip-handling-policy=disable_non_proxied_udp", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--use-mock-keychain"]);
});
it("forwards the explicit Chrome path without copying arbitrary environment or overriding session isolation", () => {
vi.stubEnv("AGENT_BROWSER_EXECUTABLE_PATH", "/process/chrome");
const spec = agentBrowserIntegration({
binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "session-key",
env: {
PATH: "/usr/bin", AGENT_BROWSER_EXECUTABLE_PATH: "/opt/trusted chrome/chrome",
PRIVATE_WORKSPACE_SECRET: "synthetic-secret",
AGENT_BROWSER_SESSION: "wrong-session", AGENT_BROWSER_ENCRYPTION_KEY: "wrong-key",
AGENT_BROWSER_ARGS: "--no-sandbox", AGENT_BROWSER_NO_WEBMCP: "0",
AGENT_BROWSER_CONFIG: "/unsafe-native-config.json", AGENT_BROWSER_PROFILE: "/unsafe-shared-profile",
},
});
expect(spec.env).toEqual({
AGENT_BROWSER_SESSION: "bot-1", AGENT_BROWSER_NO_WEBMCP: "1", AGENT_BROWSER_RESTORE: browserRestoreKey("bot-1"),
AGENT_BROWSER_RESTORE_SAVE: "auto", AGENT_BROWSER_ENCRYPTION_KEY: "session-key",
AGENT_BROWSER_CONFIG: expect.stringContaining("omb-managed-config.json"),
AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_HEADED: "0", PATH: "/usr/bin",
AGENT_BROWSER_EXECUTABLE_PATH: "/opt/trusted chrome/chrome", ...keptNoGate("bot-1"),
});
});
it("reads the configured Chrome path from the process only when no explicit environment is supplied", () => {
vi.stubEnv("PATH", "/usr/bin");
vi.stubEnv("AGENT_BROWSER_EXECUTABLE_PATH", "/opt/process-chrome/chrome");
vi.stubEnv("PRIVATE_WORKSPACE_SECRET", "synthetic-process-secret");
const spec = agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "session-key" });
expect(spec.env).toEqual({
AGENT_BROWSER_SESSION: "bot-1", AGENT_BROWSER_NO_WEBMCP: "1", AGENT_BROWSER_RESTORE: browserRestoreKey("bot-1"),
AGENT_BROWSER_RESTORE_SAVE: "auto", AGENT_BROWSER_ENCRYPTION_KEY: "session-key",
AGENT_BROWSER_CONFIG: expect.stringContaining("omb-managed-config.json"),
AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_HEADED: "0", PATH: "/usr/bin",
AGENT_BROWSER_EXECUTABLE_PATH: "/opt/process-chrome/chrome", ...keptNoGate("bot-1"),
});
for (const env of [{}, { AGENT_BROWSER_EXECUTABLE_PATH: "" }]) {
const explicit = agentBrowserIntegration({ binaryPath: "/x", session: "s", encryptionKey: "k", env });
expect(explicit.env.AGENT_BROWSER_EXECUTABLE_PATH).toBeUndefined();
expect(explicit.env.PRIVATE_WORKSPACE_SECRET).toBeUndefined();
}
});
it("attaches to Chrome only when the caller passes attach explicitly, never from the ambient process environment", () => {
const withoutAttach = agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "session-key", env: { PATH: "/usr/bin" } });
expect(withoutAttach.env).not.toHaveProperty("AGENT_BROWSER_CDP");
expect(withoutAttach.env).not.toHaveProperty("AGENT_BROWSER_AUTO_CONNECT");
const ambientAttempt = agentBrowserIntegration({
binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "session-key",
env: { PATH: "/usr/bin", AGENT_BROWSER_CDP: "https://unrelated-browser.invalid", AGENT_BROWSER_AUTO_CONNECT: "1" },
});
expect(ambientAttempt.env).not.toHaveProperty("AGENT_BROWSER_CDP");
expect(ambientAttempt.env).not.toHaveProperty("AGENT_BROWSER_AUTO_CONNECT");
const attached = agentBrowserIntegration({
binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "session-key",
env: { PATH: "/usr/bin" }, attach: { cdpUrl: "http://127.0.0.1:9333" },
});
expect(attached.env.AGENT_BROWSER_CDP).toBe("http://127.0.0.1:9333");
expect(attached.env).not.toHaveProperty("AGENT_BROWSER_AUTO_CONNECT");
});
it("My Chrome finds the person's running Chrome, and never restores into it or saves its cookies", () => {
const spec = agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "work", encryptionKey: "session-key", env: { PATH: "/usr/bin" }, attach: {} });
expect(spec.args).toEqual(["mcp", "--tools", "core", "--no-webmcp"]);
expect(spec.env).toMatchObject({ AGENT_BROWSER_SESSION: "work", AGENT_BROWSER_AUTO_CONNECT: "1", AGENT_BROWSER_RESTORE_SAVE: "never", AGENT_BROWSER_NO_WEBMCP: "1" });
for (const name of ["AGENT_BROWSER_RESTORE", "AGENT_BROWSER_CDP", "AGENT_BROWSER_HEADLESS", "AGENT_BROWSER_HEADED"]) expect(spec.env).not.toHaveProperty(name);
expect(agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "work", encryptionKey: "session-key", env: { PATH: "/usr/bin" } }).env)
.toMatchObject({ AGENT_BROWSER_RESTORE: browserRestoreKey("work"), AGENT_BROWSER_RESTORE_SAVE: "auto", AGENT_BROWSER_HEADLESS: "1" });
});
it("mounts agent-browser's MCP server with the core tools, an isolated auto-restored session, and WebMCP off", () => {
const spec = agentBrowserIntegration({ binaryPath: "/x/agent-browser", session: "bot-1", encryptionKey: "k".repeat(64), env: { PATH: "/usr/bin" } });
expect(spec.command).toBe("/x/agent-browser");
expect(spec.args).toEqual(["mcp", "--tools", "core", "--no-webmcp"]);
expect(spec.env).toMatchObject({ AGENT_BROWSER_SESSION: "bot-1", AGENT_BROWSER_RESTORE: browserRestoreKey("bot-1"), AGENT_BROWSER_RESTORE_SAVE: "auto", AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_HEADED: "0", PATH: "/usr/bin" });
expect(spec.env.AGENT_BROWSER_ENCRYPTION_KEY).toBe("k".repeat(64));
expect(agentBrowserIntegration({ binaryPath: "/x", session: "s", encryptionKey: "k", headless: false, platform: "darwin" }).env.AGENT_BROWSER_HEADLESS).toBeUndefined();
});
it("pins headless on a displayless Linux host, even for a headed-capable caller (#1383)", () => {
const linux = { binaryPath: "/x/agent-browser", session: "s", encryptionKey: "k", platform: "linux" as const, env: { PATH: "/usr/bin" } };
expect(agentBrowserIntegration(linux).env).toMatchObject({ AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_HEADED: "0" });
expect(agentBrowserIntegration({ ...linux, headless: false }).env).toMatchObject({ AGENT_BROWSER_HEADLESS: "1", AGENT_BROWSER_HEADED: "0" });
expect(agentBrowserIntegration({ ...linux, headless: false, env: { PATH: "/usr/bin", DISPLAY: ":0" } }).env.AGENT_BROWSER_HEADED).toBeUndefined();
expect(agentBrowserIntegration({ ...linux, headless: false, env: { PATH: "/usr/bin", WAYLAND_DISPLAY: "wayland-0" } }).env.AGENT_BROWSER_HEADED).toBeUndefined();
expect(agentBrowserIntegration({ ...linux, platform: "darwin", headless: false }).env.AGENT_BROWSER_HEADED).toBeUndefined();
});
it("keeps saved state separate for different bots and never saves guest state", () => {
const spec = (session: string, persistent = true) => agentBrowserIntegration({ binaryPath: "/x", session, encryptionKey: "k", persistent });
expect(spec("bot-a").env.AGENT_BROWSER_RESTORE).not.toBe(spec("bot-b").env.AGENT_BROWSER_RESTORE);
const first = browserSessionId("bot-a", "guest");
const second = browserSessionId("bot-a", "guest");
expect(first).toMatch(/^guest-[a-f0-9-]+$/u);
expect(first).not.toBe(second);
expect(spec(first, false).env).toMatchObject({ AGENT_BROWSER_RESTORE: browserRestoreKey(first), AGENT_BROWSER_RESTORE_SAVE: "never" });
});
it("names a temporary browser after its data dir, so one a crash left behind is still recognisably its own", () => {
const session = browserSessionId("bot-a", "guest", "/data/one");
expect(session).toMatch(/^guest-[a-f0-9]{8}-[a-f0-9]{16}$/u);
expect(isTemporaryBrowserSession(session, "/data/one")).toBe(true);
expect(isTemporaryBrowserSession(session, "/data/two")).toBe(false);
expect(isTemporaryBrowserSession(browserSessionId("bot-a", "", "/data/one"), "/data/one")).toBe(false);
// No longer than the uuid form it replaced: agent-browser's socket path is capped.
expect(session.length).toBeLessThanOrEqual(42);
});
it("names sessions after the shared profile, else the bot, in shell-safe form", () => {
expect(browserSessionId("bot-a", "")).toBe("bot-bot-a");
expect(browserSessionId("bot-a", "work partition/1")).toBe("work_partition_1");
expect(browserSessionId("b", "x".repeat(200))).toHaveLength(96);
});
it("makes one encryption key per data dir, private, and reuses it", () => {
const dataDir = mkdtempSync(join(tmpdir(), "omb-engine-key-"));
scratch.push(dataDir);
const key = browserEngineEncryptionKey(dataDir);
expect(key).toMatch(/^[0-9a-f]{64}$/u);
expect(browserEngineEncryptionKey(dataDir)).toBe(key);
if (posix) expect(statSync(join(dataDir, "browser-engine-key")).mode & 0o777).toBe(0o600);
writeFileSync(join(dataDir, "browser-engine-key"), "garbage\n");
const fresh = browserEngineEncryptionKey(dataDir);
expect(fresh).toMatch(/^[0-9a-f]{64}$/u);
expect(fresh).not.toBe(key);
mkdirSync(join(dataDir, "unused"));
});
});