#!/usr/bin/env node // Run against an unpacked/signed app's Resources (macOS) or resources directory. // Nothing is installed or downloaded. All browser state belongs to this fixture. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { once } from "node:events"; import { mkdtemp, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; import { inflateSync } from "node:zlib"; import { browserBundlePaths, browserBundleSpec } from "../server/browser-bundle-release.ts"; import { executableTarget } from "./prepare-cloudflared.mjs"; import { WINDOWS_VENDOR_VERSION, verifyVendorCandidate, verifyVendorPatch } from "./build-windows-browser-vendor.mjs"; const { values } = parseArgs({ options: { resources: { type: "string" }, target: { type: "string" }, "engine-candidate": { type: "string" }, "check-only": { type: "boolean", default: false }, help: { type: "boolean" }, } }); if (values.help) { console.log("Usage: node scripts/smoke-browser-bundle.mjs --resources /absolute/app/resources [--target darwin-arm64] [--check-only] [--engine-candidate /absolute/vendor/agent-browser-win32-x64.exe]"); process.exit(0); } assert(values.resources && isAbsolute(values.resources), "--resources must be an absolute packaged Resources/resources directory"); const target = values.target ?? `${process.platform}-${process.arch}`; const spec = browserBundleSpec(target); const paths = browserBundlePaths(join(resolve(values.resources), "browser-engine"), target); const manifest = JSON.parse(await readFile(paths.manifest, "utf8")); assert.equal(manifest.schemaVersion, spec.schemaVersion); assert.equal(manifest.target, target); for (const component of ["engine", "chrome"]) { assert.equal(manifest[component]?.version, spec[component].version); assert.equal(manifest[component]?.executable, spec[component].executable); assert((await stat(paths[component])).isFile(), `Missing bundled ${component}`); } assert((await readdir(paths.licenses)).length > 0, "Missing bundled third-party notices"); assert((await stat(join(paths.directory, spec.chrome.license))).size > 0, "Missing Chromium license"); assert((await stat(join(paths.directory, spec.chrome.about))).size > 0, "Missing Chromium notice"); let enginePath = paths.engine; let engineVersionExpected = spec.engine.version; if (values["engine-candidate"] !== undefined) { // Only the artifact-only vendor workflow uses this explicit mode. Standard // app release calls still discover and execute the bundled, pinned engine. assert.equal(process.platform, "win32", "Windows vendor candidates require a native Windows runner"); assert.equal(target, "win32-x64"); assert(!values["check-only"], "A candidate must execute the native browser checks"); assert(isAbsolute(values["engine-candidate"]), "--engine-candidate must be absolute"); enginePath = resolve(values["engine-candidate"]); const candidateDirectory = dirname(enginePath); const candidateBytes = await readFile(enginePath); verifyVendorCandidate(JSON.parse(await readFile(join(candidateDirectory, "provenance.json"), "utf8")), candidateBytes); verifyVendorPatch(await readFile(join(candidateDirectory, "agent-browser-windows-stdio.patch"))); assert.equal(executableTarget(candidateBytes), "win32-x64"); for (const license of ["agent-browser-LICENSE.txt", "LICENSE-axe-core.txt", "LICENSE-axe-core-THIRD-PARTY.txt"]) { assert.deepEqual(await readFile(join(candidateDirectory, license)), await readFile(join(paths.licenses, license)), `Candidate changed upstream notice ${license}`); } engineVersionExpected = WINDOWS_VENDOR_VERSION; } // Signing changes Mach-O bytes. Source-archive digests are checked before signing; // this gate checks the final layout, versions, and real browser behavior instead. if (values["check-only"]) { console.log(JSON.stringify({ ok: true, target, check: "structure-only", browserExecuted: false })); process.exit(0); } assert.equal(target, `${process.platform}-${process.arch}`, "A live smoke test must run on the target architecture; use --check-only for a structural cross-target check"); assert(process.platform !== "linux" || process.getuid?.() !== 0, "Run this sandboxed browser test as an unprivileged user, never root"); if (process.platform === "linux") { // agent-browser v0.36.0 chrome.rs:1512 automatically adds --no-sandbox for // root, CI, or container markers. We strip CI below; reject containers too // so a passing test actually exercises the Ubuntu browser sandbox. for (const marker of ["/.dockerenv", "/run/.containerenv"]) { const present = await stat(marker).then(() => true, (error) => { if (error.code === "ENOENT") return false; throw error; }); assert(!present, `Sandbox verification requires an unprivileged, non-container Linux host; ${marker} makes upstream agent-browser disable Chromium's sandbox`); } const cgroup = await readFile("/proc/1/cgroup", "utf8").catch((error) => { if (error.code === "ENOENT") return ""; throw error; }); assert(!/docker|kubepods|lxc/.test(cgroup), "Sandbox verification requires a non-container Linux host; upstream agent-browser disables Chromium's sandbox for this cgroup"); } const fixture = await mkdtemp(join(tmpdir(), "omb-browser-smoke-")); const fixtureHome = join(fixture, "home"); const env = { PATH: process.platform === "win32" ? join(process.env.SystemRoot ?? "C:\\Windows", "System32") : "/usr/bin:/bin", HOME: fixtureHome, USERPROFILE: fixtureHome, APPDATA: join(fixtureHome, "AppData", "Roaming"), LOCALAPPDATA: join(fixtureHome, "AppData", "Local"), XDG_CONFIG_HOME: join(fixture, "config"), XDG_CACHE_HOME: join(fixture, "cache"), XDG_DATA_HOME: join(fixture, "data"), XDG_RUNTIME_DIR: join(fixture, "run"), TMPDIR: join(fixture, "tmp"), TMP: join(fixture, "tmp"), TEMP: join(fixture, "tmp"), OMB_DATA_DIR: join(fixture, "omb"), OMB_RESOURCES_PATH: resolve(values.resources), ...(values["engine-candidate"] ? { OMB_AGENT_BROWSER_PATH: enginePath, AGENT_BROWSER_EXECUTABLE_PATH: paths.chrome } : {}), // macOS's per-user temp directory is long; keep Unix socket paths <104 bytes. AGENT_BROWSER_SOCKET_DIR: join(fixture, "s"), AGENT_BROWSER_DEFAULT_TIMEOUT: "15000", LANG: "en_US.UTF-8", NO_COLOR: "1", }; for (const key of ["SystemRoot", "WINDIR", "SYSTEMDRIVE", "COMSPEC", "PATHEXT"]) { if (process.platform === "win32" && process.env[key]) env[key] = process.env[key]; } for (const directory of new Set([fixtureHome, env.APPDATA, env.LOCALAPPDATA, env.XDG_CONFIG_HOME, env.XDG_CACHE_HOME, env.XDG_DATA_HOME, env.XDG_RUNTIME_DIR, env.TMPDIR, env.OMB_DATA_DIR, env.AGENT_BROWSER_SOCKET_DIR])) { await mkdir(directory, { recursive: true, mode: 0o700 }); } // Isolate even source-module initialization. No inherited account credentials, // agent-browser config, external CDP connection, profile, proxy, or launch flags. process.env = env; const children = new Set(); const clients = []; let server; let gate; let report; let failure; let fixtureRequests = 0; let phase = "runtime discovery"; let closeBrowserSession; function child(command, args, childEnv) { const proc = spawn(command, args, { cwd: fixture, env: childEnv, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); children.add(proc); proc.once("close", () => children.delete(proc)); return proc; } async function run(command, args, childEnv, timeout = 30_000) { const proc = child(command, args, childEnv); proc.stdin.end(); let output = ""; let errors = ""; proc.stdout.on("data", (data) => { output = (output + data).slice(-1_000_000); }); proc.stderr.on("data", (data) => { errors = (errors + data).slice(-8_000); }); const timer = setTimeout(() => proc.kill("SIGKILL"), timeout); try { const [code, signal] = await once(proc, "close"); assert.equal(code, 0, `${args.join(" ")} failed (${signal ?? code}): ${errors || output}`); return output.trim(); } finally { clearTimeout(timer); } } function mcp(integration) { const proc = child(integration.command, integration.args, { ...env, ...integration.env }); const pending = new Map(); const lines = createInterface({ input: proc.stdout }); let sequence = 0; let errors = ""; proc.stderr.on("data", (data) => { errors = (errors + data).slice(-8_000); }); const rejectPending = (error) => { for (const request of pending.values()) { clearTimeout(request.timer); request.reject(error); } pending.clear(); }; proc.on("error", rejectPending); // Let close report the engine's stderr instead of an unhandled EPIPE // hiding why the child refused to start. proc.stdin.on("error", (error) => { if (error.code !== "EPIPE") rejectPending(error); }); proc.on("close", () => { lines.close(); rejectPending(new Error(`Browser MCP exited: ${errors}`)); }); lines.on("line", (line) => { let message; try { message = JSON.parse(line); } catch { rejectPending(new Error("Browser MCP emitted invalid JSON")); return; } const request = pending.get(message.id); if (!request) return; pending.delete(message.id); clearTimeout(request.timer); if (message.error) request.reject(new Error(JSON.stringify(message.error))); else request.resolve(message.result); }); const request = (method, params = {}, timeout = 45_000) => new Promise((resolveRequest, reject) => { const id = ++sequence; const label = method === "tools/call" ? params.name : method; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${label} timed out after ${timeout}ms (session ${integration.env.AGENT_BROWSER_SESSION}; fixture HTTP requests ${fixtureRequests}): ${errors}`)); }, timeout); pending.set(id, { resolve: resolveRequest, reject, timer }); proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); }); return { proc, integration, request, async tool(name, args = {}) { const started = Date.now(); phase = `${name} (${integration.env.AGENT_BROWSER_SESSION})`; console.error(`[browser-smoke] ${phase} started`); const result = await request("tools/call", { name, arguments: { ...args, timeoutMs: 30_000 } }); const response = result?.structuredContent?.response; assert(!result?.isError && response?.success === true, `${name} failed: ${JSON.stringify(result).slice(0, 8_000)}`); console.error(`[browser-smoke] ${phase} passed in ${Date.now() - started}ms`); return { data: response.data, content: result.content }; }, }; } async function failureDiagnostics() { console.error(`[browser-smoke] failure during ${phase}; owned fixture received ${fixtureRequests} HTTP requests`); for (const { integration, proc } of clients) { const session = integration.env.AGENT_BROWSER_SESSION; const sidecars = {}; // Only fixed metadata names in our own temporary socket directory. Never // dump daemon config, encryption keys, browser profiles, or parent env. for (const extension of ["pid", "port", "version", "engine"]) { sidecars[extension] = await readFile(join(env.AGENT_BROWSER_SOCKET_DIR, `${session}.${extension}`), "utf8") .then((value) => value.trim().slice(0, 128), (error) => error.code === "ENOENT" ? null : error.code); } let daemonAlive = false; if (/^[1-9][0-9]*$/.test(sidecars.pid ?? "")) { try { process.kill(Number(sidecars.pid), 0); daemonAlive = true; } catch { /* owned daemon already exited */ } } console.error(`[browser-smoke] ${JSON.stringify({ session, mcpPid: proc.pid, mcpExited: proc.exitCode ?? proc.signalCode, daemonAlive, sidecars })}`); if (!daemonAlive) continue; try { // Failure evidence, not a fallback or prewarm: the MCP test still fails. // On Windows vercel-labs/agent-browser#1308 and vercel-labs/agent-browser#1407 // can leave MCP waiting on inherited pipe handles after navigation // succeeded; a read-only command to this fixture session tells them apart. const response = JSON.parse(await run(integration.command, ["--json", "--no-webmcp", "get", "url"], { ...env, ...integration.env }, 8_000)); console.error(`[browser-smoke] existing-session URL diagnostic ${JSON.stringify({ session, success: response.success, url: response.data?.url, error: response.error }).slice(0, 2_000)}`); } catch (error) { console.error(`[browser-smoke] existing-session URL diagnostic failed: ${error.message.slice(0, 2_000)}`); } } } function pngInfo(buffer) { assert(buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), "Screenshot is not PNG"); assert.equal(buffer.toString("ascii", 12, 16), "IHDR"); const width = buffer.readUInt32BE(16); const height = buffer.readUInt32BE(20); assert(width >= 100 && height >= 100 && width <= 16_384 && height <= 16_384, "Invalid screenshot dimensions"); const chunks = []; let ended = false; for (let offset = 8; offset + 12 <= buffer.length;) { const length = buffer.readUInt32BE(offset); assert(offset + length + 12 <= buffer.length, "Truncated PNG"); const type = buffer.toString("ascii", offset + 4, offset + 8); if (type === "IDAT") chunks.push(buffer.subarray(offset + 8, offset + 8 + length)); if (type === "IEND") { ended = true; break; } offset += length + 12; } assert(ended && chunks.length > 0, "Incomplete PNG"); assert(inflateSync(Buffer.concat(chunks), { maxOutputLength: 128 * 1024 * 1024 }).length > 0, "Empty PNG image data"); return { width, height, bytes: buffer.length, sha256: createHash("sha256").update(buffer).digest("hex") }; } async function ownedDaemonPid(client) { const session = client.integration.env.AGENT_BROWSER_SESSION; const value = (await readFile(join(env.AGENT_BROWSER_SOCKET_DIR, `${session}.pid`), "utf8")).trim(); assert(/^[1-9][0-9]*$/.test(value), "Owned browser daemon did not record a valid PID"); return Number(value); } async function waitForOwnedDaemonExit(pid) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { process.kill(pid, 0); } catch (error) { if (error.code === "ESRCH") return; throw error; } await new Promise((done) => setTimeout(done, 25)); } throw new Error(`Owned browser daemon ${pid} did not exit after close`); } const interrupt = () => { for (const proc of children) proc.kill("SIGTERM"); }; process.once("SIGINT", interrupt); process.once("SIGTERM", interrupt); try { const { browserEngineStatus, agentBrowserIntegration, prepareBrowserSessionState, closeBrowserSession: closeSession } = await import("../server/browser-engine.ts"); closeBrowserSession = closeSession; const status = browserEngineStatus({ dataDir: env.OMB_DATA_DIR, env }); assert.equal(status.kind, "ready", `Fresh-home runtime did not discover the bundle: ${JSON.stringify(status)}`); assert.equal(resolve(status.binaryPath), resolve(enginePath), "Runtime did not select the engine under test"); const engineVersion = await run(enginePath, ["--version"], env); const chromeVersion = await run(paths.chrome, ["--version"], env); assert.equal(engineVersion, `agent-browser ${engineVersionExpected}`, `Unexpected engine version: ${engineVersion}`); assert(chromeVersion.includes(spec.chrome.version), `Unexpected Chromium version: ${chromeVersion}`); const title = `OpenMausBot bundled browser ${randomBytes(6).toString("hex")}`; server = createServer((_request, response) => { fixtureRequests += 1; response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${title}

${title}

Waiting

`); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const url = `http://127.0.0.1:${server.address().port}/`; // The bundled Chrome browses through the egress gate, as every bot browser does; desktop rules let it reach this // local page, like a dev server. const { openGate } = await import("../server/browser/egress/gate.ts"); const { refusalFor } = await import("../server/browser/egress/policy.ts"); const rules = { host: "desktop", ownPort: () => false, botPort: async () => false, localNetwork: true, allowed: () => false }; gate = await openGate({ decide: (target, addresses) => refusalFor(target, addresses, rules) }); for (const name of ["alpha", "beta"]) { const integration = agentBrowserIntegration({ binaryPath: status.binaryPath, session: `omb-${name[0]}-${randomBytes(4).toString("hex")}`, encryptionKey: randomBytes(32).toString("hex"), persistent: false, env, egressPort: gate.port }); assert.equal(resolve(integration.env.AGENT_BROWSER_EXECUTABLE_PATH ?? ""), resolve(paths.chrome), "MCP did not receive bundled Chromium"); assert.equal(integration.env.AGENT_BROWSER_RESTORE_SAVE, "never"); // Use the production preparation path. Guest preparation creates only // managed config; it must not prewarm a daemon and mask cold-start bugs. await prepareBrowserSessionState(integration.command, integration.env.AGENT_BROWSER_SESSION, { env: { ...env, ...integration.env }, persistent: false }); await assert.rejects(readFile(join(env.AGENT_BROWSER_SOCKET_DIR, `${integration.env.AGENT_BROWSER_SESSION}.pid`)), { code: "ENOENT" }); const client = mcp(integration); clients.push(client); await client.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "omb-bundle-smoke", version: "1" } }); client.proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); const inventory = await client.request("tools/list"); assert(inventory.tools.some((tool) => tool.name === "agent_browser_open"), "Browser tools missing from MCP core profile"); await client.tool("agent_browser_open", { url }); } const [alpha, beta] = clients; assert.equal((await alpha.tool("agent_browser_get_title")).data.title, title); await alpha.tool("agent_browser_fill", { selector: "#message", text: "Bundled browser works" }); await alpha.tool("agent_browser_click", { selector: "#submit" }); assert.equal((await alpha.tool("agent_browser_get_text", { selector: "#result" })).data.text, "Bundled browser works"); // The harness reads, sizes and pictures a bot's browser over the engine's own // loopback relay: page health, step evidence, preview frames and login typing. phase = "relay contract"; const { RelayPorts, relay } = await import("../server/browser-relay.ts"); const relayTarget = { session: alpha.integration.env.AGENT_BROWSER_SESSION, binaryPath: alpha.integration.command, env: { ...env, ...alpha.integration.env } }; const relayPort = await new RelayPorts().port(relayTarget); assert(relayPort, "The engine wrote no .stream file naming its relay, or the relay did not name this session"); const relayed = async (command) => { const answer = await relay(relayPort, command, { timeoutMs: 15_000 }); assert(answer.success, `relayed ${command.action} failed: ${answer.error}`); return answer.data; }; assert.equal((await relayed({ action: "stream_status" })).connected, true); for (const list of [["console", "messages"], ["errors", "errors"]]) assert(Array.isArray((await relayed({ action: list[0] }))[list[1]]), `relayed ${list[0]} has no list`); assert(Array.isArray((await relayed({ action: "requests", status: "400-599" })).requests), "relayed requests has no list"); const snapshot = (await alpha.tool("agent_browser_snapshot", { compact: true })).data; const buttonRef = Object.entries(snapshot.refs ?? {}).find(([, ref]) => ref.name === "Show message")?.[0]; assert(buttonRef, "The snapshot named no ref for the button"); const box = await relayed({ action: "boundingbox", selector: `@${buttonRef}` }); assert(box.width > 0 && box.height > 0, `relayed boundingbox of a ref gave ${JSON.stringify(box)}`); const relayShot = join(fixture, "relay.jpg"); await relayed({ action: "screenshot", path: relayShot, format: "jpeg", quality: 70 }); assert((await stat(relayShot)).size > 0, "relayed screenshot wrote nothing"); const storage = "({ local: localStorage.getItem('omb-smoke'), cookie: document.cookie })"; const setStorage = (value) => `(() => { localStorage.setItem('omb-smoke', '${value}'); document.cookie = 'omb_smoke=${value}; Path=/; SameSite=Lax'; return ${storage}; })()`; assert.deepEqual((await alpha.tool("agent_browser_eval", { script: setStorage("alpha") })).data.result, { local: "alpha", cookie: "omb_smoke=alpha" }); assert.deepEqual((await beta.tool("agent_browser_eval", { script: storage })).data.result, { local: null, cookie: "" }, "Second bot inherited first bot's state"); assert.deepEqual((await beta.tool("agent_browser_eval", { script: setStorage("beta") })).data.result, { local: "beta", cookie: "omb_smoke=beta" }); assert.deepEqual((await alpha.tool("agent_browser_eval", { script: storage })).data.result, { local: "alpha", cookie: "omb_smoke=alpha" }, "First bot's state was overwritten by the second bot"); // Reopen through the SAME MCP process: warming a daemon before MCP would // conceal the Windows inherited-pipe bug, which returns after a close. const previousDaemonPid = await ownedDaemonPid(alpha); await alpha.tool("agent_browser_close"); // Upstream acknowledges close before its delayed daemon shutdown. Wait for // this exact owned process, otherwise reopen could race the old daemon. await waitForOwnedDaemonExit(previousDaemonPid); await alpha.tool("agent_browser_open", { url }); assert.notEqual(await ownedDaemonPid(alpha), previousDaemonPid, "Reopen reused the closed daemon"); assert.equal((await alpha.tool("agent_browser_get_title")).data.title, title); assert.deepEqual((await alpha.tool("agent_browser_eval", { script: storage })).data.result, { local: null, cookie: "" }, "Guest state was restored after close"); assert.deepEqual((await beta.tool("agent_browser_eval", { script: storage })).data.result, { local: "beta", cookie: "omb_smoke=beta" }, "Restarting the first bot changed the second bot's state"); await alpha.tool("agent_browser_fill", { selector: "#message", text: "Reopened browser works" }); await alpha.tool("agent_browser_click", { selector: "#submit" }); assert.equal((await alpha.tool("agent_browser_get_text", { selector: "#result" })).data.text, "Reopened browser works"); const screenshotPath = join(fixture, "browser.png"); const screenshot = await alpha.tool("agent_browser_screenshot", { path: screenshotPath }); const image = screenshot.content.find((item) => item.type === "image" && item.mimeType === "image/png"); assert(image?.data, "MCP did not return the screenshot image to the agent"); const screenshotInfo = pngInfo(await readFile(screenshotPath)); assert.deepEqual(pngInfo(Buffer.from(image.data, "base64")), screenshotInfo, "MCP image differs from screenshot file"); report = { ok: true, target, engineMode: values["engine-candidate"] ? "vendor-candidate" : "packaged", engineVersion, chromeVersion, checks: ["fresh-home auto-discovery", "real MCP cold-start navigation", "title", "input and click", "loopback relay reads, ref box and screenshot", "same-MCP close and reopen", "PNG screenshot after restart", "two-bot cookie and localStorage isolation"], screenshot: screenshotInfo }; } catch (error) { failure = error; try { await failureDiagnostics(); } catch (diagnosticError) { console.error(`[browser-smoke] failure diagnostics could not complete: ${diagnosticError.message}`); } } finally { const cleanupErrors = []; for (const client of clients) { try { assert(await closeBrowserSession(client.integration.command, { ...env, ...client.integration.env }), "Owned browser daemon did not close"); } catch (error) { cleanupErrors.push(error.message); } } await Promise.all([...children].map((proc) => new Promise((done) => { if (proc.exitCode !== null || proc.signalCode !== null) { done(); return; } const timer = setTimeout(() => { cleanupErrors.push(`Owned process ${proc.pid} did not exit`); done(); }, 5_000); proc.once("close", () => { clearTimeout(timer); done(); }); proc.kill("SIGKILL"); }))); if (server?.listening) { server.closeAllConnections(); await new Promise((done) => server.close(done)); } await gate?.close(); process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", interrupt); if (cleanupErrors.length) failure = new Error(`${failure?.message ?? "Browser test passed"}; browser cleanup failed, isolated fixture retained at ${fixture}: ${cleanupErrors.join("; ")}`); else await rm(fixture, { recursive: true, force: true }); } if (failure) { console.error(failure.stack ?? failure); process.exitCode = 1; } else console.log(JSON.stringify({ ...report, cleanup: "owned browsers, local HTTP server, and isolated home removed" }, null, 2));