// Real processes stand in for agent-browser's: node with Chrome's telltale // arguments, in a process group of its own, with the environment a daemon // hands its Chrome. import { execFile, execFileSync, spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { browserProcessesRunning, browserProcessTree, orphanedBrowsers, releaseBrowserProfile, stopBrowserProcesses } from "./browser-processes.ts"; import { removeTempDir } from "./testing/cleanup.ts"; // The real tools, except where a test stands in for Windows' tasklist and taskkill. vi.mock("node:child_process", async (original) => { const actual = await original(); return { ...actual, execFile: vi.fn(actual.execFile) }; }); const posix = process.platform !== "win32"; const groups: number[] = []; const homes: string[] = []; afterEach(async () => { for (const group of groups.splice(0)) { try { process.kill(-group, "SIGKILL"); } catch {} } for (const home of homes.splice(0)) await removeTempDir(home); }); const IDLE = "setInterval(() => {}, 1000)"; /** Chrome as agent-browser starts it: its own group, a temporary profile, * a DevTools port, and the daemon's environment. */ const chromeArgs = (home: string, profile = join(home, "agent-browser-chrome-0000")) => ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile}`]; /** agent-browser's own temporary profile folder, with something in it. */ function profileFolder(home: string, name = `agent-browser-chrome-${randomUUID()}`): string { const folder = join(home, name); mkdirSync(join(folder, "Default"), { recursive: true }); writeFileSync(join(folder, "Default", "Cookies"), "fixture"); return folder; } const daemonEnv = (home: string, session: string) => ({ PATH: process.env.PATH, HOME: home, AGENT_BROWSER_DAEMON: "1", AGENT_BROWSER_SESSION: session }); const gone = (pid: number) => { try { return execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim().startsWith("Z"); } catch { return true; } }; function start(command: string, args: string[], env: NodeJS.ProcessEnv): ChildProcess { const child = spawn(command, args, { detached: true, stdio: "ignore", env }); child.unref(); groups.push(child.pid!); return child; } describe.skipIf(!posix)("browsers whose engine was killed under them", () => { it("finds a Chrome of a session whose daemon is gone, by the session its environment names, and stops its group", async () => { const home = mkdtempSync(join(tmpdir(), "omb-orphan-")); homes.push(home); // Its parent is this test, not a daemon: as good as re-parented to init. const profile = profileFolder(home); const lookalikeProfile = profileFolder(home, "agent-browser-chrome-0000"); const orphan = start(process.execPath, chromeArgs(home, profile), daemonEnv(home, "bot-orphan")); // One still under its daemon (in a group of its own, as agent-browser // starts Chrome) is not an orphan, nor is one of another HOME. const servedPid = join(home, "served.pid"); const daemon = start(process.execPath, ["-e", `const served = require('node:child_process').spawn(process.execPath, ${JSON.stringify(chromeArgs(home))}, { stdio: 'ignore', detached: true }); require('node:fs').writeFileSync(${JSON.stringify(servedPid)}, String(served.pid)); ${IDLE}`], daemonEnv(home, "bot-served")); const elsewhere = start(process.execPath, chromeArgs(home), daemonEnv(join(home, "other"), "bot-elsewhere")); // A process with the arguments and a session's name that no daemon // started (no AGENT_BROWSER_DAEMON) is not one either. const lookalike = start(process.execPath, chromeArgs(home), { PATH: process.env.PATH, HOME: home, AGENT_BROWSER_SESSION: "bot-lookalike" }); await vi.waitFor(() => expect(existsSync(servedPid) && readFileSync(servedPid, "utf8")).toBeTruthy()); const served = Number(readFileSync(servedPid, "utf8")); groups.push(served); await vi.waitFor(async () => expect((await orphanedBrowsers(home)).map((found) => found.session)).toEqual(["bot-orphan"]), { timeout: 5_000 }); const [found] = await orphanedBrowsers(home); expect(found!.tree.groups).toEqual([orphan.pid]); expect(await stopBrowserProcesses(found!.tree)).toBe(true); expect(gone(orphan.pid!)).toBe(true); expect(await browserProcessesRunning(found!.tree)).toBe(false); for (const other of [daemon.pid!, served, elsewhere.pid!, lookalike.pid!]) expect(gone(other)).toBe(false); expect(existsSync(profile)).toBe(false); expect(existsSync(lookalikeProfile)).toBe(true); }); it("stops a helper the browser started in its group, with the browser", async () => { const home = mkdtempSync(join(tmpdir(), "omb-orphan-")); homes.push(home); const helperPid = join(home, "helper.pid"); // Not named the way agent-browser names its own profile folders: kept. const kept = profileFolder(home, "agent-browser-chrome-1111"); const orphan = start(process.execPath, ["-e", `const helper = require('node:child_process').spawn('sleep', ['30'], { stdio: 'ignore' }); require('node:fs').writeFileSync(${JSON.stringify(helperPid)}, String(helper.pid)); ${IDLE}`, "--", "--remote-debugging-port=0", `--user-data-dir=${join(home, "agent-browser-chrome-1111")}`], daemonEnv(home, "bot-helper")); await vi.waitFor(() => expect(existsSync(helperPid) && readFileSync(helperPid, "utf8")).toBeTruthy()); const helper = Number(readFileSync(helperPid, "utf8")); const [found] = await orphanedBrowsers(home); expect(found?.session).toBe("bot-helper"); expect(await stopBrowserProcesses(found!.tree)).toBe(true); expect(gone(orphan.pid!)).toBe(true); expect(gone(helper)).toBe(true); expect(existsSync(kept)).toBe(true); }); it("never counts or stops a process that took the number of one of the browser's", async () => { const stranger = start("sleep", ["30"], { PATH: process.env.PATH }); const tree = { daemon: stranger.pid!, members: new Map([[stranger.pid!, "an earlier process"]]), groups: [stranger.pid!] }; expect(await browserProcessesRunning(tree)).toBe(false); expect(await stopBrowserProcesses(tree, 100)).toBe(true); expect(gone(stranger.pid!)).toBe(false); }); it("stops a helper the browser starts while it exits, once the browser is gone", async () => { const home = mkdtempSync(join(tmpdir(), "omb-orphan-")); homes.push(home); const latePid = join(home, "late.pid"); const ready = join(home, "ready"); const orphan = start(process.execPath, ["-e", `process.on("SIGTERM", () => { const late = require("node:child_process").spawn("sleep", ["30"], { stdio: "ignore" }); require("node:fs").writeFileSync(${JSON.stringify(latePid)}, String(late.pid)); process.exit(0); }); require("node:fs").writeFileSync(${JSON.stringify(ready)}, ""); ${IDLE}`, "--", "--remote-debugging-port=0", `--user-data-dir=${profileFolder(home)}`], daemonEnv(home, "bot-late")); // Its command line and environment, which find it, exist from the start; // a SIGTERM before node has run the script above would simply kill it. await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5_000 }); await vi.waitFor(async () => expect((await orphanedBrowsers(home)).map((found) => found.session)).toEqual(["bot-late"]), { timeout: 5_000 }); const [found] = await orphanedBrowsers(home); expect(await stopBrowserProcesses(found!.tree)).toBe(true); const late = Number(readFileSync(latePid, "utf8")); expect(gone(orphan.pid!)).toBe(true); expect(gone(late)).toBe(true); }); }); describe.skipIf(!posix)("a saved session's kept profile", () => { /** A kept profile with a sign-in in it, locked the way Chrome locks one: SingletonLock -> "-". */ function keptProfile(lockedBy?: string) { const root = mkdtempSync(join(tmpdir(), "omb-kept-")); homes.push(root); const dir = join(root, "agent-browser-profiles", "0123456789abcdef0123456789abcdef"); mkdirSync(join(dir, "Default"), { recursive: true }); writeFileSync(join(dir, "Default", "Cookies"), "signed in"); if (lockedBy) { symlinkSync(lockedBy, join(dir, "SingletonLock")); symlinkSync(join(root, "socket"), join(dir, "SingletonSocket")); } return { root, dir, locked: () => { try { return lstatSync(join(dir, "SingletonLock")).isSymbolicLink(); } catch { return false; } } }; } const signedIn = (dir: string) => readFileSync(join(dir, "Default", "Cookies"), "utf8") === "signed in"; it("frees a lock that names another computer or a process that is gone, and keeps the sign-ins", async () => { for (const lock of [`another-host.example-${process.pid}`, `${hostname()}-2147483000`, "not-a-lock"]) { const profile = keptProfile(lock); await releaseBrowserProfile(profile.dir); expect(profile.locked()).toBe(false); expect(existsSync(join(profile.dir, "SingletonSocket"))).toBe(false); expect(signedIn(profile.dir)).toBe(true); } }); it("stops a Chrome whose daemon was killed under it, then frees its lock, and keeps the sign-ins", async () => { const profile = keptProfile(); const orphan = start(process.execPath, ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`], daemonEnv(profile.root, "bot-kept")); symlinkSync(`${hostname()}-${orphan.pid}`, join(profile.dir, "SingletonLock")); await vi.waitFor(async () => { await releaseBrowserProfile(profile.dir); expect(gone(orphan.pid!)).toBe(true); }, { timeout: 10_000 }); expect(profile.locked()).toBe(false); expect(signedIn(profile.dir)).toBe(true); }); it("leaves a daemon's own Chrome running with its lock, until that daemon is killed", async () => { const profile = keptProfile(); const servedPid = join(profile.root, "served.pid"); const chrome = ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`]; const daemon = start(process.execPath, ["-e", `const served = require('node:child_process').spawn(process.execPath, ${JSON.stringify(chrome)}, { stdio: 'ignore', detached: true }); require('node:fs').writeFileSync(${JSON.stringify(servedPid)}, String(served.pid)); ${IDLE}`], daemonEnv(profile.root, "bot-kept")); await vi.waitFor(() => expect(existsSync(servedPid) && readFileSync(servedPid, "utf8")).toBeTruthy()); const served = Number(readFileSync(servedPid, "utf8")); groups.push(served); symlinkSync(`${hostname()}-${served}`, join(profile.dir, "SingletonLock")); await releaseBrowserProfile(profile.dir); await releaseBrowserProfile(profile.dir); expect(profile.locked()).toBe(true); for (const pid of [daemon.pid!, served]) expect(gone(pid)).toBe(false); // Its daemon killed, the same Chrome is now an orphan that would take the next launch. process.kill(daemon.pid!, "SIGKILL"); await vi.waitFor(() => expect(gone(daemon.pid!)).toBe(true)); await vi.waitFor(async () => { await releaseBrowserProfile(profile.dir); expect(gone(served)).toBe(true); }, { timeout: 10_000 }); expect(profile.locked()).toBe(false); expect(signedIn(profile.dir)).toBe(true); }); it("finds a kept profile's Chrome whose daemon is gone, and stopping it never deletes the profile", async () => { const profile = keptProfile(); const orphan = start(process.execPath, ["-e", IDLE, "--", "--remote-debugging-port=0", `--user-data-dir=${profile.dir}`], daemonEnv(profile.root, "bot-kept")); const profiles = join(profile.root, "agent-browser-profiles"); expect(await orphanedBrowsers(profile.root)).toEqual([]); await vi.waitFor(async () => expect((await orphanedBrowsers(profile.root, profiles)).map((found) => found.session)).toEqual(["bot-kept"]), { timeout: 5_000 }); const [found] = await orphanedBrowsers(profile.root, profiles); expect(found!.tree.profiles).toEqual([]); expect(await stopBrowserProcesses(found!.tree)).toBe(true); expect(gone(orphan.pid!)).toBe(true); expect(signedIn(profile.dir)).toBe(true); }); }); // Windows soon gives a freed pid to a new process, and tasklist shows no // start time to tell the two apart: the daemon is the image it ran when read. describe("a browser on Windows", () => { const platform = process.platform; const actualExecFile = vi.mocked(execFile).getMockImplementation()!; afterEach(() => { Object.defineProperty(process, "platform", { value: platform, configurable: true }); vi.mocked(execFile).mockImplementation(actualExecFile); }); it("kills the daemon's tree while the daemon runs, and never a program that took its pid or one tasklist cannot vouch for", async () => { // A live process answers for the pid, as whatever Windows gave it to would. const holder = spawn(process.execPath, ["-e", IDLE], { stdio: "ignore" }); try { let image: string | null = "agent-browser.exe"; const commands: string[][] = []; vi.mocked(execFile).mockImplementation(((file: string, args: string[], _options: unknown, done: (error: Error | null, stdout: string) => void) => { commands.push([file, ...args]); if (file === "taskkill") image = ""; // the tree is gone const row = image ? `"${image}","${holder.pid}","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n"; queueMicrotask(() => image === null && file === "tasklist" ? done(new Error("tasklist failed"), "") : done(null, file === "tasklist" ? row : "")); return {} as ChildProcess; }) as unknown as typeof execFile); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); // A pid file naming another program gives no tree to stop. image = "notepad.exe"; expect(await browserProcessTree("/engine/agent-browser.exe", holder.pid!, "bot-a")).toBeNull(); image = "agent-browser.exe"; const tree = (await browserProcessTree("/engine/agent-browser.exe", holder.pid!, "bot-a"))!; expect(tree).toMatchObject({ daemon: holder.pid, image: "agent-browser.exe" }); expect(await browserProcessesRunning(tree)).toBe(true); const killed = () => commands.filter(([file]) => file === "taskkill"); // The daemon exited and Windows gave its pid to another program. image = "notepad.exe"; expect(await browserProcessesRunning(tree)).toBe(false); expect(await stopBrowserProcesses(tree)).toBe(true); // tasklist could not say what runs there: nothing is killed, and the // browser is not reported stopped. image = null; expect(await stopBrowserProcesses(tree)).toBe(false); expect(killed()).toEqual([]); image = "agent-browser.exe"; expect(await stopBrowserProcesses(tree)).toBe(true); expect(killed()).toEqual([["taskkill", "/PID", String(holder.pid), "/T", "/F"]]); expect(await browserProcessesRunning(tree)).toBe(false); } finally { holder.kill("SIGKILL"); } }); it("stops an orphaned Chrome that runs under the configured Chrome's own image name, and only that one", async () => { const root = mkdtempSync(join(tmpdir(), "omb-win-custom-chrome-")); homes.push(root); const dir = join(root, "Profile"); mkdirSync(join(dir, "lockfile"), { recursive: true }); const started = "2026-11-03T09:00:00.0000000Z"; const commands: string[][] = []; vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => { commands.push([file, ...args]); let stdout = ""; if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) stdout = `${started}\r\n`; else if (file === "powershell.exe") { stdout = JSON.stringify([{ ProcessId: 4500, ParentProcessId: 4100, CommandLine: `"C:\\Chrome Dev\\chrome-dev.exe" "--user-data-dir=${dir}"`, Name: "chrome-dev.exe", Started: started, ParentName: null, ParentStarted: null, }]); } else if (file === "taskkill") rmSync(join(dir, "lockfile"), { recursive: true, force: true }); queueMicrotask(() => done(null, stdout)); return {} as ChildProcess; }) as unknown as typeof execFile); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const killed = () => commands.filter(([file]) => file === "taskkill"); // Without the configured Chrome, an image off the list is never stopped. await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe"); expect(killed()).toEqual([]); await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe", "C:\\Chrome Dev\\Chrome-Dev.exe"); expect(killed()).toEqual([["taskkill", "/PID", "4500", "/T", "/F"]]); expect(existsSync(join(dir, "lockfile"))).toBe(false); }); it("gives up after a bounded wait when the stopped Chrome's lock stays held", async () => { const root = mkdtempSync(join(tmpdir(), "omb-win-held-lock-")); homes.push(root); const dir = join(root, "Profile"); mkdirSync(join(dir, "lockfile"), { recursive: true }); const started = "2026-11-03T09:00:00.0000000Z"; const commands: string[][] = []; vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => { commands.push([file, ...args]); let stdout = ""; if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) stdout = `${started}\r\n`; else if (file === "powershell.exe") { stdout = JSON.stringify([{ ProcessId: 4600, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\chrome.exe" "--user-data-dir=${dir}"`, Name: "chrome.exe", Started: started, ParentName: null, ParentStarted: null, }]); } queueMicrotask(() => done(null, stdout)); return {} as ChildProcess; }) as unknown as typeof execFile); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const begun = Date.now(); await releaseBrowserProfile(dir); expect(Date.now() - begun).toBeGreaterThanOrEqual(1_500); expect(commands.filter(([file]) => file === "taskkill")).toEqual([["taskkill", "/PID", "4600", "/T", "/F"]]); expect(existsSync(join(dir, "lockfile"))).toBe(true); }); it("stops a Chrome whose daemon was killed under it, which would make every launch exit with code 21, and keeps a daemon's own", async () => { const root = mkdtempSync(join(tmpdir(), "omb-win-profile-")); homes.push(root); const dir = join(root, "Kept Profile"); mkdirSync(join(dir, "Default"), { recursive: true }); writeFileSync(join(dir, "Default", "Cookies"), "signed in"); writeFileSync(join(dir, "lockfile"), ""); // Windows keeps a running Chrome's lockfile undeletable; a folder by that name, which no system unlinks, stands in for it. const hold = () => mkdirSync(join(dir, "lockfile")); const release = () => rmSync(join(dir, "lockfile"), { recursive: true, force: true }); let parentImage = ""; let parentStarted = ""; let startedNow = ""; const started = "2026-11-03T09:00:00.0000000Z"; // The browser is this test's own live pid, so a Chrome kept under its daemon can be seen still running. const browser = process.pid; const commands: string[][] = []; const chrome = (pid: number, args: string, Name = "chrome.exe") => ({ ProcessId: pid, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\${Name}" ${args}`, Name, Started: started, ParentName: parentImage || null, ParentStarted: parentStarted || null, }); vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }, done: (error: Error | null, stdout: string) => void) => { commands.push([file, ...args]); let stdout = ""; if (file === "powershell.exe" && options.env?.OMB_PROCESS_ID) { expect(options.env.OMB_PROCESS_ID).toBe(String(browser)); stdout = `${startedNow}\r\n`; } else if (file === "powershell.exe") { expect(options.env?.OMB_BROWSER_PROFILE).toBe(dir); stdout = JSON.stringify([ chrome(browser, `"--user-data-dir=${dir.toUpperCase()}" --remote-debugging-port=0`), chrome(4201, `--type=renderer "--user-data-dir=${dir}"`), chrome(4300, `"--user-data-dir=${dir} backup" --remote-debugging-port=0`), // Another program naming the profile is never stopped. chrome(4400, `"--user-data-dir=${dir}"`, "python.exe"), ]); } else if (file === "tasklist") { stdout = parentImage ? `"${parentImage}","4100","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n"; } else if (file === "taskkill") { expect(args).toEqual(["/PID", String(browser), "/T", "/F"]); release(); } queueMicrotask(() => done(null, stdout)); return {} as ChildProcess; }) as unknown as typeof execFile); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const killed = () => commands.filter(([file]) => file === "taskkill"); const listed = () => commands.filter(([file, ...args]) => file === "powershell.exe" && args.at(-1)!.includes("OMB_BROWSER_PROFILE")).length; try { // No Chrome holds the profile: nothing is listed. await releaseBrowserProfile(dir); expect(commands).toEqual([]); hold(); // Its daemon still runs: the Chrome is kept. parentImage = "agent-browser-win32-x64.exe"; parentStarted = "2026-11-03T08:59:59.0000000Z"; await releaseBrowserProfile(dir); expect(killed()).toEqual([]); // The next turn finds it under the same daemon without listing every process again. await releaseBrowserProfile(dir); expect(listed()).toBe(1); parentImage = "my-engine.exe"; await releaseBrowserProfile(dir, "C:\\Tools\\My-Engine.exe"); expect(killed()).toEqual([]); // Its daemon is gone and Windows gave the pid to a newer agent-browser, but by now the browser's pid runs another // program too: nothing is stopped. parentImage = "agent-browser-win32-x64.exe"; parentStarted = "2026-11-03T09:00:05.0000000Z"; startedNow = "2026-11-03T09:00:07.0000000Z"; await releaseBrowserProfile(dir); expect(killed()).toEqual([]); // The same Chrome still holds the profile: the browser process and its helpers go. startedNow = started; await releaseBrowserProfile(dir); expect(killed()).toEqual([["taskkill", "/PID", String(browser), "/T", "/F"]]); expect(existsSync(join(dir, "lockfile"))).toBe(false); expect(readFileSync(join(dir, "Default", "Cookies"), "utf8")).toBe("signed in"); } finally { release(); } }); it("keeps a daemon's own Chrome that the launcher batch file runs under cmd.exe, and stops it once that shell or daemon is gone", async () => { const root = mkdtempSync(join(tmpdir(), "omb-win-launcher-")); homes.push(root); const dir = join(root, "Kept Profile"); mkdirSync(dir, { recursive: true }); mkdirSync(join(dir, "lockfile")); const started = "2026-11-03T09:00:00.0000000Z"; const browser = process.pid; let daemonRuns = true; let shellRuns = true; let daemonStarted = "2026-11-03T08:59:59.0000000Z"; const commands: string[][] = []; vi.mocked(execFile).mockImplementation(((file: string, args: string[], _options: unknown, done: (error: Error | null, stdout: string) => void) => { commands.push([file, ...args]); let stdout = ""; if (file === "powershell.exe" && args.at(-1)!.includes("OMB_PROCESS_ID")) stdout = `${started}\r\n`; else if (file === "powershell.exe") { stdout = JSON.stringify([{ ProcessId: browser, ParentProcessId: 4100, CommandLine: `"C:\\Chrome\\chrome.exe" "--user-data-dir=${dir}" --remote-debugging-port=0`, Name: "chrome.exe", Started: started, ParentName: shellRuns ? "cmd.exe" : null, ParentStarted: shellRuns ? "2026-11-03T08:59:59.5000000Z" : null, GrandparentId: daemonRuns ? 4000 : null, GrandparentName: daemonRuns ? "agent-browser.exe" : null, GrandparentStarted: daemonRuns ? daemonStarted : null, }]); } else if (file === "tasklist") { const image = daemonRuns && args[1] === "PID eq 4000" ? "agent-browser.exe" : shellRuns && args[1] === "PID eq 4100" ? "cmd.exe" : null; stdout = image ? `"${image}","${args[1].slice(7)}","Console","1","12,345 K"\r\n` : "INFO: No tasks are running which match the specified criteria.\r\n"; } else if (file === "taskkill") rmSync(join(dir, "lockfile"), { recursive: true, force: true }); queueMicrotask(() => done(null, stdout)); return {} as ChildProcess; }) as unknown as typeof execFile); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const engine = "C:\\OpenMausBot\\resources\\browser-engine\\agent-browser.exe"; const launcher = "C:\\Users\\me\\AppData\\Roaming\\openmausbot\\browser\\chrome-launcher.cmd"; const killed = () => commands.filter(([file]) => file === "taskkill"); await releaseBrowserProfile(dir, engine, launcher); expect(killed(), "a live daemon's Chrome is never stopped").toEqual([]); await releaseBrowserProfile(dir, engine, launcher); expect(commands.filter(([file]) => file === "powershell.exe"), "the kept Chrome is not listed again each turn").toHaveLength(1); // agent-browser stopped only the shell: its Chrome still holds the profile under a daemon that runs. shellRuns = false; await releaseBrowserProfile(dir, engine, launcher); expect(killed()).toEqual([["taskkill", "/PID", String(browser), "/T", "/F"]]); expect(existsSync(join(dir, "lockfile"))).toBe(false); mkdirSync(join(dir, "lockfile")); shellRuns = true; daemonRuns = false; await releaseBrowserProfile(dir, engine, launcher); expect(killed()).toHaveLength(2); expect(existsSync(join(dir, "lockfile"))).toBe(false); // Windows gave the dead daemon's pid to a newer agent-browser, started after the shell: that one is not its daemon. mkdirSync(join(dir, "lockfile")); daemonRuns = true; daemonStarted = "2026-11-03T09:00:01.0000000Z"; await releaseBrowserProfile(dir, engine, launcher); expect(killed()).toHaveLength(3); expect(existsSync(join(dir, "lockfile"))).toBe(false); }); it.runIf(process.platform === "win32")("lists a real browser under cmd.exe with its daemon, keeps it for that daemon and stops it for another", async () => { const root = mkdtempSync(join(tmpdir(), "omb-win-cmd-")); homes.push(root); const dir = join(root, "Kept Profile"); mkdirSync(join(dir, "lockfile"), { recursive: true }); const ready = join(root, "ready").replaceAll("\\", "/"); // This test's node is the daemon; the node under cmd.exe names the profile as the bot's Chrome does. const browser = `require('node:fs').writeFileSync('${ready}', ''); ${IDLE}`; const shell = spawn("cmd.exe", ["/d", "/s", "/c", `""${process.execPath}" -e "${browser}" -- "--user-data-dir=${dir}""`], { stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true }); const exited = new Promise((resolve) => shell.once("exit", resolve)); try { await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 15_000 }); const killed = () => vi.mocked(execFile).mock.calls.filter(([file]) => file === "taskkill"); vi.mocked(execFile).mockClear(); const listed = () => vi.mocked(execFile).mock.calls.filter(([file, args]) => file === "powershell.exe" && String((args as string[]).at(-1)).includes("OMB_BROWSER_PROFILE")); await releaseBrowserProfile(dir, process.execPath, process.execPath); await releaseBrowserProfile(dir, process.execPath, process.execPath); expect(killed(), "its daemon's browser is kept").toEqual([]); expect(listed(), "the second turn finds the kept browser without listing again").toHaveLength(1); rmSync(join(dir, "lockfile"), { recursive: true, force: true }); await releaseBrowserProfile(dir); mkdirSync(join(dir, "lockfile")); await releaseBrowserProfile(dir, "C:\\Tools\\agent-browser.exe", process.execPath); expect(killed()).toHaveLength(1); await Promise.race([exited, new Promise((_, reject) => setTimeout(() => reject(new Error("the stopped browser's shell still runs")), 10_000))]); } finally { try { execFileSync("taskkill", ["/PID", String(shell.pid), "/T", "/F"], { stdio: "ignore" }); } catch {} } }, 60_000); });