server/browser-runtime.test.ts
Rama main · réplica de lectura del repositorio original.
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<typeof BrowserRuntime>[0] = {}) {
const value = new BrowserRuntime({ requestTimeoutMs: 1_000, takeoverTimeoutMs: 100, idleMs: 1_000, ...options });
runtimes.push(value);
return value;
}
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((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<string>();
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<boolean> | 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<string, unknown> }> = [];
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<boolean>();
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<string, unknown> & { 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<string[] | void>) {
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<void>((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<string, unknown>;
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<void>((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<void>((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<void>((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<boolean>();
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<void> }) => 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();
}
});
});