// No real engine, Chrome or user profile: the state file agent-browser saves, and a fake Chrome DevTools socket. import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; import { browserRestoreKey } from "./browser-engine.ts"; import { connectCdp, decryptBrowserState, encryptBrowserState, listSignedInSites, signOutOfSites, signedInSites, type BrowserSitesTarget, type CdpEvent } from "./browser-sites.ts"; import { removeTempDir } from "./testing/cleanup.ts"; const KEY = "a".repeat(64); // Saved by agent-browser 0.37.0 itself (`--restore`, this key) after a page // set cookies on .wikipedia.org, en.wikipedia.org and www.example.com and // localStorage on https://example.com. It proves the at-rest format. const SAVED_BY_ENGINE = Buffer.from([ "v0VwcRCnddysYKlP8Vi4nfVk54WehcGyQcgqAQQq7KjjwF2lh1JU2URYi8akIgVEfbTzxULoojyvlIvCjcy4eoMt9SVqDzpRlk5j", "7z9IYJPB3mN9cuqsnSeq4VeGPYRbxrAiDJ0eRNw9q83hOIbntxq7O/IktkpuLh0UA2XEMwGn4ATvYRKzcSZbbWbVzd5+RiNjpLd4", "LQ1v9AvlgpJq1cp9d4wpYXjgSI2sbSiL64+IRy+/Pj8WjkfaUZgUBW74OT/F5eSv8QGJLmpSf/96lTbj08NK59rhcWVDjsG8fD7Y", "PLQPdzRdzgrcBFvLF5RJhw8B+5CNgYLQEViQhgt7C7Q2up+o1/9fT7/EyNmMs6f5dQI/JQId1nuKMaaU7YCKoUeQS9o3g+q5Clzg", "bD+RbwMlRbAlklnhF4NdGYbGI3KoAUp1H2uqbCVaUw4oEHmipmTJtRrfsmE19Y1M0e4ofvjkkUgsHWSVV46cpY7Gm1mtUPq9EfeF", "zUQNLbfOMRvaQuvIsfUss68w05u2/z6ARoRIxrTCN0f1K5QW8tBNEhgYkWZ2GpsJNmBDUsAvxtquZDezf++Gc1Uxil5NfKRE6OFB", "YeWo3dV3v8a+IOXRpdI0mkX6HZ1qu8orsMssOvB2zJtvl5gG+brsYeOHbkKhIbd8uWidrCJ9bn3IHKINU4H9QH1dRfPU9qvTOOCi", "HYNliv+MjDalXH/NHMPdqAS2pKVubuL2bj50MeJ+QAZcxvbivBuQLdJjdLYziPg1fJlcDA4KTNlD49HpP6eBXB79o4kdAxdse4aK", "xUY101QD3iSFulmEL8GkmfbSlRNcubBXlXnxQ3yU6WOAWBJtSYix7T8RCQw5/A0m7CsBeGWbKtHmUvIkS49ikXgZHnjnj3ahWqTv", "53XJgsMnVuGac3ZRPZXwXx8miWvpkw9p4mQze0TYk/D/ZYpgNxrWTzf5buJqnvnQ96h/5/zyRGkvnECdKlchBIUa7fNxFi6oCic8", "yxkoif+dOBvJvJx5RG4oHgiJhSNxaEdYcS2TFjnjFaVsp9Mv+bbCzZPM8nCaokW2vAO7oVD0sIWyH46Rr7vP6e8C4Z1O4l4NFMZy", "CvpTQWl5FmbWUW6AHhZcdQnM9sjT6voTVjQEW6vVVG4T1slwj3KtdiFxUWEMCJ1axQlDgo+kmXIjOk5d2fpVT4YqtoXOW2pxH/2A", "uA==", ].join(""), "base64"); const scratch: string[] = []; const servers: WebSocketServer[] = []; afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); for (const dir of scratch.splice(0)) await removeTempDir(dir); }); function profile(session = "work") { const home = mkdtempSync(join(tmpdir(), "omb-browser-sites-")); scratch.push(home); const directory = join(home, ".agent-browser", "sessions"); mkdirSync(directory, { recursive: true }); const current = join(directory, `${browserRestoreKey(session)}-${session}.json.enc`); const target: BrowserSitesTarget = { binaryPath: "/fake/agent-browser", session, env: { HOME: home, USERPROFILE: home }, encryptionKey: KEY }; const saved = (path = current) => JSON.parse(decryptBrowserState(readFileSync(path), KEY)); return { home, directory, current, target, saved }; } const stopped = { engine: async () => null }; describe("a profile's signed-in sites, from its saved session", () => { it("lists each site with saved cookies or storage, read from the file agent-browser saved", async () => { const { current, target } = profile(); writeFileSync(current, SAVED_BY_ENGINE); expect(await listSignedInSites(target, stopped)).toEqual({ running: false, sites: [ { site: "example.com", cookies: 1, storage: 1 }, { site: "wikipedia.org", cookies: 2, storage: 0 }, ], }); }); it("reads the spelling a profile used before its restore key while the current one is missing", async () => { const { directory, target } = profile(); writeFileSync(join(directory, "work-work.json.enc"), SAVED_BY_ENGINE); expect((await listSignedInSites(target, stopped)).sites.map((site) => site.site)).toEqual(["example.com", "wikipedia.org"]); }); it("groups hosts under the widest one the browser stores for, never under a public suffix", () => { const cookie = (domain: string) => ({ name: "sid", value: "1", domain, path: "/" }); expect(signedInSites( [cookie(".google.com"), cookie("accounts.google.com"), cookie("mail.google.com"), cookie("www.bbc.co.uk"), cookie("app.example.org"), cookie("127.0.0.1")], [{ origin: "https://docs.google.com", localStorage: [{ name: "a", value: "b" }] }, { origin: "https://empty.example.org", localStorage: [] }], )).toEqual([ { site: "127.0.0.1", cookies: 1, storage: 0 }, { site: "app.example.org", cookies: 1, storage: 0 }, { site: "google.com", cookies: 3, storage: 1 }, { site: "www.bbc.co.uk", cookies: 1, storage: 0 }, ]); }); it("an unreadable file restores nothing, so it lists nothing", async () => { const { current, target } = profile(); writeFileSync(current, encryptBrowserState(JSON.stringify({ cookies: [{ name: "a", value: "b", domain: "x.test", path: "/" }], origins: [] }), "b".repeat(64))); expect(await listSignedInSites(target, stopped)).toEqual({ running: false, sites: [] }); }); }); describe("signing a stopped browser out", () => { it("removes one site's cookies and storage and leaves every other site", async () => { const { current, directory, target, saved } = profile(); writeFileSync(current, SAVED_BY_ENGINE); writeFileSync(join(directory, "work-work.json.enc"), SAVED_BY_ENGINE); writeFileSync(`${current}.previous`, SAVED_BY_ENGINE); const result = await signOutOfSites(target, "wikipedia.org", stopped); expect(result).toEqual({ running: false, sites: [{ site: "example.com", cookies: 1, storage: 1 }] }); for (const path of [current, join(directory, "work-work.json.enc")]) { const state = saved(path); expect(state.cookies.map((cookie: { domain: string }) => cookie.domain)).toEqual(["www.example.com"]); expect(state.origins.map((origin: { origin: string }) => origin.origin)).toEqual(["https://example.com"]); expect(state.cookies[0]).toMatchObject({ name: "sid", value: "s1", path: "/", httpOnly: true, secure: true }); } expect(existsSync(`${current}.previous`)).toBe(false); }); it("signs out of every site in every saved copy", async () => { const { current, directory, target, saved } = profile(); writeFileSync(current, SAVED_BY_ENGINE); writeFileSync(join(directory, "work-work.json"), JSON.stringify({ cookies: [{ name: "old", value: "1", domain: "old.test", path: "/" }], origins: [] })); expect(await signOutOfSites(target, null, stopped)).toEqual({ running: false, sites: [] }); expect(saved()).toEqual({ cookies: [], origins: [] }); expect(JSON.parse(readFileSync(join(directory, "work-work.json"), "utf8"))).toEqual({ cookies: [], origins: [] }); }); it("changes nothing for a site that is not listed, such as a whole top-level domain", async () => { const { current, target } = profile(); writeFileSync(current, SAVED_BY_ENGINE); for (const site of ["org", "com", "nobody.test"]) { expect((await signOutOfSites(target, site, stopped)).sites).toHaveLength(2); expect(readFileSync(current).equals(SAVED_BY_ENGINE)).toBe(true); } }); it("leaves other profiles' saved sessions alone", async () => { const { directory, current, target } = profile(); const other = join(directory, `${browserRestoreKey("work-client")}-work-client.json.enc`); writeFileSync(current, SAVED_BY_ENGINE); writeFileSync(other, SAVED_BY_ENGINE); await signOutOfSites(target, null, stopped); expect(readFileSync(other).equals(SAVED_BY_ENGINE)).toBe(true); }); }); /** A fake running browser: the engine's `state save` and `get cdp-url`, and * a Chrome DevTools endpoint over one cookie jar and per-origin storage. */ async function fakeChrome(cookies: Array>, storage: Record = {}, pages = [{ type: "page", targetId: "page-1", url: "https://en.wikipedia.org/wiki/Main_Page" }, { type: "page", targetId: "page-2", url: "https://www.example.com/" }]) { const jar = [...cookies]; const stored = new Map(Object.entries(storage)); const calls: Array<{ method: string; params: Record; sessionId?: string }> = []; const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); servers.push(server); server.on("connection", (socket) => socket.on("message", (data) => { const { id, method, params = {}, sessionId } = JSON.parse(String(data)); calls.push({ method, params, sessionId }); let result: Record = {}; if (method === "Target.getTargets") result = { targetInfos: [...pages, { type: "service_worker", targetId: "sw" }] }; if (method === "Target.attachToTarget") result = { sessionId: `on-${params.targetId}` }; if (method === "Network.getAllCookies") result = { cookies: jar }; if (method === "Network.deleteCookies") { const index = jar.findIndex((cookie) => cookie.name === params.name && cookie.domain === params.domain && cookie.path === params.path); if (index >= 0) jar.splice(index, 1); } if (method === "Network.clearBrowserCookies") jar.length = 0; if (method === "Storage.clearDataForOrigin") stored.delete(String(params.origin)); socket.send(JSON.stringify({ id, result, ...(sessionId ? { sessionId } : {}) })); })); await new Promise((ready) => server.on("listening", ready)); const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/devtools/browser/fake`; const commands: string[][] = []; const engine = async (_target: BrowserSitesTarget, args: string[]) => { commands.push(args); if (args[0] === "get" && args[1] === "cdp-url") return { cdpUrl: url }; if (args[0] === "state" && args[1] === "save") { const origins = [...stored].map(([origin, value]) => ({ origin, localStorage: [{ name: "token", value }], sessionStorage: [] })); writeFileSync(`${args[2]}.enc`, encryptBrowserState(JSON.stringify({ cookies: jar, origins }), KEY)); return { path: `${args[2]}.enc`, saved: true }; } throw new Error(`unexpected engine command ${args.join(" ")}`); }; return { jar, stored, calls, commands, engine }; } const cookie = (name: string, domain: string) => ({ name, value: "v", domain, path: "/", httpOnly: true, secure: true }); describe("signing a running browser out", () => { it("lists what the running browser holds now, as the engine saves it", async () => { const { current, target } = profile(); writeFileSync(current, SAVED_BY_ENGINE); const chrome = await fakeChrome([cookie("SID", ".google.com"), cookie("sid", "www.example.com")], { "https://mail.google.com": "t" }); expect(await listSignedInSites(target, { engine: chrome.engine })).toEqual({ running: true, sites: [{ site: "google.com", cookies: 1, storage: 1 }, { site: "www.example.com", cookies: 1, storage: 0 }], }); expect(chrome.calls).toEqual([]); }); it("deletes one site's cookies live, clears its storage and tabs, rewrites the saved file, and leaves the rest", async () => { const { current, target, saved } = profile(); writeFileSync(current, SAVED_BY_ENGINE); const chrome = await fakeChrome( [cookie("sid", ".wikipedia.org"), cookie("sid", "en.wikipedia.org"), cookie("sid", "www.example.com"), cookie("SID", ".google.com")], { "https://en.wikipedia.org": "w", "https://example.com": "e" }, ); const result = await signOutOfSites(target, "wikipedia.org", { engine: chrome.engine }); expect(result).toEqual({ running: true, sites: [{ site: "example.com", cookies: 1, storage: 1 }, { site: "google.com", cookies: 1, storage: 0 }] }); expect(chrome.jar.map((entry) => entry.domain)).toEqual(["www.example.com", ".google.com"]); expect(chrome.calls.filter((call) => call.method === "Network.deleteCookies").map((call) => call.params)) .toEqual([{ name: "sid", domain: ".wikipedia.org", path: "/" }, { name: "sid", domain: "en.wikipedia.org", path: "/" }]); expect(chrome.calls.some((call) => call.method === "Network.clearBrowserCookies")).toBe(false); const cleared = chrome.calls.filter((call) => call.method === "Storage.clearDataForOrigin").map((call) => String(call.params.origin)); expect(cleared).toEqual(expect.arrayContaining(["https://wikipedia.org", "https://en.wikipedia.org"])); expect(cleared.some((origin) => origin.includes("example.com"))).toBe(false); expect([...chrome.stored.keys()]).toEqual(["https://example.com"]); expect(chrome.calls.filter((call) => call.method === "Runtime.evaluate").map((call) => call.sessionId)).toEqual(["on-page-1"]); expect(saved().cookies.map((entry: { domain: string }) => entry.domain)).toEqual(["www.example.com"]); }); it("signs out of all: clears every cookie and origin live and empties the saved file", async () => { const { current, target, saved } = profile(); writeFileSync(current, SAVED_BY_ENGINE); const chrome = await fakeChrome([cookie("sid", ".wikipedia.org"), cookie("sid", "www.example.com")], { "https://example.com": "e" }); expect(await signOutOfSites(target, null, { engine: chrome.engine })).toEqual({ running: true, sites: [] }); expect(chrome.calls.filter((call) => call.method === "Network.clearBrowserCookies")).toHaveLength(1); expect(chrome.calls.filter((call) => call.method === "Storage.clearDataForOrigin").map((call) => call.params.origin)) .toEqual(expect.arrayContaining(["https://wikipedia.org", "https://www.example.com", "https://example.com"])); expect(saved()).toEqual({ cookies: [], origins: [] }); }); it("starts a stopped browser with a kept profile to sign it out there too, then stops it again", async () => { const { home, current, target, saved } = profile(); writeFileSync(current, SAVED_BY_ENGINE); const kept = join(home, "agent-browser-profiles", "work"); mkdirSync(kept, { recursive: true }); const chrome = await fakeChrome([cookie("sid", ".wikipedia.org"), cookie("sid", "www.example.com")]); let running = false; const lifecycle: string[] = []; const deps = { engine: async (given: BrowserSitesTarget, args: string[]) => running ? chrome.engine(given, args) : null, start: async () => { lifecycle.push("start"); running = true; return true; }, stop: async () => { lifecycle.push("stop"); running = false; return true; }, }; const withProfile = { ...target, env: { ...target.env, AGENT_BROWSER_PROFILE: kept } }; expect(await listSignedInSites(withProfile, deps)).toMatchObject({ running: false }); expect(lifecycle).toEqual([]); expect(await signOutOfSites(withProfile, "wikipedia.org", deps)).toEqual({ running: false, sites: [{ site: "www.example.com", cookies: 1, storage: 0 }] }); expect(lifecycle).toEqual(["start", "stop"]); expect(chrome.jar.map((entry) => entry.domain)).toEqual(["www.example.com"]); expect(saved().cookies.map((entry: { domain: string }) => entry.domain)).toEqual(["www.example.com"]); await signOutOfSites(target, "example.com", deps); expect(lifecycle).toEqual(["start", "stop"]); }); it("only ever connects to the engine's own Chrome on this machine", async () => { const { target } = profile(); const chrome = await fakeChrome([cookie("sid", "www.example.com")]); const engine = async (_target: BrowserSitesTarget, args: string[]) => args[0] === "get" ? { cdpUrl: "ws://browser.example.net:9222/devtools/browser/x" } : chrome.engine(_target, args); await expect(signOutOfSites(target, null, { engine })).rejects.toThrow("DevTools address"); expect(chrome.calls).toEqual([]); }); }); describe("the DevTools connection", () => { it("hands events to its listener, answers to their callers, and says when the browser closes it", async () => { const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); servers.push(server); server.on("connection", (socket) => socket.on("message", (data) => { const { id, sessionId } = JSON.parse(String(data)); socket.send(JSON.stringify({ method: "Target.attachedToTarget", params: { sessionId: "child", targetInfo: { type: "iframe" } }, sessionId })); socket.send(JSON.stringify({ id, result: { ok: true } })); setTimeout(() => socket.close(), 10); })); await new Promise((ready) => server.on("listening", ready)); const events: CdpEvent[] = []; let closed!: () => void; const gone = new Promise((resolve) => { closed = resolve; }); const cdp = await connectCdp(`ws://127.0.0.1:${(server.address() as { port: number }).port}/devtools/browser/x`, 2_000, { event: (event) => events.push(event), closed }); await expect(cdp.send("Target.setAutoAttach", {}, "page")).resolves.toEqual({ ok: true }); await gone; expect(events).toEqual([{ method: "Target.attachedToTarget", params: { sessionId: "child", targetInfo: { type: "iframe" } }, sessionId: "page" }]); await expect(cdp.send("Browser.getVersion")).rejects.toThrow(); }); });