openmausbot 0.1.57 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // The pinned agent-browser release the harness downloads for the bots'
2
+ // browser (docs/plans/browser-engine.md). Digests were computed from the
3
+ // GitHub release assets on 2026-09-06; bump the version and every digest
4
+ // together, through a pull request whose end-to-end run exercises the binary.
5
+ // The Dockerfile pins the same version.
6
+ export const AGENT_BROWSER_VERSION = "0.36.0";
7
+ const RELEASES = new Map([
8
+ [
9
+ "darwin-arm64",
10
+ { target: "darwin-arm64", asset: "agent-browser-darwin-arm64", sha256: "b2106ab39db0838e7b1772f7f26f760518de56d09053150c56f9dddf15af997d", bytes: 12363200 },
11
+ ],
12
+ [
13
+ "darwin-x64",
14
+ { target: "darwin-x64", asset: "agent-browser-darwin-x64", sha256: "45d9ac061a7d72e61eaff905326e2e19365f4dadb12142ea2f2d76d84689c708", bytes: 13510280 },
15
+ ],
16
+ [
17
+ "linux-arm64",
18
+ { target: "linux-arm64", asset: "agent-browser-linux-arm64", sha256: "aeb556addca3903601a433de1acad3ace1c9c61d170084bf58d875884599a990", bytes: 12442720 },
19
+ ],
20
+ [
21
+ "linux-musl-arm64",
22
+ { target: "linux-musl-arm64", asset: "agent-browser-linux-musl-arm64", sha256: "1ca7e003c9cb185f174fc81e51a609db27c77e3bfe00a0edff60688f8cd14f88", bytes: 12297000 },
23
+ ],
24
+ [
25
+ "linux-musl-x64",
26
+ { target: "linux-musl-x64", asset: "agent-browser-linux-musl-x64", sha256: "a20cc2a5202a48f5820372803dedbcd5f556dff7a89421f1b0f2612962b10718", bytes: 13995728 },
27
+ ],
28
+ [
29
+ "linux-x64",
30
+ { target: "linux-x64", asset: "agent-browser-linux-x64", sha256: "56d15181e51e00213f907fcf39707cfc76bfa804ff20f5a9373661c73f96de5e", bytes: 14156776 },
31
+ ],
32
+ [
33
+ "win32-x64",
34
+ { target: "win32-x64", asset: "agent-browser-win32-x64.exe", sha256: "412ff72737a109e93f5304b0ff76c988fb6f1f451d0fc7e010577922bcc20ff3", bytes: 13837312 },
35
+ ],
36
+ ]);
37
+ export function agentBrowserReleaseUrl(asset) {
38
+ return `https://github.com/vercel-labs/agent-browser/releases/download/v${AGENT_BROWSER_VERSION}/${asset.asset}`;
39
+ }
40
+ /** The asset for this machine, or null where Vercel publishes none. */
41
+ export function resolveAgentBrowserReleaseAsset(platform = process.platform, arch = process.arch, musl = false) {
42
+ const key = platform === "linux" && musl ? `linux-musl-${arch}` : `${platform}-${arch}`;
43
+ return RELEASES.get(key) ?? null;
44
+ }
@@ -0,0 +1,185 @@
1
+ // The bots' browser engine: agent-browser (docs/plans/browser-engine.md).
2
+ //
3
+ // One engine on every platform. This module answers three questions for the
4
+ // harness: is the engine here (and if not, why), how does a bot get it as an
5
+ // MCP server for a turn, and where does the session state live. Everything
6
+ // that runs Chrome is agent-browser's; we resolve a pinned binary (or fetch
7
+ // it, verified), make sure it has a Chrome, and hand a turn the spec.
8
+ //
9
+ // Fail closed, say why: a missing engine reports `unavailable` with a
10
+ // reason a person can act on, never a silently browserless bot.
11
+ import { spawn } from "node:child_process";
12
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
14
+ import { delimiter, join, resolve } from "node:path";
15
+ import { writeFileAtomic } from "./atomic.js";
16
+ import { DATA_DIR } from "./config.js";
17
+ import { AGENT_BROWSER_VERSION, agentBrowserReleaseUrl, resolveAgentBrowserReleaseAsset, } from "./browser-engine-release.js";
18
+ const ENGINE_DIR = "tools/agent-browser";
19
+ const KEY_FILE = "browser-engine-key";
20
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
21
+ function executableName(platform = process.platform) {
22
+ return platform === "win32" ? "agent-browser.exe" : "agent-browser";
23
+ }
24
+ /** Alpine-style systems need the musl build. */
25
+ export function isMusl(platform = process.platform, exists = existsSync) {
26
+ return platform === "linux" && (exists("/lib/ld-musl-x86_64.so.1") || exists("/lib/ld-musl-aarch64.so.1"));
27
+ }
28
+ export function pinnedBinaryPath(dataDir = DATA_DIR, platform = process.platform) {
29
+ return join(dataDir, ENGINE_DIR, AGENT_BROWSER_VERSION, executableName(platform));
30
+ }
31
+ function onPath(env, platform, exists) {
32
+ const pathValue = platform === "win32"
33
+ ? Object.entries(env).findLast(([key]) => key.toUpperCase() === "PATH")?.[1]
34
+ : env.PATH;
35
+ for (const part of (pathValue ?? "").split(delimiter)) {
36
+ const dir = part.trim().replace(/^"|"$/gu, "");
37
+ if (!dir)
38
+ continue;
39
+ const candidate = resolve(dir, executableName(platform));
40
+ if (exists(candidate))
41
+ return candidate;
42
+ }
43
+ return null;
44
+ }
45
+ /** OMB_AGENT_BROWSER_PATH, then the pinned download under the data dir, then
46
+ * PATH (a package or image that installed it globally). */
47
+ export function resolveAgentBrowserBinary(options = {}) {
48
+ const env = options.env ?? process.env;
49
+ const platform = options.platform ?? process.platform;
50
+ const exists = options.exists ?? existsSync;
51
+ const override = env.OMB_AGENT_BROWSER_PATH?.trim();
52
+ if (override)
53
+ return resolve(override) && exists(resolve(override)) ? resolve(override) : null;
54
+ const pinned = pinnedBinaryPath(options.dataDir, platform);
55
+ if (exists(pinned))
56
+ return pinned;
57
+ return onPath(env, platform, exists);
58
+ }
59
+ /** Download the pinned release asset for this machine into the data dir,
60
+ * verifying size and SHA-256 before the file gets its final name. */
61
+ export async function installAgentBrowserBinary(options = {}) {
62
+ const platform = options.platform ?? process.platform;
63
+ const asset = options.asset ?? resolveAgentBrowserReleaseAsset(platform, options.arch ?? process.arch, options.musl ?? isMusl(platform));
64
+ if (!asset)
65
+ throw new Error(`agent-browser publishes no build for ${platform}-${options.arch ?? process.arch}.`);
66
+ const destination = pinnedBinaryPath(options.dataDir, platform);
67
+ const directory = join(destination, "..");
68
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
69
+ const url = agentBrowserReleaseUrl(asset);
70
+ options.log?.(`downloading agent-browser ${AGENT_BROWSER_VERSION} (${Math.round(asset.bytes / 1024 / 1024)} MB, digest pinned)`);
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
73
+ timer.unref?.();
74
+ let body;
75
+ try {
76
+ const response = await (options.fetchImpl ?? fetch)(url, { redirect: "follow", signal: controller.signal });
77
+ if (!response.ok)
78
+ throw new Error(`the agent-browser download failed (HTTP ${response.status})`);
79
+ body = Buffer.from(await response.arrayBuffer());
80
+ }
81
+ finally {
82
+ clearTimeout(timer);
83
+ }
84
+ if (body.length !== asset.bytes)
85
+ throw new Error("the agent-browser download did not match its pinned size; nothing was installed");
86
+ const digest = createHash("sha256").update(body).digest("hex");
87
+ if (digest !== asset.sha256)
88
+ throw new Error("the agent-browser download failed its SHA-256 check; nothing was installed");
89
+ const staging = `${destination}.${randomBytes(6).toString("hex")}.part`;
90
+ writeFileSync(staging, body, { mode: 0o755 });
91
+ if (platform !== "win32")
92
+ chmodSync(staging, 0o755);
93
+ renameSync(staging, destination);
94
+ return destination;
95
+ }
96
+ /** `agent-browser install` fetches Chrome for Testing when no Chrome, Chromium
97
+ * or Brave is found; `--with-deps` adds the Linux libraries (needs a package
98
+ * manager and privileges, so it is for images and root shells). */
99
+ export function ensureChrome(binaryPath, options = {}) {
100
+ const args = ["install", ...(options.withDeps ? ["--with-deps"] : [])];
101
+ return new Promise((done, fail) => {
102
+ const child = spawn(binaryPath, args, { env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
103
+ let output = "";
104
+ child.stdout?.on("data", (chunk) => { output += String(chunk); });
105
+ child.stderr?.on("data", (chunk) => { output += String(chunk); });
106
+ child.on("error", fail);
107
+ child.on("exit", (code) => {
108
+ if (code === 0) {
109
+ options.log?.("agent-browser: Chrome is ready");
110
+ done();
111
+ }
112
+ else {
113
+ fail(new Error(`agent-browser install exited ${code ?? "by signal"}: ${output.trim().split("\n").slice(-3).join(" ")}`));
114
+ }
115
+ });
116
+ });
117
+ }
118
+ /** The key agent-browser uses to encrypt saved session state at rest. Made
119
+ * once, 0600, beside the rest of the data dir's secrets. */
120
+ export function browserEngineEncryptionKey(dataDir = DATA_DIR) {
121
+ const file = join(dataDir, KEY_FILE);
122
+ try {
123
+ const existing = readFileSync(file, "utf8").trim();
124
+ if (/^[0-9a-f]{64}$/u.test(existing))
125
+ return existing;
126
+ }
127
+ catch {
128
+ // first run
129
+ }
130
+ const key = randomBytes(32).toString("hex");
131
+ mkdirSync(dataDir, { recursive: true });
132
+ writeFileAtomic(file, `${key}\n`, { mode: 0o600 });
133
+ return key;
134
+ }
135
+ /** What the harness can offer bots right now, with the reason when nothing. */
136
+ export function browserEngineStatus(options = {}) {
137
+ const binaryPath = resolveAgentBrowserBinary(options);
138
+ if (binaryPath)
139
+ return { kind: "ready", binaryPath, version: AGENT_BROWSER_VERSION };
140
+ const platform = options.platform ?? process.platform;
141
+ const asset = resolveAgentBrowserReleaseAsset(platform, process.arch, isMusl(platform));
142
+ return asset
143
+ ? { kind: "unavailable", reason: "agent-browser is not installed on this machine yet", installable: true }
144
+ : { kind: "unavailable", reason: `agent-browser publishes no build for ${platform}-${process.arch}`, installable: false };
145
+ }
146
+ /** The MCP server a turn mounts so the bot gets browser tools. One isolated,
147
+ * auto-restored session per browser profile (or per bot), page-provided
148
+ * WebMCP tools off, and only the core tool set. */
149
+ export function agentBrowserIntegration(input) {
150
+ const env = {
151
+ AGENT_BROWSER_SESSION: input.session,
152
+ // This is a restore *name*, not a boolean. "1" would give every bot
153
+ // the same saved cookies despite using different daemon sessions.
154
+ AGENT_BROWSER_RESTORE: input.session,
155
+ AGENT_BROWSER_RESTORE_SAVE: input.persistent === false ? "never" : "auto",
156
+ AGENT_BROWSER_ENCRYPTION_KEY: input.encryptionKey,
157
+ };
158
+ if (input.headless !== false)
159
+ env.AGENT_BROWSER_HEADLESS = "1";
160
+ const path = (input.env ?? process.env).PATH;
161
+ if (path)
162
+ env.PATH = path;
163
+ return { command: input.binaryPath, args: ["mcp", "--tools", "core", "--no-webmcp"], env };
164
+ }
165
+ /** Session ids are file-system and shell safe: a bot id or a profile partition. */
166
+ export function browserSessionId(botId, partitionId) {
167
+ if (partitionId === "guest")
168
+ return `guest-${randomUUID()}`;
169
+ const raw = partitionId || `bot-${botId}`;
170
+ return raw.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 96);
171
+ }
172
+ export function describeBrowserEngine(status) {
173
+ return status.kind === "ready"
174
+ ? `browser engine: agent-browser ${status.version} at ${status.binaryPath}`
175
+ : `browser engine: unavailable (${status.reason})`;
176
+ }
177
+ // Kept for callers that want a file check without a full status.
178
+ export function agentBrowserBinaryExists(dataDir = DATA_DIR) {
179
+ try {
180
+ return statSync(pinnedBinaryPath(dataDir)).isFile();
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
@@ -28,9 +28,10 @@ import { createInterface } from "node:readline/promises";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import qrcode from "qrcode-terminal";
30
30
  import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
31
+ import { browserEngineStatus, describeBrowserEngine, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, } from "./browser-engine.js";
31
32
  import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
32
33
  const HERE = dirname(fileURLToPath(import.meta.url));
33
- const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "help", "--help", "-h"];
34
+ const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
34
35
  export function parseArgs(argv, env = process.env) {
35
36
  const [command = "help", ...rest] = argv;
36
37
  if (!COMMANDS.includes(command)) {
@@ -44,6 +45,7 @@ export function parseArgs(argv, env = process.env) {
44
45
  tunnel: false,
45
46
  client: false,
46
47
  pair: true,
48
+ withDeps: false,
47
49
  json: false,
48
50
  };
49
51
  for (let i = 0; i < rest.length; i += 1) {
@@ -78,6 +80,10 @@ export function parseArgs(argv, env = process.env) {
78
80
  options.email = value();
79
81
  else if (options.command === "sessions" && arg === "revoke")
80
82
  options.revoke = value();
83
+ else if (options.command === "browser" && (arg === "install" || arg === "status"))
84
+ options.browserAction = arg;
85
+ else if (options.command === "browser" && arg === "--with-deps")
86
+ options.withDeps = true;
81
87
  else
82
88
  return { error: `unknown argument "${arg}"` };
83
89
  }
@@ -91,6 +97,8 @@ export function parseArgs(argv, env = process.env) {
91
97
  return { error: "--public-url must start with http:// or https://" };
92
98
  if (options.tailscale && options.tunnel)
93
99
  return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
100
+ if (options.command === "browser" && !options.browserAction)
101
+ return { error: "browser needs an action: install or status" };
94
102
  return options;
95
103
  }
96
104
  export const USAGE = `openmausbot — run the server anywhere, pair devices to it
@@ -102,6 +110,7 @@ export const USAGE = `openmausbot — run the server anywhere, pair devices to i
102
110
  openmausbot status
103
111
  openmausbot login [--email you@example.com]
104
112
  openmausbot logout
113
+ openmausbot browser install [--with-deps] | status
105
114
 
106
115
  serve starts the server and prints a pairing link + QR code
107
116
  pair mints a pairing code against a running server (--client: chat only)
@@ -110,6 +119,10 @@ status what the server says about itself
110
119
  login signs this machine in to an OpenMausBot account (an emailed code)
111
120
  and reserves its public address for --tunnel
112
121
  logout releases that address and signs out
122
+ browser install: the bots' browser engine (agent-browser, pinned) and a
123
+ Chrome for Testing, into the data dir; --with-deps also installs
124
+ the Linux libraries Chrome needs (run as root once). status: what
125
+ this machine has.
113
126
 
114
127
  --tailscale serve over your tailnet: Tailscale terminates HTTPS and the
115
128
  link uses this machine's MagicDNS name (needs Tailscale signed in
@@ -329,6 +342,44 @@ export async function runLogout(options, io = defaultIo()) {
329
342
  io.log(`Signed out ${before.email}${before.address ? `; ${before.address} is released` : ""}.`);
330
343
  return 0;
331
344
  }
345
+ export async function runBrowser(options, io = defaultIo()) {
346
+ const status = browserEngineStatus({ dataDir: options.dataDir });
347
+ if (options.browserAction === "status") {
348
+ io.log(describeBrowserEngine(status));
349
+ if (status.kind !== "ready" && status.installable)
350
+ io.log("install it with: openmausbot browser install");
351
+ return status.kind === "ready" ? 0 : 1;
352
+ }
353
+ let binary = resolveAgentBrowserBinary({ dataDir: options.dataDir });
354
+ if (binary) {
355
+ io.log(`agent-browser is already here: ${binary}`);
356
+ }
357
+ else {
358
+ if (status.kind !== "ready" && !status.installable) {
359
+ io.error(status.reason);
360
+ return 1;
361
+ }
362
+ try {
363
+ binary = await installAgentBrowserBinary({ dataDir: options.dataDir, log: io.log });
364
+ }
365
+ catch (error) {
366
+ io.error(`could not install agent-browser: ${message(error)}`);
367
+ return 1;
368
+ }
369
+ io.log(`installed ${binary}`);
370
+ }
371
+ try {
372
+ await ensureChrome(binary, { withDeps: options.withDeps === true, log: io.log });
373
+ }
374
+ catch (error) {
375
+ io.error(`Chrome is not ready: ${message(error)}`);
376
+ if (process.platform === "linux" && !options.withDeps)
377
+ io.error("on Linux, Chrome needs system libraries: run `sudo openmausbot browser install --with-deps` once");
378
+ return 1;
379
+ }
380
+ io.log("bots on this server can use a browser now: turn it on under Settings → Experimental, then per bot");
381
+ return 0;
382
+ }
332
383
  /** Where the server bundle lives relative to this file: next to it in the
333
384
  * npm package and the image (dist-server/), or the TypeScript source in a
334
385
  * checkout. */
@@ -485,6 +536,7 @@ export async function runServe(options, log = console.log) {
485
536
  log("");
486
537
  log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
487
538
  log(`data: ${options.dataDir}`);
539
+ log(describeBrowserEngine(browserEngineStatus({ dataDir: options.dataDir })));
488
540
  if (options.pair) {
489
541
  log("");
490
542
  log(await mintPairing(options.port, { label: options.label ? `${options.label} owner` : undefined, publicUrl: publicUrl ?? undefined }));
@@ -517,6 +569,8 @@ export async function main(argv = process.argv.slice(2)) {
517
569
  return runLogin(options);
518
570
  case "logout":
519
571
  return runLogout(options);
572
+ case "browser":
573
+ return runBrowser(options);
520
574
  default:
521
575
  console.log(USAGE);
522
576
  return 0;
@@ -48,6 +48,24 @@ function takeLines(buffer) {
48
48
  const rest = parts.pop() ?? "";
49
49
  return { lines: parts.map((line) => line.replace(/\r$/u, "")), rest };
50
50
  }
51
+ /** Only fixed, allowlisted startup hints leave stderr. Native output can
52
+ * contain OAuth codes and credentials in arbitrary formats, so generic
53
+ * text redaction is not enough to safely echo a diagnostic tail. */
54
+ function startupHint(line) {
55
+ if (/no space left|not enough (?:space|disk)|disk (?:is )?full|winerror\s*112/iu.test(line)) {
56
+ return "The runtime reported insufficient disk space while starting.";
57
+ }
58
+ if (/failed to (?:extract|load (?:python|embedded python))|could not load python/iu.test(line)) {
59
+ return "The runtime reported a problem unpacking or loading its bundled Python runtime.";
60
+ }
61
+ if (/permission denied|access is denied|winerror\s*5\b/iu.test(line)) {
62
+ return "The runtime reported a file or process permission failure.";
63
+ }
64
+ if (/address family not supported|failed to create.*(?:socket|listener)|failed to bind/iu.test(line)) {
65
+ return "The runtime reported a local network listener failure.";
66
+ }
67
+ return undefined;
68
+ }
51
69
  /** The sign-in link in a server output line, or null for anything else.
52
70
  * Google announces it two ways and both land on stderr: it hands the link to
53
71
  * $BROWSER — our helper re-emits it JSON-encoded behind a private marker — and
@@ -125,6 +143,10 @@ export class AntigravityAcpClient {
125
143
  pending = new Map();
126
144
  buffer = "";
127
145
  diagnosticBuffer = "";
146
+ initializationComplete = false;
147
+ startupOutputBytes = 0;
148
+ startupDiagnosticBytes = 0;
149
+ nativeStartupHint;
128
150
  closed = false;
129
151
  onAuthorizationUrl;
130
152
  /** Settles once the runtime process is gone. On Windows a running
@@ -136,16 +158,17 @@ export class AntigravityAcpClient {
136
158
  this.child = spawnCli(runtime.executablePath, process.platform === "linux" ? ["--uid="] : [], { cwd, env: profile.environment, stdio: ["pipe", "pipe", "pipe"] });
137
159
  this.child.stdout.setEncoding("utf8");
138
160
  this.child.stdout.on("data", (chunk) => this.consume(chunk));
139
- // Google announces the sign-in link on stderr, not stdout. Scan for that
140
- // one line and drop every other byte: stderr also carries authorization
141
- // codes, so nothing else is retained or surfaced.
161
+ // Keep only sign-in announcements and fixed startup failure categories;
162
+ // never surface raw stderr, which can contain authorization codes.
142
163
  this.child.stderr.setEncoding("utf8");
143
164
  this.child.stderr.on("data", (chunk) => this.consumeDiagnostics(chunk));
144
165
  this.child.once("error", (error) => this.failAll(error));
145
166
  this.exited = new Promise((resolve) => {
146
- this.child.once("close", (code) => {
167
+ this.child.once("close", (code, signal) => {
168
+ this.noteStartupDiagnostic(this.diagnosticBuffer);
169
+ this.diagnosticBuffer = "";
147
170
  if (!this.closed)
148
- this.failAll(new Error(`Antigravity ACP exited ${code ?? "unexpectedly"}.`));
171
+ this.failAll(new Error(`Antigravity ACP exited ${code ?? signal ?? "unexpectedly"}.${this.nativeStartupHint ? ` ${this.nativeStartupHint}` : ""}`));
149
172
  resolve();
150
173
  });
151
174
  // Failed spawns also emit `close`. An `error` alone can instead mean
@@ -153,6 +176,8 @@ export class AntigravityAcpClient {
153
176
  });
154
177
  }
155
178
  consume(chunk) {
179
+ if (!this.initializationComplete)
180
+ this.startupOutputBytes += Buffer.byteLength(chunk);
156
181
  this.buffer += chunk;
157
182
  if (Buffer.byteLength(this.buffer) > MAX_PROTOCOL_LINE_BYTES) {
158
183
  this.failAll(new Error("Antigravity sent a protocol line that is too large."));
@@ -206,11 +231,19 @@ export class AntigravityAcpClient {
206
231
  * and released immediately and the tail is capped, so no authorization code
207
232
  * is ever held. Draining also keeps the pipe from stalling the server. */
208
233
  consumeDiagnostics(chunk) {
234
+ if (!this.initializationComplete)
235
+ this.startupDiagnosticBytes += Buffer.byteLength(chunk);
209
236
  const { lines, rest } = takeLines(this.diagnosticBuffer + chunk);
210
237
  // A partial line already longer than any legal link cannot become one.
211
238
  this.diagnosticBuffer = rest.length > MAX_DIAGNOSTIC_LINE_CHARS ? "" : rest;
212
- for (const line of lines)
213
- this.announceAuthorizationUrl(line);
239
+ for (const line of lines) {
240
+ if (!this.announceAuthorizationUrl(line))
241
+ this.noteStartupDiagnostic(line);
242
+ }
243
+ }
244
+ noteStartupDiagnostic(line) {
245
+ if (!this.initializationComplete)
246
+ this.nativeStartupHint ??= startupHint(line);
214
247
  }
215
248
  failAll(error) {
216
249
  for (const pending of this.pending.values()) {
@@ -227,7 +260,16 @@ export class AntigravityAcpClient {
227
260
  return new Promise((resolveRequest, reject) => {
228
261
  const timer = setTimeout(() => {
229
262
  this.pending.delete(id);
230
- reject(new Error(`${method} timed out.`));
263
+ if (method === "initialize") {
264
+ this.noteStartupDiagnostic(this.diagnosticBuffer);
265
+ reject(new Error(`Antigravity initialization timed out after ${Math.ceil(timeoutMs / 1_000)} seconds (${process.platform}-${process.arch}). ` +
266
+ "The executable was found, but did not finish starting. " +
267
+ (this.nativeStartupHint ? `${this.nativeStartupHint} ` : "") +
268
+ `Startup output: ${this.startupOutputBytes} bytes; diagnostic output: ${this.startupDiagnosticBytes} bytes. ` +
269
+ "Retry setup. If it still fails, share this error and your OpenMausBot version; do not paste Google sign-in links or tokens."));
270
+ }
271
+ else
272
+ reject(new Error(`${method} timed out.`));
231
273
  }, timeoutMs);
232
274
  timer.unref?.();
233
275
  this.pending.set(id, { resolve: resolveRequest, reject, timer });
@@ -235,11 +277,14 @@ export class AntigravityAcpClient {
235
277
  });
236
278
  }
237
279
  async initialize(timeoutMs = STARTUP_TIMEOUT_MS) {
238
- return this.request("initialize", {
280
+ const initialized = await this.request("initialize", {
239
281
  protocolVersion: 1,
240
282
  clientInfo: { name: "openmausbot", version: "0.0.0" },
241
283
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
242
284
  }, timeoutMs);
285
+ this.initializationComplete = true;
286
+ this.nativeStartupHint = undefined;
287
+ return initialized;
243
288
  }
244
289
  close() {
245
290
  if (this.closed)
@@ -440,6 +485,12 @@ export async function validateAntigravityRuntime(runtime, expectedVersion) {
440
485
  instanceId: `verify-${randomUUID()}`,
441
486
  runtime,
442
487
  profileDirectory,
488
+ // Google's Windows one-file executable expands a large Python runtime
489
+ // into TEMP. Forced shutdown skips its own cleanup. Keep verification's
490
+ // extraction inside the profile we already remove after confirmed close.
491
+ baseEnv: process.platform === "win32"
492
+ ? { ...process.env, TEMP: profileDirectory, TMP: profileDirectory }
493
+ : undefined,
443
494
  });
444
495
  client = new AntigravityAcpClient(runtime, profile, profileDirectory);
445
496
  const initialized = await client.initialize();
@@ -2,6 +2,7 @@ import { createAcpDriver } from "./acp/core.js";
2
2
  import { AntigravityAuthController, antigravityProfileAuthenticated, catalogFromAntigravityConfigOptions, prepareAntigravityProfile, probeAntigravityModels, validateAntigravityRuntime, } from "./antigravity-acp.js";
3
3
  import { antigravityManagedInstallAvailable, installAntigravityRuntime, resolveAntigravityRuntime, } from "./antigravity-runtime.js";
4
4
  import { resolveAntigravityReleaseAsset } from "./antigravity-release.js";
5
+ import { augmentedPath } from "../env-path.js";
5
6
  export const STATIC_ANTIGRAVITY_MODELS = {
6
7
  default: "gemini-3.8-flash-high",
7
8
  options: [
@@ -107,8 +108,11 @@ export const AntigravityDriver = {
107
108
  async create(input) {
108
109
  const base = await AcpAntigravityDriver.create(input);
109
110
  const auth = new AntigravityAuthController();
111
+ let installFailure;
110
112
  const runtimeAndProfile = async () => {
111
- const environment = { ...process.env, ...input.environment };
113
+ // Match the ACP driver's discovery/chat environment. A GUI launch's
114
+ // raw PATH may omit an official runtime that the model picker found.
115
+ const environment = { ...process.env, ...input.environment, PATH: augmentedPath() };
112
116
  const runtime = await resolveAntigravityRuntime(input.config.cli, environment);
113
117
  const profile = await prepareAntigravityProfile({
114
118
  instanceId: input.instanceId,
@@ -122,10 +126,27 @@ export const AntigravityDriver = {
122
126
  get models() {
123
127
  return base.models;
124
128
  },
129
+ snapshot: async () => {
130
+ const snapshot = await base.snapshot();
131
+ if (snapshot.state === "available")
132
+ installFailure = undefined;
133
+ // A failed first install has no promoted executable yet. Preserve
134
+ // why it failed across closing/reopening setup in this app session.
135
+ return snapshot.state === "unavailable" && installFailure
136
+ ? { ...snapshot, reason: installFailure }
137
+ : snapshot;
138
+ },
125
139
  ...(antigravityManagedInstallAvailable()
126
140
  ? {
127
141
  installRuntime: async () => {
128
- await installAntigravityRuntime({ validate: validateAntigravityRuntime });
142
+ installFailure = undefined;
143
+ try {
144
+ await installAntigravityRuntime({ validate: validateAntigravityRuntime });
145
+ }
146
+ catch (error) {
147
+ installFailure = error instanceof Error ? error.message : String(error);
148
+ throw error;
149
+ }
129
150
  },
130
151
  }
131
152
  : {}),
@@ -85,6 +85,7 @@ import * as vps from "./vps-computer.js";
85
85
  import { RoutineManager } from "./routines.js";
86
86
  import { CalendarCallManager } from "./calendar-calls.js";
87
87
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT, applyDesktopBrowserConnectionMessage, availableBrowserConnection, browserScreenshot, clearBrowserCapabilities, registerBrowserCapability, revokeBrowserCapability, } from "./browser-connection.js";
88
+ import { agentBrowserIntegration, browserEngineEncryptionKey, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
88
89
  import { captureOutsideHumanControl } from "./private-screen-capture.js";
89
90
  import { screenFrameHash, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
90
91
  import { RoutineRequestService } from "./routine-requests.js";
@@ -542,7 +543,7 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
542
543
  async function browserIntegration(botId, profile, threadId, generation, stillValid = () => true, ownerId = randomUUID()) {
543
544
  const connection = availableBrowserConnection();
544
545
  if (!connection)
545
- return null;
546
+ return engineBrowserIntegration(botId, profile);
546
547
  const control = controlIntegration(botId, threadId, generation);
547
548
  // A profile that no longer exists falls back to the bot's own session.
548
549
  // Canonical ids belong to config/bot references; Electron must receive the
@@ -583,6 +584,43 @@ async function browserIntegration(botId, profile, threadId, generation, stillVal
583
584
  },
584
585
  };
585
586
  }
587
+ /** No desktop surface (a server, or a Windows desktop): the bot's browser is
588
+ * agent-browser, one isolated session per profile or per bot
589
+ * (docs/plans/browser-engine.md). Returns null, with the reason logged once,
590
+ * when the engine is not on this machine. */
591
+ function engineBrowserIntegration(botId, profile) {
592
+ const status = browserEngineStatus();
593
+ if (status.kind !== "ready") {
594
+ if (!engineUnavailableLogged) {
595
+ engineUnavailableLogged = true;
596
+ console.warn(`${describeBrowserEngine(status)}; bots get no browser tools until it is installed`);
597
+ }
598
+ return null;
599
+ }
600
+ const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
601
+ const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
602
+ const session = browserSessionId(botId, partitionId);
603
+ return {
604
+ connection: null,
605
+ capability: null,
606
+ profile: partitionId,
607
+ integration: agentBrowserIntegration({
608
+ binaryPath: status.binaryPath,
609
+ session,
610
+ encryptionKey: browserEngineEncryptionKey(),
611
+ persistent: profile !== "guest",
612
+ }),
613
+ };
614
+ }
615
+ let engineUnavailableLogged = false;
616
+ export function browserEngineSummary() {
617
+ if (availableBrowserConnection())
618
+ return { kind: "desktop" };
619
+ const status = browserEngineStatus();
620
+ return status.kind === "ready"
621
+ ? { kind: "headless", version: status.version }
622
+ : { kind: "unavailable", reason: status.reason, installable: status.installable };
623
+ }
586
624
  function phoneIntegration() {
587
625
  const env = { ...AGENTS_NODE_FLAG };
588
626
  if (process.env.OMB_ADB_PATH)
@@ -4002,9 +4040,9 @@ async function startTurn(botId, text, opts) {
4002
4040
  // after its own turn.completed would never be torn down — it would
4003
4041
  // keep polling the box forever, carrying dead per-turn state. busy
4004
4042
  // is flipped false in the fold, so it is the honest "still running".
4005
- if (!previewCapture && browser) {
4006
- const { connection } = browser;
4007
- previewCapture = () => browserScreenshot(connection, browser.capability, fetch);
4043
+ if (!previewCapture && browser?.connection && browser.capability) {
4044
+ const { connection, capability } = browser;
4045
+ previewCapture = () => browserScreenshot(connection, capability, fetch);
4008
4046
  }
4009
4047
  if (previewCapture && store.bot(bot.id)?.busy) {
4010
4048
  startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" });
@@ -6534,6 +6572,9 @@ function configStatus() {
6534
6572
  showToolCalls: showToolCallsEnabled(cfg),
6535
6573
  browser: builtInBrowserEnabled(cfg),
6536
6574
  },
6575
+ // Which browser this server can give bots: the desktop app's surface,
6576
+ // the agent-browser engine, or nothing yet (with the reason).
6577
+ browserEngine: browserEngineSummary(),
6537
6578
  // partitionId is non-secret routing metadata. The renderer needs it to
6538
6579
  // show the same durable session as an agent, but config PATCH validation
6539
6580
  // keeps it read-only and rejects callers that try to choose it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",