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.
- package/dist/assets/{index-jWqt5dmY.js → index-CE7W1AIk.js} +1 -1
- package/dist/assets/index-r-WPT-Pu.js +264 -0
- package/dist/index.html +1 -1
- package/dist-server/index.js +466 -258
- package/dist-server/openmausbot.js +15219 -305
- package/dist-server/pair-cli.js +15219 -305
- package/dist-server/server/browser-engine-release.js +44 -0
- package/dist-server/server/browser-engine.js +185 -0
- package/dist-server/server/cli.js +55 -1
- package/dist-server/server/drivers/antigravity-acp.js +60 -9
- package/dist-server/server/drivers/antigravity.js +23 -2
- package/dist-server/server/index.js +45 -4
- package/package.json +1 -1
- package/dist/assets/index-DSh8AIUy.js +0 -264
|
@@ -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
|
-
//
|
|
140
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|