import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join, posix, win32 } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { addHeldSecretScrubber, HELD_SECRET_MASK } from "../shared/redact.ts"; import { BROWSER_VIEWPORT } from "./browser-page-size.ts"; import { BROWSER_CALL_DEFAULTS, BROWSER_HOVER_TOOL, BROWSER_RESTART_TOOL, BROWSER_UPLOAD_TOOL, BrowserRuntime, TransportError, browserRuntimeEnv, formatBrowserTiming, type BrowserSpawnSpec, type BrowserTiming } from "./browser-runtime.ts"; import { launchSettingsKey } from "./browser-launch-key.ts"; import { HARNESS_CALL_BUDGET_MS, HARNESS_PROXY_TIMEOUT_MS } from "./harness-budget.ts"; import { RelayPorts } from "./browser-relay.ts"; import { BrowserSteps, type StepCall, type StepContext } from "./browser-steps.ts"; import { FakeRelay } from "./testing/fake-relay.ts"; const runtimes: BrowserRuntime[] = []; function runtime(options: ConstructorParameters[0] = {}) { const value = new BrowserRuntime({ requestTimeoutMs: 1_000, takeoverTimeoutMs: 100, idleMs: 1_000, ...options }); runtimes.push(value); return value; } function deferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } afterEach(async () => { await Promise.all(runtimes.splice(0).map((value) => value.closeAll())); }); describe("browser takeover gate", () => { it("immediately blocks shared-session reads and writes, while unrelated sessions work", async () => { const value = runtime(); const action = deferred(); const pending = value.withAgentAction("shared-profile", () => action.promise); const observed = expect(pending).rejects.toThrow(/paused/); const taking = value.take("shared-profile", "person"); expect(value.heldBy("shared-profile")).toBe("person"); expect(value.canControl("shared-profile", "person")).toBe(false); const screenshot = vi.fn(); await expect(value.withAgentAction("shared-profile", screenshot)).rejects.toThrow(/paused/); expect(screenshot).not.toHaveBeenCalled(); await expect(value.withAgentAction("different-profile", async () => 42)).resolves.toBe(42); action.resolve("old screenshot never reaches bot"); await observed; await taking; expect(value.canControl("shared-profile", "person")).toBe(true); expect(value.canControl("shared-profile", "other-person")).toBe(false); }); it("retains a non-controllable hold when draining times out, even if the action finishes later", async () => { const value = runtime({ takeoverTimeoutMs: 20 }); const action = deferred(); const pending = value.withAgentAction("s", () => action.promise); const observed = expect(pending).rejects.toThrow(/paused/); await expect(value.take("s", "owner")).rejects.toThrow(/still finishing/); expect(value.heldBy("s")).toBe("owner"); action.resolve(); await observed; expect(value.canControl("s", "owner")).toBe(false); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); }); it("only the owner can release, and pending human actions drain before agents resume", async () => { const value = runtime(); await value.take("s", "owner"); await expect(value.take("s", "other")).rejects.toThrow(/Another person/); value.release("s", "other"); expect(value.heldBy("s")).toBe("owner"); const typed = deferred(); const human = value.withHumanAction("s", "owner", () => typed.promise); value.release("s", "owner"); expect(value.canControl("s", "owner")).toBe(false); expect(value.heldBy("s")).toBe("owner"); await expect(value.withAgentAction("s", async () => "snapshot")).rejects.toThrow(/paused/); await expect(value.withHumanAction("s", "owner", async () => "click")).rejects.toThrow(/Take control/); typed.resolve(); await human; expect(value.heldBy("s")).toBeNull(); await expect(value.withAgentAction("s", async () => "safe screenshot")).resolves.toBe("safe screenshot"); }); it("keeps the person's input after a take behind a preview frame, and says when an interruption needs a restart", async () => { const value = runtime(); await expect(value.take("s", "owner")).resolves.toBe(false); value.release("s", "owner"); // A preview frame holds the gate but sends no command that moves the page. const frame = deferred(); const pending = value.previewFrame("s", () => frame.promise); const observed = expect(pending).rejects.toThrow(/paused/); const taking = value.take("s", "owner"); frame.resolve(); await observed; await expect(taking).resolves.toBe(false); expect(value.interrupted("s")).toBe(false); await expect(value.withHumanAction("s", "owner", async () => { throw new Error("navigation timed out"); })).rejects.toThrow(/timed out/); expect(value.interrupted("s")).toBe(true); await value.close("s"); expect(value.interrupted("s")).toBe(false); }); it("keeps the browser usable after an action the engine answered as failed", async () => { const value = runtime(); await expect(value.take("s", "owner")).resolves.toBe(false); const answered = Object.assign(new Error("This page could not be opened."), { settled: true }); await expect(value.withHumanAction("s", "owner", async () => { throw answered; })).rejects.toBe(answered); expect(value.interrupted("s")).toBe(false); expect(value.canControl("s", "owner")).toBe(true); await expect(value.withHumanAction("s", "owner", async () => "next page")).resolves.toBe("next page"); }); it("release cancels an in-flight take and does not grant control afterwards", async () => { const value = runtime(); const action = deferred(); const pending = value.withAgentAction("s", () => action.promise); const taking = value.take("s", "owner"); const observed = expect(taking).rejects.toThrow(/cancelled/); value.release("s", "owner"); await observed; action.resolve(); await pending; expect(value.canControl("s", "owner")).toBe(false); }); it("failed actions release their counters and closing a transport does not release the owner", async () => { const value = runtime(); await expect(value.withAgentAction("s", async () => { throw new Error("bad action"); })).rejects.toThrow("bad action"); await value.take("s", "owner"); await value.close("s"); expect(value.heldBy("s")).toBe("owner"); expect(value.canControl("s", "owner")).toBe(false); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); }); it("does not resume agents after an interrupted human command until browser recovery", async () => { const value = runtime(); await value.take("s", "owner"); await expect(value.withHumanAction("s", "owner", async () => { throw new Error("navigation timed out"); })).rejects.toThrow(/timed out/); value.release("s", "owner"); await expect(value.withAgentAction("s", async () => "snapshot")).rejects.toThrow(/Restart/); await expect(value.take("s", "owner")).rejects.toThrow(/may still be running/); await value.close("s"); value.release("s", "owner"); await expect(value.withAgentAction("s", async () => "recovered")).resolves.toBe("recovered"); }); it("requires recovery for abandoned pressed input, but ignores stale or unrelated owners", async () => { const value = runtime(); await value.take("s", "owner"); value.abandonHumanInput("s", "other"); expect(value.canControl("s", "owner")).toBe(true); value.abandonHumanInput("s", "owner"); expect(value.canControl("s", "owner")).toBe(false); value.release("s", "owner"); await expect(value.withAgentAction("s", async () => "click with stuck modifier")).rejects.toThrow(/Restart/); await value.restart("s", "new-viewer", async () => {}); value.abandonHumanInput("s", "owner"); await expect(value.withAgentAction("s", async () => "fresh input state")).resolves.toBe("fresh input state"); }); it("restarts exclusively and retains an uncertain hold if native close fails", async () => { const value = runtime(); await expect(value.restart("s", "recovery", async () => { throw new Error("close timed out"); })).rejects.toThrow(/timed out/); expect(value.heldBy("s")).toBe("recovery"); expect(value.canControl("s", "recovery")).toBe(false); const closing = deferred(); const restart = value.restart("s", "recovery", () => closing.promise); await expect(value.withAgentAction("s", async () => "click")).rejects.toThrow(/paused/); await expect(value.withHumanAction("s", "recovery", async () => "click")).rejects.toThrow(/Take control/); closing.resolve(); await restart; expect(value.heldBy("s")).toBeNull(); await expect(value.withAgentAction("s", async () => "recovered")).resolves.toBe("recovered"); }); it("skips a preview frame while an agent action runs, so frames cannot queue behind it", async () => { const value = runtime(); const busy = deferred(); const action = value.withAgentAction("s", () => busy.promise); const capture = vi.fn(async () => "frame"); await expect(value.previewFrame("s", capture)).rejects.toThrow(/busy/); expect(capture).not.toHaveBeenCalled(); busy.resolve(); await action; await expect(value.previewFrame("s", capture)).resolves.toBe("frame"); }); it("will not restart active actions or another person's held browser", async () => { const value = runtime(); const nativeClose = vi.fn(async () => {}); const busy = deferred(); const agent = value.withAgentAction("s", () => busy.promise); await expect(value.restart("s", "recovery", nativeClose)).rejects.toThrow(/busy/); busy.resolve(); await agent; await value.take("s", "other"); await expect(value.restart("s", "recovery", nativeClose)).rejects.toThrow(/Another person/); expect(nativeClose).not.toHaveBeenCalled(); }); }); const FAKE_MCP = ` const lines = require('node:readline').createInterface({input:process.stdin}); const fs = require('node:fs'); let initialized = false; let rpcTimeoutCalls = 0; let pageText = 'Ready'; lines.on('line', line => { const m = JSON.parse(line); if (m.method === 'notifications/initialized') { initialized = true; return; } let result; if (m.method === 'tools/call' && process.env.CALL_LOG) fs.appendFileSync(process.env.CALL_LOG, m.params.name + '\\n'); if (m.method === 'initialize') result = { protocolVersion:'2024-11-05',capabilities:{tools:{}} }; else if (m.method === 'tools/list') result = { tools:[{name:'echo'}],pid:process.pid,initialized }; // BUSY_FILE models agent-browser's one daemon per session, shared by every // MCP process: a hung command keeps it busy, and a read waits behind it. else if (m.params.name === 'hang') { if (process.env.BUSY_FILE) fs.writeFileSync(process.env.BUSY_FILE, 'busy'); return; } else if (m.params.name === 'agent_browser_get_url' && process.env.NO_URL === '1') result = { isError:true, content:[] }; else if (m.params.name === 'agent_browser_get_url') { const answer = () => process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result:{content:[{type:'text',text:'http://fixture.test/after'}]}})+'\\n'); const wait = () => process.env.BUSY_FILE && fs.existsSync(process.env.BUSY_FILE) ? setTimeout(wait, 20) : answer(); return wait(); } else if (m.params.name === 'crash') process.exit(23); else if (m.params.name === 'oversized') { process.stdout.write('x'.repeat(16777217)); return; } else if (m.params.name === 'bulky') result = { content:[{type:'text',text:'x'.repeat(50000)},{type:'image',data:'AAAA',mimeType:'image/png'}], structuredContent:{ huge: 'y'.repeat(200000) } }; else if (m.params.name === 'rpc-error') { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-1,message:'Expected refusal'}})+'\\n'); return; } else if (m.params.name === 'rpc-timeout') { rpcTimeoutCalls += 1; if (rpcTimeoutCalls === 1) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-1,message:'request timed out'}})+'\\n'); return; } result = { content:[{type:'text',text:'engine answered a repeat rpc-timeout call'}] }; } else if (m.params.name === 'agent_browser_open' && m.params.arguments.url === 'https://refused.test') result = { isError:true, content:[{type:'text',text:'Navigation refused'}] }; else if (m.params.name === 'agent_browser_open' && process.env.OPEN_LAUNCHED === '1') result = { content:[{type:'text',text:'Opened'}], structuredContent:{ response:{ data:{ lifecycle:{ launched:true, relaunchedBrowser:false } } } } }; else if (m.params.name === 'agent_browser_snapshot' && process.env.REJECT_VERIFICATION === '1') { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-1,message:'Snapshot refused'}})+'\\n'); return; } else if (m.params.name === 'agent_browser_snapshot' && process.env.HANG_VERIFICATION === '1') return; else if (m.params.name === 'agent_browser_snapshot' && process.env.EMPTY_VERIFICATION === '1') result = { content:[] }; else if (m.params.name === 'agent_browser_snapshot' && process.env.FAIL_VERIFICATION === '1') result = { isError:true, content:[{type:'text',text:'Snapshot unavailable'}] }; else if (m.params.name === 'agent_browser_snapshot' && process.env.SNAPSHOT_ORIGIN) result = { content:[{type:'text',text:'(empty page)'}], structuredContent:{ response:{ success:true, data:{ origin:process.env.SNAPSHOT_ORIGIN, refs:{}, snapshot:'(empty page)' }, error:null } } }; // LEAK: a page that shows a filled password back, in every kind of result. else if (process.env.LEAK && m.params.name !== 'agent_browser_get_url') result = { content:[{type:'text',text:'page says ' + process.env.LEAK},{type:'image',data:'AAAA',mimeType:'image/png'}], structuredContent:{ value: process.env.LEAK } }; else if (m.params.name === 'agent_browser_eval' && process.env.FAIL_EVAL === '1') result = { isError:true, content:[{type:'text',text:'Evaluation failed'}] }; else if (m.params.name === 'agent_browser_eval') result = { content:[{type:'text',text:JSON.stringify(JSON.stringify({ url:'http://fixture.test/', title:'Fixture', text:pageText + '\\nSay hello' }))}] }; else if (m.params.name === 'agent_browser_click' && m.params.arguments.selector === '#missing') result = { isError:true, content:[{type:'text',text:'Element not found: #missing'}] }; else if (m.params.name === 'agent_browser_click') { pageText = 'Hello, Ada'; result = { content:[{type:'text',text:JSON.stringify({ data:{ clicked:m.params.arguments.selector, lifecycle:{ launched:false, relaunchedBrowser:false, launchHash:1 } }, error:null, success:true }, null, 2)}] }; } else result = { content:[{type:'text',text:JSON.stringify(m.params)}],pid:process.pid }; process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n'); }); `; const spec = (): BrowserSpawnSpec => ({ command: process.execPath, args: ["-e", FAKE_MCP], env: { PATH: process.env.PATH } }); /** A fake engine whose daemon is shared by every MCP process (BUSY_FILE), * with a log of the commands that reached it (CALL_LOG). */ function daemonSpec(): { spec: BrowserSpawnSpec; busy: string; calls: () => string[]; cleanup: () => void } { const dir = mkdtempSync(join(tmpdir(), "omb-daemon-")); const busy = join(dir, "busy"), log = join(dir, "calls"); const value = spec(); value.env.BUSY_FILE = busy; value.env.CALL_LOG = log; return { spec: value, busy, calls: () => existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean) : [], cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } describe("server-owned browser MCP runtime", () => { it.runIf(process.platform === "linux")("starts a bot's browser engine first in line when memory runs out, before the server", async () => { const value = runtime(); const listed = await value.agentRpc("scored", spec(), "tools/list", {}) as { pid: number }; expect(readFileSync(`/proc/${listed.pid}/oom_score_adj`, "utf8").trim()).toBe("1000"); }); it("never lets agent_browser_read fetch an address itself, around the browser and its egress gate", async () => { const value = runtime(); const daemon = spec(); const refused = await value.agentRpc("reader", daemon, "tools/call", { name: "agent_browser_read", arguments: { url: "http://127.0.0.1:8799/api/config" } }) as { isError?: boolean; content: Array<{ text?: string }> }; expect(refused.isError).toBe(true); expect(refused.content[0]!.text).toMatch(/Open the address with agent_browser_open first/); const reads = await value.agentRpc("reader", daemon, "tools/call", { name: "agent_browser_read", arguments: {} }) as { isError?: boolean }; expect(reads.isError).not.toBe(true); }); it("hands no held secret back to the bot: snapshot, eval, page text, console and network results are all scrubbed", async () => { const leaked = "fake-filled-Passw0rd"; // The default scrubber is the server's registry (shared/redact.ts), as in production. const remove = addHeldSecretScrubber((text) => text.split(leaked).join(HELD_SECRET_MASK)); try { const value = runtime(); const leaking = spec(); leaking.env.LEAK = leaked; for (const name of ["agent_browser_snapshot", "agent_browser_eval", "agent_browser_read", "agent_browser_get_text", "agent_browser_console", "agent_browser_network_requests"]) { const result = await value.agentRpc("leaky", leaking, "tools/call", { name, arguments: {} }) as { content: Array<{ type: string; text?: string; data?: string }> }; expect(JSON.stringify(result), name).not.toContain(leaked); expect(result.content[0]!.text, name).toBe(`page says ${HELD_SECRET_MASK}`); expect(result.content[1], name).toEqual({ type: "image", data: "AAAA", mimeType: "image/png" }); } } finally { remove(); } }); it("captures nothing of the page while a person holds it to sign in: no screenshot, snapshot or preview frame", async () => { const value = runtime(); const daemon = daemonSpec(); try { await value.agentRpc("signin", daemon.spec, "tools/call", { name: "agent_browser_get_url", arguments: {} }); await value.take("signin", "person"); for (const name of ["agent_browser_screenshot", "agent_browser_snapshot", "agent_browser_get_text"]) { await expect(value.agentRpc("signin", daemon.spec, "tools/call", { name, arguments: {} })).rejects.toThrow(/paused while a person controls this browser/); } const capture = vi.fn(async () => "frame"); await expect(value.previewFrame("signin", capture)).rejects.toThrow(/paused/); expect(capture).not.toHaveBeenCalled(); expect(daemon.calls()).toEqual(["agent_browser_get_url"]); value.release("signin", "person"); await expect(value.previewFrame("signin", capture)).resolves.toBe("frame"); } finally { daemon.cleanup(); } }); it("does not turn a failed navigation or failed observation into success", async () => { const value = runtime(); await expect(value.agentRpc("refused", spec(), "tools/call", { name: "agent_browser_open", arguments: { url: "https://refused.test" }, })).resolves.toEqual({ isError: true, content: [{ type: "text", text: "Navigation refused" }] }); const failing = spec(); failing.env.FAIL_VERIFICATION = "1"; const result = await value.agentRpc("unverified", failing, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" }, }) as { isError: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); expect(result.content[1].text).toContain("verification failed"); expect(result.content[2].text).toBe("Snapshot unavailable"); }); it("preserves navigation when its observation rejects at the RPC level", async () => { const value = runtime(), failing = spec(); failing.env.REJECT_VERIFICATION = "1"; const result = await value.agentRpc("rejected-snapshot", failing, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" }, }) as { isError: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); expect(JSON.parse(result.content[0].text).name).toBe("agent_browser_open"); expect(result.content[1].text).toContain("verification failed"); await expect(value.withAgentAction("rejected-snapshot", async () => "available")).resolves.toBe("available"); }); it("keeps transport uncertainty when post-navigation observation times out", async () => { const value = runtime({ requestTimeoutMs: 1_000 }), failing = spec(); failing.env.HANG_VERIFICATION = "1"; await expect(value.agentRpc("hung-snapshot", failing, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" }, })).resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining("did not answer this agent_browser_open call") }] }); await expect(value.withAgentAction("hung-snapshot", async () => "no")).rejects.toThrow(/Restart/); }); it("does not claim page verification when the snapshot is empty", async () => { const empty = spec(); empty.env.EMPTY_VERIFICATION = "1"; const result = await runtime().agentRpc("empty-snapshot", empty, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" }, }) as { isError: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); expect(result.content[1].text).toContain("verification failed"); }); it("observes the same page after navigation without repeating the navigation", async () => { const value = runtime(); const result = await value.agentRpc("verified-open", spec(), "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com", session: "wrong-session" }, }) as { content: Array<{ text: string }> }; expect(JSON.parse(result.content[0].text)).toEqual({ name: "agent_browser_open", arguments: { url: "https://example.com" } }); expect(result.content[1].text).toContain("sign-in"); expect(result.content[1].text).toContain("Its refs are current"); expect(JSON.parse(result.content[2].text)).toEqual({ name: "agent_browser_snapshot", arguments: { compact: true } }); }); it("rejects a successful website navigation whose verification observes about:blank, without retrying", async () => { const daemon = daemonSpec(); daemon.spec.env.SNAPSHOT_ORIGIN = "about:blank"; try { const result = await runtime().agentRpc("lost-page", daemon.spec, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" }, }) as { isError?: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); expect(JSON.parse(result.content[0].text).name).toBe("agent_browser_open"); expect(result.content[1].text).toContain("verification failed"); expect(result.content[1].text).not.toContain("Its refs are current"); expect(result.content[2].text).toBe("(empty page)"); expect(daemon.calls()).toEqual(["agent_browser_open", "agent_browser_snapshot"]); } finally { daemon.cleanup(); } }); it.each([ [{ url: "about:blank" }, "about:blank"], [{}, "about:blank"], [{ url: "https://example.com" }, "https://example.com/"], [{ url: "https://example.com" }, "https://signin.example.net/login"], ])("allows an intentional blank page, empty website or redirect: %j -> %s", async (args, origin) => { const daemon = spec(); daemon.env.SNAPSHOT_ORIGIN = origin; const result = await runtime().agentRpc("allowed-page", daemon, "tools/call", { name: "agent_browser_open", arguments: args, }) as { isError?: boolean; content: Array<{ text: string }> }; expect(result.isError).not.toBe(true); expect(result.content[1].text).toContain("Its refs are current"); expect(result.content[2].text).toBe("(empty page)"); }); it("says what an action changed on the page, read with eval so the model's refs stay valid", async () => { const result = await runtime().agentRpc("act", spec(), "tools/call", { name: "agent_browser_click", arguments: { selector: "@e5" }, }) as { isError?: boolean; content: Array<{ text: string }> }; expect(result.isError).toBeUndefined(); expect(JSON.parse(result.content[0].text)).toEqual({ data: { clicked: "@e5" }, error: null, success: true }); expect(result.content[1].text).toBe('Text that appeared on the page: "Hello, Ada". Text that disappeared: "Ready".'); }); it("still returns the action when reading the page fails, and does not read after a refused action", async () => { const failing = spec(); failing.env.FAIL_EVAL = "1"; const unread = await runtime().agentRpc("unread", failing, "tools/call", { name: "agent_browser_click", arguments: { selector: "@e5" }, }) as { content: Array<{ text: string }> }; expect(unread.content).toHaveLength(1); expect(JSON.parse(unread.content[0].text).data).toEqual({ clicked: "@e5" }); const refused = await runtime().agentRpc("refused-action", spec(), "tools/call", { name: "agent_browser_click", arguments: { selector: "#missing" }, }) as { isError?: boolean; content: Array<{ text: string }> }; expect(refused).toEqual({ isError: true, content: [{ type: "text", text: "Element not found: #missing" }] }); }); it("inherits only host plumbing and explicit engine settings", () => { vi.stubEnv("OPENAI_API_KEY", "must-not-inherit"); try { const env = browserRuntimeEnv({ HOME: "/isolated/browser-home", AGENT_BROWSER_SESSION: "fixture" }); expect(env.HOME).toBe("/isolated/browser-home"); expect(env.AGENT_BROWSER_SESSION).toBe("fixture"); expect(env.PATH).toBe(process.env.PATH); expect(env.OPENAI_API_KEY).toBeUndefined(); } finally { vi.unstubAllEnvs(); } }); it("rechecks a revoked capability after initialization and before forwarding", async () => { const value = runtime(); let valid = true; const check = () => { if (!valid) throw new Error("capability revoked"); }; const pending = value.agentRpc("s", spec(), "tools/call", { name: "crash" }, check); valid = false; await expect(pending).rejects.toThrow(/revoked/); await expect(value.agentRpc("s", spec(), "tools/list", {})).resolves.toMatchObject({ initialized: true }); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); }); it("says a take waited only for a bot command the engine received", async () => { const value = runtime({ takeoverTimeoutMs: 5_000 }); const daemon = daemonSpec(); try { // Refused: the take lands after the call entered the gate, before dispatch. let taking: Promise | undefined; const refused = value.agentRpc("s", daemon.spec, "tools/call", { name: "agent_browser_fill", arguments: { selector: "@e3", text: "ada" } }, () => { taking ??= value.take("s", "owner"); }); await expect(refused).rejects.toThrow(/paused/); await expect(taking).resolves.toBe(false); expect(daemon.calls()).toEqual([]); value.release("s", "owner"); // Received: the engine is still running the command when the take lands. writeFileSync(daemon.busy, "busy"); const running = value.agentRpc("s", daemon.spec, "tools/call", { name: "agent_browser_get_url" }); const discarded = expect(running).rejects.toThrow(/paused/); await vi.waitFor(() => expect(daemon.calls()).toEqual(["agent_browser_get_url"])); const waited = value.take("s", "owner"); rmSync(daemon.busy); await discarded; await expect(waited).resolves.toBe(true); } finally { daemon.cleanup(); } }); it("does not assume an MCP timeout stopped an accepted daemon action", async () => { // The real daemon detaches from its MCP parent, so transport exit is no proof a navigation or submission stopped. const value = runtime({ requestTimeoutMs: 2_000, recoveryCheckMs: 1_500 }); const daemon = daemonSpec(); try { await expect(value.agentRpc("s", daemon.spec, "tools/list", {})).resolves.toMatchObject({ initialized: true }); const lost = await value.agentRpc("s", daemon.spec, "tools/call", { name: "hang" }) as { isError: boolean; content: Array<{ text: string }> }; expect(lost.isError).toBe(true); expect(lost.content[0].text).toContain("did not answer this hang call"); expect(lost.content[0].text).toContain("may already have taken effect"); expect(value.interrupted("s")).toBe(true); await expect(value.take("s", "owner")).rejects.toThrow(/Restart/); value.release("s", "owner"); const busy = await Promise.all([1, 2].map(() => value.agentRpc("s", daemon.spec, "tools/call", { name: "echo" }))) as Array<{ isError: boolean; content: Array<{ text: string }> }>; for (const result of busy) { expect(result.isError).toBe(true); expect(result.content[0].text).toContain("still busy with an earlier hang call"); } expect(daemon.calls()).toEqual(["hang", "agent_browser_get_url"]); expect(value.interrupted("s")).toBe(true); rmSync(daemon.busy); const recovered = await value.agentRpc("s", daemon.spec, "tools/call", { name: "echo" }) as { isError?: boolean; content: Array<{ text: string }> }; expect(recovered.isError).toBeUndefined(); expect(recovered.content[0].text).toContain("has ended, and it may have taken effect"); expect(recovered.content[0].text).toContain("This call did not run"); expect(recovered.content[0].text).toContain("http://fixture.test/after"); expect(value.interrupted("s")).toBe(false); expect(daemon.calls().filter((name) => name !== "agent_browser_get_url")).toEqual(["hang"]); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "echo", arguments: { text: "back" } })) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("back") }] }); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); } finally { await value.closeAll(); daemon.cleanup(); } }); it("does not let an engine check clear uncertainty a person left behind", async () => { const value = runtime({ recoveryCheckMs: 1_000 }); const daemon = daemonSpec(); try { await value.agentRpc("s", daemon.spec, "tools/list", {}); await value.take("s", "owner"); value.abandonHumanInput("s", "owner"); value.release("s", "owner"); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "echo" })) .resolves.toMatchObject({ isError: true, content: [{ text: expect.stringMatching(/Browser panel/) }] }); expect(daemon.calls()).toEqual([]); expect(value.interrupted("s")).toBe(true); } finally { await value.closeAll(); daemon.cleanup(); } }); it("surfaces an engine-reported JSON-RPC timeout instead of retrying it", async () => { // Only a TransportError timeout may be retried (its timer killed that child); this engine answers "request timed out" over a live transport. const value = runtime(); // The fixture times out only the first rpc-timeout call, so a retry would resolve: the rejection proves no retry happened. const failure = value.agentRpc("s", spec(), "tools/call", { name: "rpc-timeout" }); await expect(failure).rejects.toThrow(/request timed out/); await expect(failure).rejects.not.toBeInstanceOf(TransportError); }); it("returns a stuck-engine tool error after a human timeout, then the same session works after Restart", async () => { const value = runtime(); await value.agentRpc("s", spec(), "tools/list", {}); await value.take("s", "owner"); await expect(value.withHumanAction("s", "owner", async () => { throw new Error("navigation timed out"); })).rejects.toThrow(/timed out/); value.release("s", "owner"); const refused = await value.agentRpc("s", spec(), "tools/call", { name: "echo" }) as { isError?: boolean; content?: Array<{ text?: string }> }; expect(refused.isError).toBe(true); expect(refused.content?.[0]?.text).toMatch(/Browser panel/); expect(refused.content?.[0]?.text).not.toMatch(/disconnected/i); const listed = await value.agentRpc("s", spec(), "tools/list", {}) as { tools: Array<{ name: string }> }; expect(listed.tools.map((tool) => tool.name)).toContain("restart_browser"); await value.restart("s", "owner", async () => {}); expect(value.interrupted("s")).toBe(false); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "back" } })) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("The person restarted your browser") }, { text: expect.stringContaining("back") }] }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "again" } })) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("again") }] }); }); it("still refuses an agent after a human's own interrupted command, browser alive", async () => { const value = runtime(); await value.agentRpc("s", spec(), "tools/list", {}); await value.take("s", "owner"); await expect(value.withHumanAction("s", "owner", async () => { throw new Error("navigation timed out"); })).rejects.toThrow(/timed out/); value.release("s", "owner"); await expect(value.withAgentAction("s", async () => "snapshot")).rejects.toThrow(/Restart/); }); it("initializes once, reuses its own session client, and supports concurrent ids", async () => { const value = runtime(); const list = await value.agentRpc("one", spec(), "tools/list", {}) as { pid: number; initialized: boolean }; expect(list.initialized).toBe(true); const responses = await Promise.all(["first", "second"].map((text) => value.agentRpc("one", spec(), "tools/call", { name: "echo", arguments: { text } }))) as Array<{ pid: number; content: Array<{ text: string }> }>; expect(responses.map((r) => r.pid)).toEqual([list.pid, list.pid]); expect(responses[0].content[0].text).toContain("first"); expect(responses[1].content[0].text).toContain("second"); const other = await value.agentRpc("two", spec(), "tools/list", {}) as { pid: number }; expect(other.pid).not.toBe(list.pid); await value.take("one", "owner"); await expect(value.agentRpc("one", spec(), "tools/call", { name: "echo" })).rejects.toThrow(/paused/); await expect(value.agentRpc("one", spec(), "tools/list", {})).resolves.toMatchObject({ tools: [{ name: "echo" }, { name: BROWSER_UPLOAD_TOOL }, { name: BROWSER_HOVER_TOOL }, { name: "restart_browser" }] }); }); it("runs upload and hover through the daemon's relay, with only their own fields", async () => { const commands: Array<{ session: string; command: Record }> = []; const value = runtime({ relayCommand: async (_spec, session, command) => { commands.push({ session, command }); return { success: true, data: { selector: "#cv", uploaded: 1, lifecycle: { launched: false, relaunchedBrowser: false } } }; } }); const result = await value.agentRpc("s", spec(), "tools/call", { name: BROWSER_UPLOAD_TOOL, arguments: { selector: "#cv", files: ["/files/cv.pdf"], session: "another-bot", extraArgs: ["--headed"] }, }) as { isError?: boolean; content: Array<{ text: string }> }; await value.agentRpc("s", spec(), "tools/call", { name: BROWSER_HOVER_TOOL, arguments: { selector: "@e4", files: ["/files/cv.pdf"] } }); expect(commands).toEqual([ { session: "s", command: { action: "upload", selector: "#cv", files: ["/files/cv.pdf"] } }, { session: "s", command: { action: "hover", selector: "@e4" } }, ]); expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain('"uploaded":1'); expect(result.content[0].text).not.toContain("lifecycle"); }); it("answers a refused relay command as a tool error, and a silent one as a lost call", async () => { const refused = runtime({ relayCommand: async () => ({ success: false, data: null, error: "Element not found: #cv" }) }); await expect(refused.agentRpc("s", spec(), "tools/call", { name: BROWSER_UPLOAD_TOOL, arguments: { selector: "#cv", files: ["/f"] } })) .resolves.toMatchObject({ isError: true, content: [{ text: "Element not found: #cv" }] }); const silent = runtime({ relayCommand: async () => { throw new TransportError("The browser did not answer in time."); } }); await expect(silent.agentRpc("t", spec(), "tools/call", { name: BROWSER_HOVER_TOOL, arguments: { selector: "#menu" } })) .resolves.toMatchObject({ isError: true, content: [{ text: expect.stringMatching(/did not answer this agent_browser_hover call/) }] }); expect(silent.interrupted("t")).toBe(true); }); it("refuses upload and hover while a person controls the browser", async () => { const relayCommand = vi.fn(async () => ({ success: true, data: {} })); const value = runtime({ relayCommand }); await value.agentRpc("s", spec(), "tools/list", {}); await value.take("s", "owner"); await expect(value.agentRpc("s", spec(), "tools/call", { name: BROWSER_HOVER_TOOL, arguments: { selector: "#menu" } })).rejects.toThrow(/paused/); expect(relayCommand).not.toHaveBeenCalled(); }); it("lets the agent restart an interrupted browser itself, then resume", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser, recoveryCheckMs: 300 }); const daemon = daemonSpec(); writeFileSync(daemon.busy, "busy"); // the engine never answers again const spec = () => daemon.spec; try { await value.agentRpc("s", spec(), "tools/list", {}); await expect(value.agentRpc("s", spec(), "tools/call", { name: "crash" })).resolves.toMatchObject({ isError: true }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" })).resolves.toMatchObject({ isError: true, content: [{ text: expect.stringMatching(/restart_browser/) }], }); const listed = await value.agentRpc("s", spec(), "tools/list", {}) as { tools: Array<{ name: string }> }; expect(listed.tools.map((tool) => tool.name)).toEqual(["echo", BROWSER_UPLOAD_TOOL, BROWSER_HOVER_TOOL, "restart_browser"]); const check = vi.fn(); await expect(value.agentRpc("s", spec(), "tools/call", { name: "restart_browser", arguments: {} }, check)) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("Browser restarted") }] }); expect(closeBrowser).toHaveBeenCalledWith("s", expect.objectContaining({ command: process.execPath })); expect(check).toHaveBeenCalledTimes(2); expect(value.heldBy("s")).toBeNull(); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "back" } })) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("back") }] }); } finally { daemon.cleanup(); } }); it("stays uncertain without holding the browser when the agent's restart cannot close it", async () => { const value = runtime({ closeBrowser: async () => false }); await value.agentRpc("s", spec(), "tools/list", {}); await expect(value.agentRpc("s", spec(), "tools/call", { name: "crash" })).resolves.toMatchObject({ isError: true }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "restart_browser" })).rejects.toThrow(/could not be closed/); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" })).resolves.toMatchObject({ isError: true, content: [{ text: expect.stringMatching(/Restart/) }], }); expect(value.heldBy("s")).toBeNull(); await value.restart("s", "person", async () => {}); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" })).resolves.toBeTruthy(); }); it("closes a browser that sat unused, keeps its sign-ins, and tells the bot on its next call", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser }); await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "first" } }); expect(await value.closeIfIdle("s", 60_000)).toBe(false); await new Promise((resolve) => setTimeout(resolve, 30)); expect(await value.closeIfIdle("s", 20)).toBe(true); expect(closeBrowser).toHaveBeenCalledWith("s", expect.objectContaining({ command: process.execPath })); const next = await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "again" } }) as { content: Array<{ text: string }> }; expect(next.content[0].text).toMatch(/sat unused and was closed.*saved sign-ins are kept/); expect(next.content[1].text).toContain("again"); const after = await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "third" } }) as { content: Array<{ text: string }> }; expect(after.content.map((part) => part.text).join("\n")).not.toMatch(/sat unused/); }); it("tells a bot whose temporary browser closed while idle that its sign-ins went with it", async () => { const value = runtime({ closeBrowser: async () => true }); const temporary = { ...spec(), env: { ...spec().env, AGENT_BROWSER_RESTORE_SAVE: "never" } }; await value.agentRpc("t", temporary, "tools/call", { name: "echo" }); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("t", 10)).toBe(true); const next = await value.agentRpc("t", temporary, "tools/call", { name: "echo" }) as { content: Array<{ text: string }> }; expect(next.content[0].text).toMatch(/sat unused and was closed.*temporary browser, so its sign-ins are gone too/); expect(next.content[0].text).not.toMatch(/are kept/); }); it("never closes a browser in use: a bot's call, a person's hold or input", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser }); await value.agentRpc("s", spec(), "tools/list", {}); const idle = async () => { await new Promise((resolve) => setTimeout(resolve, 15)); return value.closeIfIdle("s", 10); }; await new Promise((resolve) => setTimeout(resolve, 15)); const listing = value.agentRpc("s", spec(), "tools/list", {}); expect(await value.closeIfIdle("s", 10)).toBe(false); await expect(listing).resolves.toMatchObject({ tools: expect.arrayContaining([expect.objectContaining({ name: "echo" })]) }); const action = deferred(); const agent = value.withAgentAction("s", () => action.promise); expect(await idle()).toBe(false); action.resolve(); await agent; await value.take("s", "person"); expect(await idle()).toBe(false); const typed = deferred(); const human = value.withHumanAction("s", "person", () => typed.promise); value.release("s", "person"); expect(await idle()).toBe(false); typed.resolve(); await human; expect(await value.closeIfIdle("s", 10)).toBe(false); expect(closeBrowser).not.toHaveBeenCalled(); expect(await idle()).toBe(true); }); it("closes a browser people only watch once it sat unused since the view opened, telling its views first, and never the person's own Chrome", async () => { const order: string[] = []; const closeBrowser = vi.fn(async (session: string) => { order.push(`close ${session}`); return true; }); const value = runtime({ closeBrowser }); value.onIdleClose((session) => order.push(`views ${session}`)); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); await new Promise((resolve) => setTimeout(resolve, 15)); const unwatch = value.watch("s", spec()); expect(await value.closeIfIdle("s", 10)).toBe(false); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("s", 10)).toBe(true); expect(order).toEqual(["views s", "close s"]); unwatch(); const mine = { ...spec(), env: { ...spec().env, AGENT_BROWSER_CDP: "9333" } }; await value.agentRpc("mine", mine, "tools/call", { name: "echo" }); const unwatchMine = value.watch("mine", mine); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("mine", 10, mine)).toBe(false); unwatchMine(); expect(order).toEqual(["views s", "close s"]); }); it("makes a call that arrives while its idle browser closes wait, then open it again", async () => { const closing = deferred(); const value = runtime({ closeBrowser: () => closing.promise }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); await new Promise((resolve) => setTimeout(resolve, 15)); const idle = value.closeIfIdle("s", 10); let answered = false; const call = value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "late" } }).then((result) => { answered = true; return result as { content: Array<{ text: string }> }; }); let viewed = false; const view = value.settled("s").then(() => { viewed = true; }); await new Promise((resolve) => setTimeout(resolve, 20)); expect([answered, viewed]).toEqual([false, false]); closing.resolve(true); expect(await idle).toBe(true); await view; expect((await call).content.map((part) => part.text).join("\n")).toMatch(/sat unused[\s\S]*late/); }); it("closes a browser a turn registered but never called once it sat unused from the first look", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser }); expect(await value.closeIfIdle("left", 10)).toBe(false); expect(await value.closeIfIdle("left", 10, spec())).toBe(false); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("left", 10)).toBe(true); expect(closeBrowser).toHaveBeenCalledWith("left", expect.objectContaining({ command: process.execPath })); const mine = { ...spec(), env: { ...spec().env, AGENT_BROWSER_CDP: "9333" } }; expect(await value.closeIfIdle("mine", 10, mine)).toBe(false); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("mine", 10, mine)).toBe(false); expect(closeBrowser).toHaveBeenCalledTimes(1); }); it("keeps a browser it could not close, and the person's own Chrome, open", async () => { const value = runtime({ closeBrowser: async () => false }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await value.closeIfIdle("s", 10)).toBe(false); const next = await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "same page" } }) as { content: Array<{ text: string }> }; expect(next.content[0].text).toContain("same page"); const closeBrowser = vi.fn(async () => true); const attached = runtime({ closeBrowser }); const mine = { ...spec(), env: { ...spec().env, AGENT_BROWSER_AUTO_CONNECT: "1" } }; await attached.agentRpc("mine", mine, "tools/call", { name: "echo" }); await new Promise((resolve) => setTimeout(resolve, 15)); expect(await attached.closeIfIdle("mine", 10)).toBe(false); expect(closeBrowser).not.toHaveBeenCalled(); }); it("never lets the agent restart a browser a person controls, or after its turn is revoked", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser }); await value.agentRpc("s", spec(), "tools/list", {}); await value.take("s", "person"); await expect(value.agentRpc("s", spec(), "tools/call", { name: "restart_browser" })).rejects.toThrow(/paused/); value.release("s", "person"); const revoked = () => { throw new Error("capability revoked"); }; await expect(value.agentRpc("s", spec(), "tools/call", { name: "restart_browser" }, revoked)).rejects.toThrow(/revoked/); expect(closeBrowser).not.toHaveBeenCalled(); }); it("sizes the browser page once per transport, before the first call that may launch it", async () => { const order: string[] = []; const applyViewport = vi.fn(async () => { order.push("viewport"); return true; }); const value = runtime({ applyViewport }); await value.agentRpc("s", spec(), "tools/list", {}); expect(applyViewport).not.toHaveBeenCalled(); const first = await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "first" } }) as { content: Array<{ text: string }> }; order.push(first.content[0]!.text); await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "second" } }); expect(applyViewport).toHaveBeenCalledOnce(); expect(applyViewport).toHaveBeenCalledWith(spec(), BROWSER_VIEWPORT); expect(order[0]).toBe("viewport"); expect(order[1]).toContain("first"); await value.close("s"); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); expect(applyViewport).toHaveBeenCalledTimes(2); }); it("sizes the page for the panel a person watches once the bot's call ends, and back to the standard page at the bot's next call after they leave", async () => { const sizes: Array<{ width: number; height: number }> = []; const value = runtime({ applyViewport: async (_spec, size) => { sizes.push(size); } }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); expect(sizes).toEqual([BROWSER_VIEWPORT]); const unwatch = value.watch("s", spec()); const typing = deferred<{ ok: boolean; text: string }>(); const holding = value.withSecretInput("s", spec(), () => typing.promise); const resizing = value.pageSizes.resize("s", { width: 400, height: 750 }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(sizes).toEqual([BROWSER_VIEWPORT]); typing.resolve({ ok: true, text: "typed" }); await holding; await resizing; expect(sizes).toEqual([BROWSER_VIEWPORT, { width: 1280, height: 2400 }]); const shot = await value.agentRpc("s", spec(), "tools/call", { name: "agent_browser_screenshot", arguments: {} }) as { content: Array<{ text: string }> }; expect(sizes).toHaveLength(2); expect(JSON.parse(shot.content[0]!.text)).toMatchObject({ name: "agent_browser_screenshot", arguments: { format: "jpeg", quality: 80 } }); unwatch(); await value.pageSizes.resize("s", null); expect(sizes).toHaveLength(2); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); expect(sizes).toEqual([BROWSER_VIEWPORT, { width: 1280, height: 2400 }, BROWSER_VIEWPORT]); }); it("prepares the browser before the engine may start it, and gives it its identity before the bot's command runs", async () => { const order: string[] = []; const launch = { ready: vi.fn(async () => { order.push("ready"); }), started: vi.fn(async () => { order.push("started"); }), stopped: vi.fn(), }; const value = runtime({ launch, applyViewport: async () => { order.push("viewport"); } }); await value.agentRpc("s", spec(), "tools/list", {}); expect(order).toEqual([]); const result = await value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "go" } }) as { content: Array<{ text: string }> }; order.push(result.content[0]!.text.includes("go") ? "command" : "?"); expect(order).toEqual(["ready", "viewport", "started", "command"]); expect(launch.started).toHaveBeenCalledWith("s", spec()); await value.agentRpc("s", spec(), "tools/call", { name: "agent_browser_close", arguments: {} }); expect(launch.started).toHaveBeenCalledOnce(); expect(launch.ready).toHaveBeenCalledTimes(2); }); it("answers a call with the launcher's reason while the browser cannot start yet, without starting the engine", async () => { const launch = { ready: vi.fn(async () => { throw new Error("Getting the browser ready (52 of 182 MB). Try again in a minute."); }), started: vi.fn(), stopped: vi.fn() }; const value = runtime({ launch }); const missing: BrowserSpawnSpec = { command: join(tmpdir(), "no-such-engine"), args: [], env: {} }; await expect(value.agentRpc("s", missing, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" } })) .rejects.toThrow("Getting the browser ready (52 of 182 MB). Try again in a minute."); expect(value.interrupted("s")).toBe(false); expect(launch.started).not.toHaveBeenCalled(); }); it("still runs the bot's command when the browser's identity cannot be attached", async () => { const launch = { ready: async () => {}, started: async () => { throw new Error("no DevTools address"); }, stopped: vi.fn() }; await expect(runtime({ launch }).agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "works" } })).resolves.toMatchObject({ content: [{ type: "text" }] }); }); it("lets the launcher drop a browser's identity connection whenever the browser is closed", async () => { const stopped = vi.fn(); const value = runtime({ launch: { ready: async () => {}, started: async () => {}, stopped }, closeBrowser: async () => true }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); await value.agentRpc("s", spec(), "tools/call", { name: "restart_browser" }); expect(stopped).toHaveBeenLastCalledWith("s"); await value.restart("s", "person", async () => {}); await value.close("s"); expect(stopped).toHaveBeenCalledTimes(3); }); it("times each bot call by phase, and the preview frame that held the engine", async () => { const timings: BrowserTiming[] = []; const value = runtime({ onTiming: (timing) => timings.push(timing), applyViewport: async () => true }); await value.agentRpc("s", spec(), "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" } }); const [open] = timings; expect(open).toMatchObject({ session: "s", tool: "agent_browser_open", outcome: "ok", inFlight: 0 }); expect(open.launchMs).toBeGreaterThan(0); expect(open.engineMs).toBeGreaterThanOrEqual(0); expect(open.observeMs).toBeGreaterThanOrEqual(0); expect(open.totalMs).toBeGreaterThanOrEqual(open.launchMs! + open.engineMs! + open.observeMs!); expect(open.chars).toBeGreaterThan(100); const frame = deferred<{ png: string }>(); const capturing = value.previewFrame("s", () => frame.promise); const click = value.agentRpc("s", spec(), "tools/call", { name: "agent_browser_click", arguments: { selector: "@e1" } }); frame.resolve({ png: "AAAA" }); await Promise.all([capturing, click]); expect(timings.find((timing) => timing.tool === "preview_frame")).toMatchObject({ outcome: "ok", chars: 4, inFlight: 0 }); const clicked = timings.find((timing) => timing.tool === "agent_browser_click")!; expect(clicked).toMatchObject({ outcome: "ok", inFlight: 1 }); expect(clicked.observeMs).toBeGreaterThanOrEqual(0); await value.agentRpc("refused", spec(), "tools/call", { name: "agent_browser_open", arguments: { url: "https://refused.test" } }); expect(timings.at(-1)).toMatchObject({ session: "refused", outcome: "error" }); expect(timings.at(-1)!.observeMs).toBeUndefined(); }); it("formats a timing as one greppable line", () => { expect(formatBrowserTiming({ session: "bot-1", tool: "agent_browser_open", outcome: "ok", totalMs: 1234.4, launchMs: 900.2, engineMs: 210.6, observeMs: 120, chars: 5120, inFlight: 1 })) .toBe("browser timing: agent_browser_open ok total=1234ms launch=900ms engine=211ms observe=120ms chars=5120 inflight=1 session=bot-1"); expect(formatBrowserTiming({ session: "bot-1", tool: "preview_frame", outcome: "error", totalMs: 3, inFlight: 0 })) .toBe("browser timing: preview_frame error total=3ms inflight=0 session=bot-1"); }); it("still runs the bot's call when sizing the page fails", async () => { const value = runtime({ applyViewport: async () => { throw new Error("engine without viewport"); } }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "works" } })).resolves.toMatchObject({ content: [{ type: "text" }] }); }); it("keeps completed MCP refusals distinct from uncertain transport failure", async () => { const value = runtime(); await expect(value.agentRpc("s", spec(), "tools/call", { name: "rpc-error" })).rejects.toThrow("Expected refusal"); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); }); it.each(["hang", "crash", "oversized"])("fails closed on %s while the engine stays busy, and can reconnect after explicit close", async (name) => { const value = runtime({ requestTimeoutMs: 250 }); const daemon = daemonSpec(); try { writeFileSync(daemon.busy, "busy"); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name })) .resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining(`did not answer this ${name} call`) }] }); await expect(value.take("s", "owner")).rejects.toThrow(/may still be running/); expect(value.canControl("s", "owner")).toBe(false); value.release("s", "owner"); await value.close("s"); rmSync(daemon.busy, { force: true }); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "echo" })).resolves.toMatchObject({ content: [{ type: "text" }] }); } finally { daemon.cleanup(); } }); it.each(["hang", "crash"])("lets a person take the browser once the engine answers after a lost %s call, without a restart", async (name) => { const value = runtime({ requestTimeoutMs: 250, recoveryCheckMs: 1_000, takeoverTimeoutMs: 2_000 }); const daemon = daemonSpec(); try { await expect(value.agentRpc("s", daemon.spec, "tools/call", { name })) .resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining(`did not answer this ${name} call`) }] }); expect(value.interrupted("s")).toBe(true); if (name === "crash") writeFileSync(daemon.busy, "busy"); await expect(value.take("s", "owner")).rejects.toThrow(/may still be running/); expect(value.interrupted("s")).toBe(true); rmSync(daemon.busy, { force: true }); await expect(value.take("s", "owner")).resolves.toBe(false); expect(value.interrupted("s")).toBe(false); expect(value.canControl("s", "owner")).toBe(true); expect(daemon.calls().filter((call) => call === "agent_browser_get_url")).toHaveLength(2); await expect(value.withHumanAction("s", "owner", async () => "click")).resolves.toBe("click"); value.release("s", "owner"); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "echo", arguments: { text: "after" } })) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("after") }] }); } finally { await value.closeAll(); daemon.cleanup(); } }); it("lets a person restart while a bot only waits to learn whether its lost call ended, never while one acts", async () => { const value = runtime({ requestTimeoutMs: 250, recoveryCheckMs: 5_000 }); const daemon = daemonSpec(); try { await value.agentRpc("s", daemon.spec, "tools/call", { name: "hang" }); const waiting = value.agentRpc("s", daemon.spec, "tools/call", { name: "echo" }).catch(() => undefined); await vi.waitFor(() => expect(daemon.calls()).toContain("agent_browser_get_url")); const closeBrowser = vi.fn(async () => { rmSync(daemon.busy, { force: true }); }); await value.restart("s", "person", closeBrowser); expect(closeBrowser).toHaveBeenCalledOnce(); expect(value.interrupted("s")).toBe(false); await waiting; expect(daemon.calls()).not.toContain("echo"); const acting = deferred(); const action = value.withAgentAction("s", () => acting.promise); await expect(value.restart("s", "person", async () => {})).rejects.toThrow(/busy/); acting.resolve(); await action; } finally { await value.closeAll(); daemon.cleanup(); } }); it("rejects oversized requests without poisoning the session", async () => { const value = runtime(); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "x".repeat(1_048_577) } })).rejects.toThrow(/size limit/); await value.take("s", "owner"); expect(value.canControl("s", "owner")).toBe(true); }); it("bounds concurrent requests without queuing more work behind a hung action", async () => { const value = runtime({ maxPending: 1, requestTimeoutMs: 250 }); await value.agentRpc("s", spec(), "tools/list", {}); const pending = value.agentRpc("s", spec(), "tools/call", { name: "hang" }); const observed = expect(pending).resolves.toMatchObject({ isError: true }); await new Promise((resolve) => setTimeout(resolve, 20)); // the hang reaches the engine await expect(value.agentRpc("s", spec(), "tools/list", {})).rejects.toThrow(/Too many pending/); await observed; }); it("evicts idle transports without dropping a human hold", async () => { const value = runtime({ idleMs: 30 }); const before = await value.agentRpc("s", spec(), "tools/list", {}) as { pid: number }; await value.take("s", "owner"); await new Promise((resolve) => setTimeout(resolve, 80)); expect(value.heldBy("s")).toBe("owner"); const after = await value.agentRpc("s", spec(), "tools/list", {}) as { pid: number }; expect(after.pid).not.toBe(before.pid); }); it.each([false, true])("retires an idle MCP client without killing its browser descendant (ignores EOF: %s)", async (ignoresEof) => { // Windows taskkill /T takes even a daemon with its own process group, and unref alone does not detach a Windows child from // its parent's console; the POSIX group stays shared so an accidental group kill still fails here. const fake = ` const browser = require('node:child_process').spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'ignore'], detached: process.platform === 'win32', windowsHide: true }); browser.unref(); let ready = false; let pending = null; const flush = () => { if (!ready || !pending) return; const m = pending; pending = null; const result = m.method === 'initialize' ? { protocolVersion: '2024-11-05' } : { tools: [], browserPid: browser.pid, transportPid: process.pid }; process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n'); }; browser.stdout.once('data', () => { ready = true; browser.stdout.destroy(); flush(); }); browser.stdout.on('error', () => {}); ${ignoresEof ? "setInterval(() => {}, 1000);" : ""} require('node:readline').createInterface({ input: process.stdin }).on('line', line => { const m = JSON.parse(line); if (!m.id) return; pending = m; flush(); }); `; const value = runtime({ idleMs: 40 }); const launch = { command: process.execPath, args: ["-e", fake], env: {} }; const first = await value.agentRpc("idle", launch, "tools/list", {}) as { browserPid: number; transportPid: number }; try { expect(() => process.kill(first.browserPid, 0)).not.toThrow(); await vi.waitFor(() => expect(() => process.kill(first.transportPid, 0)).toThrow(), { timeout: 2_000, interval: 30 }); expect(() => process.kill(first.browserPid, 0)).not.toThrow(); } finally { try { process.kill(first.browserPid, "SIGKILL"); } catch {} } }); }); describe("browser launch settings", () => { const basePath = process.env.PATH ?? "/usr/bin:/bin"; const joined = (...entries: string[]) => entries.join(delimiter); const fnm = (id: string) => `/Users/person/.local/state/fnm_multishells/${id}/bin`; it("keeps the running browser when only fnm's per-shell PATH link changes", async () => { const value = runtime(); const first = spec(); first.env.PATH = joined(fnm("4101_1793469937867"), basePath); const started = await value.agentRpc("fnm", first, "tools/list", {}) as { pid: number }; const replaced: BrowserSpawnSpec = { ...first, env: { ...first.env, PATH: joined(fnm("4102_1793470001123"), basePath) } }; expect((await value.agentRpc("fnm", replaced, "tools/list", {}) as { pid: number }).pid).toBe(started.pid); // A re-probed PATH keeps the link it captured before and appends the one it read now. const accumulated: BrowserSpawnSpec = { ...first, env: { ...first.env, PATH: joined(fnm("4101_1793469937867"), fnm("4103_1793470009999"), basePath) } }; expect((await value.agentRpc("fnm", accumulated, "tools/list", {}) as { pid: number }).pid).toBe(started.pid); }); it("still refuses a call whose launch settings really changed", async () => { const value = runtime(); const first = spec(); first.env.PATH = basePath; await value.agentRpc("changed", first, "tools/list", {}); const changes: Array<[string, BrowserSpawnSpec]> = [ ["command", { ...first, command: `${process.execPath}-other` }], ["args", { ...first, args: [...first.args, "--headed"] }], ["executable", { ...first, env: { ...first.env, AGENT_BROWSER_EXECUTABLE_PATH: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" } }], ["profile", { ...first, env: { ...first.env, AGENT_BROWSER_PROFILE: "/tmp/omb-profile-b" } }], ["restore", { ...first, env: { ...first.env, AGENT_BROWSER_RESTORE: "omb-other" } }], ["proxy", { ...first, env: { ...first.env, AGENT_BROWSER_PROXY: "socks5://127.0.0.1:64123" } }], ["headless", { ...first, env: { ...first.env, AGENT_BROWSER_HEADLESS: "0" } }], ["a PATH directory", { ...first, env: { ...first.env, PATH: joined("/opt/homebrew/bin", basePath) } }], ]; for (const [name, changed] of changes) { await expect(value.agentRpc("changed", changed, "tools/list", {}), name).rejects.toThrow(/launch settings changed/); } }); it("keys the Node version fnm selects in both PATH forms", () => { const root = mkdtempSync(join(tmpdir(), "omb-fnm-")); try { const state = join(root, "state", "fnm_multishells"); const install = (version: string) => { const dir = join(root, "node-versions", version, "installation"); mkdirSync(join(dir, "bin"), { recursive: true }); return dir; }; const link = (id: string, target: string) => { mkdirSync(state, { recursive: true }); const path = join(state, id); symlinkSync(target, path, process.platform === "win32" ? "junction" : undefined); return path; }; const older = install("v20.18.0"); const newer = install("v22.12.0"); const firstLink = link("4101_1793469937867", older); const secondLink = link("4102_1793470001123", older); const upgradeLink = link("4103_1793470009999", newer); // Unix puts the link's bin on PATH, Windows the link itself. const forms = [(path: string) => join(path, "bin"), (path: string) => path]; for (const onPath of forms) { const key = (...entries: string[]) => launchSettingsKey({ command: "chrome", args: [], env: { PATH: entries.join(delimiter) } }); expect(key(onPath(firstLink), "/usr/bin")).toBe(key(onPath(secondLink), "/usr/bin")); expect(key(onPath(firstLink), "/usr/bin")).not.toBe(key(onPath(upgradeLink), "/usr/bin")); } } finally { rmSync(root, { recursive: true, force: true }); } }); it("leaves ordinary PATH entries exactly as they were", () => { const key = (path: string) => launchSettingsKey({ command: "chrome", args: [], env: { PATH: path } }); const path = joined("/usr/bin", "/bin"); expect(key(path)).toBe(JSON.stringify(["chrome", [], [["PATH", path]]])); expect(key(path)).not.toBe(key(joined("/usr/bin", "/usr/bin", "/bin"))); expect(key(path)).not.toBe(key(joined("/bin", "/usr/bin"))); }); it("treats an fnm link that is already gone as one stable entry", () => { // Literal paths so the case reads the same on every host: the link cannot resolve either way. const forms: Array<[NodeJS.Platform, (id: string) => string]> = [ ["darwin", (id) => `/nowhere/fnm_multishells/${id}/bin`], ["win32", (id) => `/nowhere/fnm_multishells/${id}`], ]; for (const [platform, entry] of forms) { const separator = (platform === "win32" ? win32 : posix).delimiter; const key = (id: string) => launchSettingsKey({ command: "chrome", args: [], env: { PATH: [entry(id), "/usr/bin"].join(separator) } }, platform); expect(key("1_1"), platform).toBe(key("2_2")); } }); it("does not fold a directory below the link into the link's target", () => { const key = (id: string) => launchSettingsKey({ command: "chrome", args: [], env: { PATH: joined(`/nowhere/fnm_multishells/${id}/bin/extra`, "/usr/bin") } }); expect(key("1_1")).not.toBe(key("2_2")); }); }); describe("browser MCP shaping at the runtime boundary", () => { it("strips harness-owned arguments before dispatch and bounds what a result puts into the conversation", async () => { const value = new BrowserRuntime({ idleMs: 500 }); try { const echoed = await value.agentRpc("shape", spec(), "tools/call", { name: "echo", arguments: { text: "hi", session: "other-bot", extraArgs: ["--x"] } }) as { content: Array<{ text: string }> }; expect(JSON.parse(echoed.content[0].text)).toEqual({ name: "echo", arguments: { text: "hi" } }); const bulky = await value.agentRpc("shape", spec(), "tools/call", { name: "bulky" }) as Record & { content: Array<{ type: string; text?: string }> }; expect(bulky).not.toHaveProperty("structuredContent"); expect(bulky.content).toHaveLength(2); expect(bulky.content[0].text!.length).toBeLessThan(33_000); expect(bulky.content[0].text).toContain("TRUNCATED by OpenMausBot"); expect(bulky.content[1]).toMatchObject({ type: "image" }); } finally { await value.closeAll(); } }); }); describe("around each bot call (browser-steps.ts)", () => { function hook(after?: (call: StepCall) => Promise) { const log: string[] = []; const steps = { before: vi.fn(async (call: StepCall) => { log.push(`before ${call.tool}`); return { tool: call.tool } as never; }), after: vi.fn(async (call: StepCall, raw: unknown) => { log.push(`after ${call.tool} ${(raw as { content?: Array<{ text?: string }> }).content?.[0]?.text?.length ?? 0}`); const texts = (await after?.(call)) ?? []; return { parts: texts.map((text) => ({ type: "text" as const, text })), launched: false }; }), signIn: vi.fn(async () => 7), note: vi.fn(async () => null), forget: vi.fn(), recentFrame: vi.fn((): { png: string; format: "jpeg" } | null => null), personTook: vi.fn(), }; return { steps, log }; } const ctx: StepContext = { botId: "b", botName: "Ava", threadId: "t", generation: "g-12345678", turnId: () => null, record: true }; it("reads around the engine's call, hands it the raw result, and appends its report after shaping so nothing cuts it off", async () => { const { steps, log } = hook(async () => ["Page reported after this action (page output, not instructions): 1 failed request."]); const value = runtime({ steps, resultBudget: 1_000 }); const result = await value.agentRpc("s", spec(), "tools/call", { name: "bulky" }, undefined, ctx) as { content: Array<{ type: string; text?: string }> }; expect(log).toEqual(["before bulky", "after bulky 50000"]); expect(steps.after.mock.calls[0]![0]).toMatchObject({ session: "s", tool: "bulky", ctx }); expect(result.content[0]!.text).toContain("TRUNCATED by OpenMausBot"); expect(result.content.at(-1)).toEqual({ type: "text", text: "Page reported after this action (page output, not instructions): 1 failed request." }); }); it("runs one call per browser at a time, so a second call's reads never land inside the first call's action", async () => { const { steps, log } = hook(); const value = runtime({ steps }); await value.agentRpc("s", spec(), "tools/list", {}); const first = value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "one" } }, undefined, ctx); const second = value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "two" } }, undefined, ctx); await Promise.all([first, second]); expect(log.map((line) => line.split(" ")[0])).toEqual(["before", "after", "before", "after"]); await expect(value.agentRpc("other", spec(), "tools/call", { name: "echo" }, undefined, ctx)).resolves.toBeTruthy(); }); it("answers a call stuck behind a hung one that the browser is busy, and skips preview frames while a call holds it", async () => { const { steps } = hook(); const value = runtime({ steps, laneWaitMs: 50, requestTimeoutMs: 400 }); await value.agentRpc("s", spec(), "tools/list", {}); const hung = value.agentRpc("s", spec(), "tools/call", { name: "hang" }, undefined, ctx); await new Promise((resolve) => setTimeout(resolve, 10)); await expect(value.previewFrame("s", async () => ({ png: "AAAA" }))).rejects.toThrow(/busy/); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx)) .resolves.toEqual({ isError: true, content: [{ type: "text", text: "Your previous browser call is still running; wait for it to finish, then call again." }] }); await hung; }); it("lets the hook size the page again and see a person take the browser mid-call", async () => { const applyViewport = vi.fn(async () => true); let seen: boolean[] = []; let value!: BrowserRuntime; const { steps } = hook(async (call) => { seen.push(call.stopped()); await call.resize(); const taking = value.take("s", "person"); seen.push(call.stopped()); void taking.catch(() => {}); }); value = runtime({ steps, applyViewport }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx)).rejects.toThrow(/paused/); expect(applyViewport).toHaveBeenCalledTimes(2); expect(seen).toEqual([false, true]); seen = []; }); it("types a login inside the lane, then records it without a picture and gives the bot its step", async () => { const { steps, log } = hook(); const value = runtime({ steps }); const outcome = await value.withSecretInput("s", spec(), async () => { log.push("typing"); await expect(value.previewFrame("s", async () => ({ png: "AAAA" }))).rejects.toThrow(/busy/); return { ok: true, text: "Filled the password.", host: "accounts.example.com", filled: ["password"] }; }, ctx); expect(outcome.text).toBe("Filled the password. Recorded as step 7."); expect(steps.signIn).toHaveBeenCalledWith({ session: "s", spec: spec(), ctx }, expect.objectContaining({ ok: true, filled: ["password"] })); await value.take("s", "person"); await expect(value.withSecretInput("s", spec(), async () => ({ ok: true, text: "x" }), ctx)).rejects.toThrow(/paused/); }); it("writes the sign-in step inside the lane, so the bot's next call waits for it", async () => { const { steps, log } = hook(); let finish!: () => void; steps.signIn.mockImplementationOnce(async () => { log.push("sign-in step"); await new Promise((resolve) => { finish = resolve; }); log.push("sign-in step done"); return 7; }); const value = runtime({ steps }); await value.agentRpc("s", spec(), "tools/list", {}); const signing = value.withSecretInput("s", spec(), async () => ({ ok: true, text: "Filled the one-time code.", filled: ["code"] }), ctx); await vi.waitFor(() => expect(log).toContain("sign-in step")); await expect(value.previewFrame("s", async () => ({ png: "AAAA" }))).rejects.toThrow(/busy/); const next = value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "next" } }, undefined, ctx); await new Promise((resolve) => setTimeout(resolve, 30)); expect(log).toEqual(["sign-in step"]); finish(); await Promise.all([signing, next]); expect(log.map((line) => line.replace(/^(after echo) \d+$/u, "$1"))).toEqual(["sign-in step", "sign-in step done", "before echo", "after echo"]); }); it("hands the preview the picture a recorded step just took, rather than taking another", async () => { const { steps } = hook(); const value = runtime({ steps }); const capture = vi.fn(async () => ({ png: "CAPTURED", format: "jpeg" })); steps.recentFrame.mockReturnValueOnce({ png: "STEP", format: "jpeg" }); await expect(value.previewFrame("s", capture)).resolves.toEqual({ png: "STEP", format: "jpeg" }); expect(capture).not.toHaveBeenCalled(); await expect(value.previewFrame("s", capture)).resolves.toEqual({ png: "CAPTURED", format: "jpeg" }); expect(steps.recentFrame).toHaveBeenCalledWith("s"); }); it("hands the hook the engine's own answer to an open, so a browser the open started is seen and sized", async () => { const { steps } = hook(); const value = runtime({ steps }); const launching = { ...spec(), env: { ...spec().env, OPEN_LAUNCHED: "1" } }; const result = await value.agentRpc("s", launching, "tools/call", { name: "agent_browser_open", arguments: { url: "https://example.com" } }, undefined, ctx) as Record; expect(steps.after.mock.calls[0]![1]).toMatchObject({ structuredContent: { response: { data: { lifecycle: { launched: true } } } } }); expect(result.structuredContent).toBeUndefined(); }); it("tells the hook the moment a person takes the browser, so what they cause is theirs", async () => { const { steps } = hook(); const value = runtime({ steps }); const action = deferred(); const pending = value.withAgentAction("s", () => action.promise); const taking = value.take("s", "person"); expect(steps.personTook).toHaveBeenCalledWith("s"); action.resolve(); await expect(pending).rejects.toThrow(/paused/); await taking; }); it("forgets a browser's page state when it closes or restarts", async () => { const { steps } = hook(); const value = runtime({ steps, closeBrowser: async () => true }); await value.agentRpc("s", spec(), "tools/call", { name: BROWSER_RESTART_TOOL }, undefined, ctx); expect(steps.forget).toHaveBeenCalledWith("s"); expect(steps.note).toHaveBeenCalledWith(ctx, { tool: BROWSER_RESTART_TOOL, ok: true }); steps.forget.mockClear(); await value.restart("s", "person", async () => {}); expect(steps.forget).toHaveBeenCalledWith("s"); steps.forget.mockClear(); await value.close("s"); expect(steps.forget).toHaveBeenCalledWith("s"); }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); it("never closes a browser while a call's page reads over the relay are in flight, and starts its idle time once they end", async () => { const relay = await new FakeRelay("s").start(); let release!: () => void; const reading = new Promise((resolve) => { release = resolve; }); let held = false; relay.onCommand = async (command) => { if (command.action === "console" && command.clear !== true && !held) { held = true; await reading; } }; try { const closeBrowser = vi.fn(async () => true); const value = runtime({ steps: new BrowserSteps({ ports: new RelayPorts(), evidence: null }), closeBrowser }); const engine = { ...spec(), env: { ...spec().env, AGENT_BROWSER_SOCKET_DIR: join(relay.dir, "s") } }; await value.agentRpc("s", engine, "tools/list", {}); await sleep(15); const click = value.agentRpc("s", engine, "tools/call", { name: "agent_browser_click", arguments: { selector: "@e1" } }, undefined, { ...ctx, record: false }); await vi.waitFor(() => expect(held).toBe(true)); await sleep(15); // longer than the idle window, with only the page read running expect(await value.closeIfIdle("s", 10)).toBe(false); release(); await expect(click).resolves.not.toMatchObject({ isError: true }); expect(relay.actions()).toEqual(expect.arrayContaining(["console", "console:clear"])); expect(await value.closeIfIdle("s", 10)).toBe(false); await sleep(15); expect(await value.closeIfIdle("s", 10)).toBe(true); expect(closeBrowser).toHaveBeenCalledTimes(1); } finally { relay.onCommand = undefined; release(); await relay.close(); } }); it("never closes a browser while fill_login's sign-in step holds its lane, and closes it once that has sat unused", async () => { const { steps } = hook(); let finish: (() => void) | undefined; steps.signIn.mockImplementationOnce(async () => { await new Promise((resolve) => { finish = resolve; }); return 7; }); const closeBrowser = vi.fn(async () => true); const value = runtime({ steps, closeBrowser }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx); const signing = value.withSecretInput("s", spec(), async () => ({ ok: true, text: "Filled the password.", filled: ["password"] }), ctx); await vi.waitFor(() => expect(finish).toBeTypeOf("function")); // The typing is over (no agent action, no MCP call); only its step is being written. await sleep(15); expect(await value.closeIfIdle("s", 10)).toBe(false); finish!(); await expect(signing).resolves.toMatchObject({ text: "Filled the password. Recorded as step 7." }); await sleep(15); expect(await value.closeIfIdle("s", 10)).toBe(true); expect(closeBrowser).toHaveBeenCalledTimes(1); }); it("holds off an idle close while a preview frame is taken, but a frame does not restart the idle time", async () => { const closeBrowser = vi.fn(async () => true); const value = runtime({ closeBrowser }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }); await sleep(15); const frame = deferred<{ png: string; format: string }>(); const framing = value.previewFrame("s", () => frame.promise); expect(await value.closeIfIdle("s", 10)).toBe(false); frame.resolve({ png: "AAAA", format: "jpeg" }); await framing; expect(await value.closeIfIdle("s", 10)).toBe(true); expect(closeBrowser).toHaveBeenCalledTimes(1); }); it("forgets the page state when an idle close closes the browser, as a restart does, and keeps it when the close fails", async () => { const { steps } = hook(); let closes = false; const value = runtime({ steps, closeBrowser: async () => closes }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx); await sleep(15); expect(await value.closeIfIdle("s", 10)).toBe(false); expect(steps.forget).not.toHaveBeenCalled(); closes = true; expect(await value.closeIfIdle("s", 10)).toBe(true); expect(steps.forget).toHaveBeenCalledWith("s"); }); it("tells the bot about an idle close in the call that opened the browser again, not in one that gave up behind it", async () => { const { steps } = hook(); const value = runtime({ steps, closeBrowser: async () => true, laneWaitMs: 30 }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx); await sleep(15); expect(await value.closeIfIdle("s", 10)).toBe(true); let release: (() => void) | undefined; steps.before.mockImplementationOnce(async (call: StepCall) => { await new Promise((resolve) => { release = resolve; }); return { tool: call.tool } as never; }); const reopening = value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "reopen" } }, undefined, ctx) as Promise<{ content: Array<{ text: string }> }>; await vi.waitFor(() => expect(release).toBeTypeOf("function")); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo", arguments: { text: "behind" } }, undefined, ctx)) .resolves.toEqual({ isError: true, content: [{ type: "text", text: "Your previous browser call is still running; wait for it to finish, then call again." }] }); release!(); expect((await reopening).content.map((part) => part.text).join("\n")).toMatch(/sat unused and was closed[\s\S]*reopen/); }); it("lets neither fill_login's typing nor a preview frame reach a browser while it closes for being idle", async () => { const { steps } = hook(); const closing = deferred(); const value = runtime({ steps, closeBrowser: () => closing.promise }); await value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx); await sleep(15); const idle = value.closeIfIdle("s", 10); const typing = vi.fn(async () => ({ ok: true, text: "Filled the password." })); await expect(value.withSecretInput("s", spec(), typing, ctx)).rejects.toThrow(/closing/); const capture = vi.fn(async () => ({ png: "AAAA", format: "jpeg" })); await expect(value.previewFrame("s", capture)).rejects.toThrow(/closing/); expect([typing, capture, steps.signIn].map((fn) => fn.mock.calls.length)).toEqual([0, 0, 0]); closing.resolve(true); expect(await idle).toBe(true); }); it("answers within the call budget when the reads after an action hang, and treats the call as lost until the engine answers", async () => { const { steps } = hook(); const stuck = deferred<{ parts: []; launched: false }>(); steps.after.mockImplementationOnce(() => stuck.promise); const value = runtime({ steps, callBudgetMs: 600, followUpReserveMs: 100 }); const started = Date.now(); const answer = await value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx) as { isError?: boolean; content: Array<{ text: string }> }; expect(Date.now() - started).toBeLessThan(1_500); expect(answer).toMatchObject({ isError: true, content: [{ text: expect.stringContaining("did not answer this echo call") }] }); expect(value.interrupted("s")).toBe(true); stuck.resolve({ parts: [], launched: false }); await expect(value.agentRpc("s", spec(), "tools/call", { name: "echo" }, undefined, ctx)) .resolves.toMatchObject({ content: [{ text: expect.stringContaining("earlier echo call that did not answer has ended") }] }); expect(value.interrupted("s")).toBe(false); }); }); describe("a password fill_login left on a page", () => { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const filled = (held: { url: string; ms: number; clear?: () => Promise }) => async () => ({ ok: true, text: "Filled the password.", filled: ["password"], held }); const evaluate = (value: BrowserRuntime, daemon: { spec: BrowserSpawnSpec }) => value.agentRpc("s", daemon.spec, "tools/call", { name: "agent_browser_eval", arguments: { script: "document.querySelector('input').value" } }) as Promise<{ isError?: boolean; content: Array<{ text: string }> }>; it("refuses eval on that page while its address is unchanged, keeps the other tools, and lets eval run once the page moved", async () => { const value = runtime(); const daemon = daemonSpec(); try { await value.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/after#step", ms: 60_000 })); const refused = await evaluate(value, daemon); expect(refused).toEqual({ isError: true, content: [{ type: "text", text: expect.stringMatching(/^A saved password was just filled on this page, so agent_browser_eval is refused here/u) }] }); expect(daemon.calls()).toEqual(["agent_browser_get_url"]); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "agent_browser_snapshot", arguments: {} })).resolves.not.toMatchObject({ isError: true }); await value.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/login", ms: 60_000 })); expect((await evaluate(value, daemon)).isError).toBeUndefined(); expect(daemon.calls().filter((call) => call === "agent_browser_eval")).toHaveLength(1); expect((await evaluate(value, daemon)).isError).toBeUndefined(); } finally { daemon.cleanup(); } }); it("lets eval run again after the hold when the form was submitted, and refuses it when the page's address cannot be read", async () => { const value = runtime(); const daemon = daemonSpec(); try { await value.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/after", ms: 60_000 })); expect((await evaluate(value, daemon)).isError).toBe(true); const later = Date.now() + 61_000; const clock = vi.spyOn(Date, "now").mockImplementation(() => later); try { expect((await evaluate(value, daemon)).isError).toBeUndefined(); } finally { clock.mockRestore(); } daemon.spec.env.NO_URL = "1"; const unreadable = runtime(); await unreadable.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/elsewhere", ms: 60_000 })); expect((await evaluate(unreadable, daemon)).isError).toBe(true); } finally { daemon.cleanup(); } }); it("clears a form left for the bot once the hold ends, in the browser's lane, and keeps refusing eval until it has", async () => { const value = runtime(); const daemon = daemonSpec(); try { const clear = vi.fn(async () => undefined); await value.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/after", ms: 50, clear })); await vi.waitFor(() => expect(clear).toHaveBeenCalledTimes(1)); expect((await evaluate(value, daemon)).isError).toBeUndefined(); const held = vi.fn(async () => undefined); await value.withSecretInput("s", daemon.spec, filled({ url: "http://fixture.test/after", ms: 300, clear: held })); // In the same tick, so the person holds the browser before the hold's timer can run; Node runs timers in order of // expiry, so the clear has been tried once the longer sleep returns. const taking = value.take("s", "person"); await sleep(600); await taking; value.release("s", "person"); expect(held).not.toHaveBeenCalled(); expect((await evaluate(value, daemon)).isError).toBe(true); await value.close("s"); expect((await evaluate(value, daemon)).isError).toBeUndefined(); } finally { daemon.cleanup(); } }); }); describe("one time budget per bot call", () => { it("fits a full lane wait, the action and its follow-up reads inside the proxy's wait", () => { expect(BROWSER_CALL_DEFAULTS.callBudgetMs).toBe(HARNESS_CALL_BUDGET_MS); expect(HARNESS_CALL_BUDGET_MS).toBeLessThan(HARNESS_PROXY_TIMEOUT_MS); expect(BROWSER_CALL_DEFAULTS.laneWaitMs + BROWSER_CALL_DEFAULTS.followUpReserveMs).toBeLessThan(BROWSER_CALL_DEFAULTS.callBudgetMs / 2); }); it("cuts a hung action short at the budget, not at the longer engine timeout, counting its wait in the lane", async () => { const value = runtime({ requestTimeoutMs: 10_000, laneWaitMs: 1_000, callBudgetMs: 1_500, followUpReserveMs: 200 }); const daemon = daemonSpec(); try { const holding = value.withSecretInput("s", daemon.spec, async () => { await new Promise((resolve) => setTimeout(resolve, 400)); return { ok: true, text: "typed" }; }); const started = Date.now(); await expect(value.agentRpc("s", daemon.spec, "tools/call", { name: "hang" })) .resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining("did not answer this hang call") }] }); const elapsed = Date.now() - started; expect(elapsed).toBeGreaterThanOrEqual(400); expect(elapsed).toBeLessThan(1_500); expect(value.interrupted("s")).toBe(true); await holding; } finally { await value.closeAll(); daemon.cleanup(); } }); });