openmausbot 0.1.56 → 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-B433sv8K.css +1 -0
- package/dist/assets/{index-Dq2j4Ikr.js → index-CE7W1AIk.js} +1 -1
- package/dist/assets/index-r-WPT-Pu.js +264 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +6 -1
- package/dist-server/connector-proxy.js +31 -12
- package/dist-server/container-mcp.js +49 -31
- package/dist-server/drivers/agents-proxy.js +56 -2
- package/dist-server/index.js +2126 -487
- package/dist-server/openmausbot.js +15219 -305
- package/dist-server/pair-cli.js +15219 -305
- package/dist-server/server/auto-approve.js +24 -0
- package/dist-server/server/bot-folder.js +102 -0
- package/dist-server/server/bot-overview.js +206 -0
- package/dist-server/server/bot-package.js +5 -0
- package/dist-server/server/bot-profile.js +7 -0
- 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/connector-proxy.js +37 -16
- package/dist-server/server/container-computer.js +9 -2
- package/dist-server/server/drivers/agents-proxy.js +63 -2
- package/dist-server/server/drivers/antigravity-acp.js +119 -20
- package/dist-server/server/drivers/antigravity-runtime.js +47 -3
- package/dist-server/server/drivers/antigravity.js +23 -2
- package/dist-server/server/drivers/claude.js +7 -1
- package/dist-server/server/index.js +634 -107
- package/dist-server/server/mcp-bridge.js +73 -44
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/profile-requests.js +332 -0
- package/dist-server/server/profile-revision.js +15 -0
- package/dist-server/server/profile-versions.js +154 -0
- package/dist-server/server/routine-requests.js +34 -1
- package/dist-server/server/routines.js +70 -2
- package/dist-server/server/setup-mode.js +63 -0
- package/dist-server/server/skill-fetch.js +67 -11
- package/dist-server/server/skills.js +1 -1
- package/dist-server/server/store.js +87 -7
- package/dist-server/server/system-prompt.js +49 -0
- package/dist-server/server/team-backup.js +2 -2
- package/dist-server/server/team-manifest.js +7 -0
- package/dist-server/shared/bot-profile.js +5 -1
- package/dist-server/shared/line-diff.js +42 -0
- package/dist-server/shared/profile-request.js +9 -0
- package/dist-server/shared/team-backup.js +4 -0
- package/dist-server/vps-container-mcp.js +49 -31
- package/package.json +1 -1
- package/dist/assets/index-CJBrtDbC.css +0 -1
- package/dist/assets/index-D0xt96rv.js +0 -263
|
@@ -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;
|
|
@@ -118,22 +118,42 @@ function connectorAdds(args) {
|
|
|
118
118
|
const toolkits = args.toolkits;
|
|
119
119
|
if (!Array.isArray(toolkits))
|
|
120
120
|
return [];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const requests = [];
|
|
123
|
+
for (const item of toolkits) {
|
|
124
|
+
if (typeof item === "string") {
|
|
125
|
+
const slug = item.trim().toLowerCase();
|
|
126
|
+
const key = JSON.stringify([slug, ""]);
|
|
127
|
+
if (!seen.has(key)) {
|
|
128
|
+
seen.add(key);
|
|
129
|
+
requests.push({ slug });
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
134
|
+
continue;
|
|
135
|
+
const row = item;
|
|
136
|
+
const action = String(row.action ?? "add").toLowerCase();
|
|
137
|
+
if (!["add", "connect", "initiate"].includes(action))
|
|
138
|
+
continue;
|
|
139
|
+
const slug = typeof row.toolkit === "string" ? row.toolkit : row.name;
|
|
140
|
+
if (typeof slug !== "string")
|
|
141
|
+
continue;
|
|
142
|
+
const normalized = slug.trim().toLowerCase();
|
|
143
|
+
const alias = (typeof row.alias === "string" ? row.alias : typeof row.account === "string" ? row.account : "").trim();
|
|
144
|
+
const key = JSON.stringify([normalized, alias.toLowerCase()]);
|
|
145
|
+
if (seen.has(key))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
requests.push({ slug: normalized, ...(alias ? { alias } : {}) });
|
|
149
|
+
}
|
|
150
|
+
return requests;
|
|
131
151
|
}
|
|
132
|
-
async function showConnectorCards(
|
|
152
|
+
async function showConnectorCards(items) {
|
|
133
153
|
const response = await fetch(`${HARNESS}/api/internal/connectors/request`, {
|
|
134
154
|
method: "POST",
|
|
135
155
|
headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
|
|
136
|
-
body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID,
|
|
156
|
+
body: JSON.stringify({ botId: BOT_ID, threadId: THREAD_ID, items, resumeKey: randomUUID() }),
|
|
137
157
|
signal: AbortSignal.timeout(30_000),
|
|
138
158
|
});
|
|
139
159
|
if (!response.ok) {
|
|
@@ -176,10 +196,11 @@ async function handle(message) {
|
|
|
176
196
|
if (method === "tools/call") {
|
|
177
197
|
const params = (message.params ?? {});
|
|
178
198
|
const name = String(params.name ?? "");
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
await showConnectorCards(
|
|
182
|
-
|
|
199
|
+
const requests = /MANAGE_CONNECTIONS$/i.test(name) ? connectorAdds(params.arguments) : [];
|
|
200
|
+
if (requests.length) {
|
|
201
|
+
await showConnectorCards(requests);
|
|
202
|
+
const labels = requests.map((r) => (r.alias ? `${r.slug} (${r.alias})` : r.slug)).join(", ");
|
|
203
|
+
send(textResult(id, `OpenMausBot showed the user a secure connection card for ${labels}. End this turn now. The app will continue the task automatically after the connection finishes.`));
|
|
183
204
|
return;
|
|
184
205
|
}
|
|
185
206
|
if (/WAIT_FOR_CONNECTIONS$/i.test(name)) {
|
|
@@ -728,7 +728,10 @@ export function wholeScreenshot(bytes) {
|
|
|
728
728
|
mime: "image/jpeg",
|
|
729
729
|
};
|
|
730
730
|
}
|
|
731
|
-
|
|
731
|
+
/** The raw frame, in the shape the live screen poller broadcasts to every
|
|
732
|
+
* client (server/index.ts). The web panel wants a data URL instead, so
|
|
733
|
+
* containerComputerScreenshot below wraps this one. */
|
|
734
|
+
export async function containerComputerFrame(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
|
|
732
735
|
const cacheable = runner === sh && platform === process.platform;
|
|
733
736
|
const now = Date.now();
|
|
734
737
|
const cached = screenshotStatusCache.get(target.key);
|
|
@@ -759,7 +762,7 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
|
|
|
759
762
|
if (!checked.ok) {
|
|
760
763
|
throw Object.assign(new Error("Cua Driver returned an incomplete screenshot"), { status: 502 });
|
|
761
764
|
}
|
|
762
|
-
return
|
|
765
|
+
return { png: data, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
|
|
763
766
|
}
|
|
764
767
|
catch (error) {
|
|
765
768
|
if (cacheable)
|
|
@@ -767,6 +770,10 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
|
|
|
767
770
|
throw error;
|
|
768
771
|
}
|
|
769
772
|
}
|
|
773
|
+
export async function containerComputerScreenshot(runner = sh, platform = process.platform, target = SHARED_LOCAL_VM_TARGET) {
|
|
774
|
+
const { png, format } = await containerComputerFrame(runner, platform, target);
|
|
775
|
+
return `data:image/${format};base64,${png}`;
|
|
776
|
+
}
|
|
770
777
|
const screenshotStatusCache = new Map();
|
|
771
778
|
const containerMcpPath = SPAWNED_PROXIES.containerMcp;
|
|
772
779
|
export function containerComputerMcp(runtime, control, target = SHARED_LOCAL_VM_TARGET) {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// list_routines() → inspect this bot's scheduled work
|
|
20
20
|
// propose_routine(...) → show a confirmation card for a new routine
|
|
21
21
|
// propose_routine_action(...) → show a confirmation card for a routine change
|
|
22
|
+
// propose_profile(...) → show a confirmation card for a profile change
|
|
22
23
|
//
|
|
23
24
|
// Speaks raw JSON-RPC 2.0 over stdio (no MCP SDK — house style, matches
|
|
24
25
|
// computer-proxy / permission-proxy). All state comes from env, injected by
|
|
@@ -212,6 +213,10 @@ const ROUTINE_FIELDS_SCHEMA = {
|
|
|
212
213
|
type: "boolean",
|
|
213
214
|
description: "Only for updates: set true to remove an existing safety limit. Do not combine with timeout_minutes.",
|
|
214
215
|
},
|
|
216
|
+
continuity: {
|
|
217
|
+
type: "boolean",
|
|
218
|
+
description: "Opt in to using the latest completed run's bounded report as historical context. Defaults to false; set false in an update to start fresh again. Shown on the confirmation card.",
|
|
219
|
+
},
|
|
215
220
|
};
|
|
216
221
|
const TOOLS = [
|
|
217
222
|
{
|
|
@@ -390,6 +395,31 @@ const TOOLS = [
|
|
|
390
395
|
required: ["routine_id", "action"],
|
|
391
396
|
},
|
|
392
397
|
},
|
|
398
|
+
{
|
|
399
|
+
name: "propose_profile",
|
|
400
|
+
description: "Propose changes to your own name, title, description, standing instructions (SOUL.md), or working folder (cwd). This only creates a confirmation card; nothing changes until the user approves it. After calling it, end the turn and do not claim the change is applied. Keep SOUL.md short — who you are and the rules you never break; put step-by-step procedure into a skill instead. A Chief of Staff may pass for_bot_id (from list_bots) to propose a change for another bot in its section.",
|
|
401
|
+
inputSchema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
additionalProperties: false,
|
|
404
|
+
properties: {
|
|
405
|
+
name: { type: "string", maxLength: 100, description: "New display name." },
|
|
406
|
+
title: { type: "string", maxLength: 200, description: "New role or title." },
|
|
407
|
+
description: { type: "string", maxLength: 4000, description: "New one-line blurb shown in rosters." },
|
|
408
|
+
soul: { type: "string", description: "Full replacement text for SOUL.md, at most 24000 bytes." },
|
|
409
|
+
cwd: {
|
|
410
|
+
type: "string",
|
|
411
|
+
maxLength: 1024,
|
|
412
|
+
description: "Absolute path of the folder your tools read and write in (for example /Users/me/Projects/site). It must already exist. An empty string means your private workspace.",
|
|
413
|
+
},
|
|
414
|
+
reason: { type: "string", minLength: 1, maxLength: 500, description: "One sentence the user will see explaining why." },
|
|
415
|
+
for_bot_id: {
|
|
416
|
+
type: "string",
|
|
417
|
+
description: "Chief of Staff only: the id of another bot in your section whose profile this changes. Omit to change your own.",
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
required: ["reason"],
|
|
421
|
+
},
|
|
422
|
+
},
|
|
393
423
|
{
|
|
394
424
|
name: "skills_list",
|
|
395
425
|
description: "List this bot's imported skills (enabled and disabled) and any staged skill writes waiting for the user to confirm. Use this before skill_manage to avoid duplicate names. Listing does not enable anything.",
|
|
@@ -475,12 +505,14 @@ function routineFields(args) {
|
|
|
475
505
|
fields.timeoutMinutes = null;
|
|
476
506
|
else if (typeof args.timeout_minutes === "number")
|
|
477
507
|
fields.timeoutMinutes = args.timeout_minutes;
|
|
508
|
+
if (typeof args.continuity === "boolean")
|
|
509
|
+
fields.continuity = args.continuity;
|
|
478
510
|
return { fields };
|
|
479
511
|
}
|
|
480
|
-
function confirmationResult(r, fallback) {
|
|
512
|
+
function confirmationResult(r, fallback, noun = "routine") {
|
|
481
513
|
const summary = typeof r.summary === "string" && r.summary.trim() ? `\n\n${r.summary.trim()}` : "";
|
|
482
514
|
return {
|
|
483
|
-
text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the
|
|
515
|
+
text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the ${noun} was created or changed before confirmation.`,
|
|
484
516
|
};
|
|
485
517
|
}
|
|
486
518
|
/** Who said a recalled line, as the header of a hit or a read. A user-role
|
|
@@ -770,6 +802,35 @@ async function callTool(name, args) {
|
|
|
770
802
|
});
|
|
771
803
|
return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`);
|
|
772
804
|
}
|
|
805
|
+
if (name === "propose_profile") {
|
|
806
|
+
const changes = {};
|
|
807
|
+
if (typeof args.name === "string")
|
|
808
|
+
changes.name = args.name.trim();
|
|
809
|
+
if (typeof args.title === "string")
|
|
810
|
+
changes.title = args.title.trim();
|
|
811
|
+
if (typeof args.description === "string")
|
|
812
|
+
changes.description = args.description.trim();
|
|
813
|
+
if (typeof args.soul === "string")
|
|
814
|
+
changes.soul = args.soul;
|
|
815
|
+
if (typeof args.cwd === "string")
|
|
816
|
+
changes.cwd = args.cwd.trim();
|
|
817
|
+
if (!Object.keys(changes).length) {
|
|
818
|
+
return { text: "propose_profile needs at least one of name, title, description, soul, or cwd.", isError: true };
|
|
819
|
+
}
|
|
820
|
+
const forBotId = String(args.for_bot_id ?? "").trim();
|
|
821
|
+
const r = await api("/api/internal/profile-requests", {
|
|
822
|
+
method: "POST",
|
|
823
|
+
body: JSON.stringify({
|
|
824
|
+
fromBotId: BOT_ID,
|
|
825
|
+
fromThreadId: THREAD_ID,
|
|
826
|
+
changes,
|
|
827
|
+
reason: args.reason,
|
|
828
|
+
// JSON.stringify drops the key entirely when no target was named
|
|
829
|
+
forBotId: forBotId || undefined,
|
|
830
|
+
}),
|
|
831
|
+
});
|
|
832
|
+
return confirmationResult(r, "the profile change", "profile");
|
|
833
|
+
}
|
|
773
834
|
if (name === "session_search") {
|
|
774
835
|
const q = String(args.query ?? "").trim();
|
|
775
836
|
if (!q)
|
|
@@ -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,25 +143,41 @@ 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;
|
|
152
|
+
/** Settles once the runtime process is gone. On Windows a running
|
|
153
|
+
* executable pins its file and its directory, so an installer must not
|
|
154
|
+
* rename or delete either until this settles. */
|
|
155
|
+
exited;
|
|
130
156
|
constructor(runtime, profile, cwd, onAuthorizationUrl) {
|
|
131
157
|
this.onAuthorizationUrl = onAuthorizationUrl;
|
|
132
158
|
this.child = spawnCli(runtime.executablePath, process.platform === "linux" ? ["--uid="] : [], { cwd, env: profile.environment, stdio: ["pipe", "pipe", "pipe"] });
|
|
133
159
|
this.child.stdout.setEncoding("utf8");
|
|
134
160
|
this.child.stdout.on("data", (chunk) => this.consume(chunk));
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// 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.
|
|
138
163
|
this.child.stderr.setEncoding("utf8");
|
|
139
164
|
this.child.stderr.on("data", (chunk) => this.consumeDiagnostics(chunk));
|
|
140
165
|
this.child.once("error", (error) => this.failAll(error));
|
|
141
|
-
this.
|
|
142
|
-
|
|
143
|
-
this.
|
|
166
|
+
this.exited = new Promise((resolve) => {
|
|
167
|
+
this.child.once("close", (code, signal) => {
|
|
168
|
+
this.noteStartupDiagnostic(this.diagnosticBuffer);
|
|
169
|
+
this.diagnosticBuffer = "";
|
|
170
|
+
if (!this.closed)
|
|
171
|
+
this.failAll(new Error(`Antigravity ACP exited ${code ?? signal ?? "unexpectedly"}.${this.nativeStartupHint ? ` ${this.nativeStartupHint}` : ""}`));
|
|
172
|
+
resolve();
|
|
173
|
+
});
|
|
174
|
+
// Failed spawns also emit `close`. An `error` alone can instead mean
|
|
175
|
+
// a failed kill, and is not evidence that the runtime stopped.
|
|
144
176
|
});
|
|
145
177
|
}
|
|
146
178
|
consume(chunk) {
|
|
179
|
+
if (!this.initializationComplete)
|
|
180
|
+
this.startupOutputBytes += Buffer.byteLength(chunk);
|
|
147
181
|
this.buffer += chunk;
|
|
148
182
|
if (Buffer.byteLength(this.buffer) > MAX_PROTOCOL_LINE_BYTES) {
|
|
149
183
|
this.failAll(new Error("Antigravity sent a protocol line that is too large."));
|
|
@@ -197,11 +231,19 @@ export class AntigravityAcpClient {
|
|
|
197
231
|
* and released immediately and the tail is capped, so no authorization code
|
|
198
232
|
* is ever held. Draining also keeps the pipe from stalling the server. */
|
|
199
233
|
consumeDiagnostics(chunk) {
|
|
234
|
+
if (!this.initializationComplete)
|
|
235
|
+
this.startupDiagnosticBytes += Buffer.byteLength(chunk);
|
|
200
236
|
const { lines, rest } = takeLines(this.diagnosticBuffer + chunk);
|
|
201
237
|
// A partial line already longer than any legal link cannot become one.
|
|
202
238
|
this.diagnosticBuffer = rest.length > MAX_DIAGNOSTIC_LINE_CHARS ? "" : rest;
|
|
203
|
-
for (const line of lines)
|
|
204
|
-
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);
|
|
205
247
|
}
|
|
206
248
|
failAll(error) {
|
|
207
249
|
for (const pending of this.pending.values()) {
|
|
@@ -218,7 +260,16 @@ export class AntigravityAcpClient {
|
|
|
218
260
|
return new Promise((resolveRequest, reject) => {
|
|
219
261
|
const timer = setTimeout(() => {
|
|
220
262
|
this.pending.delete(id);
|
|
221
|
-
|
|
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.`));
|
|
222
273
|
}, timeoutMs);
|
|
223
274
|
timer.unref?.();
|
|
224
275
|
this.pending.set(id, { resolve: resolveRequest, reject, timer });
|
|
@@ -226,11 +277,14 @@ export class AntigravityAcpClient {
|
|
|
226
277
|
});
|
|
227
278
|
}
|
|
228
279
|
async initialize(timeoutMs = STARTUP_TIMEOUT_MS) {
|
|
229
|
-
|
|
280
|
+
const initialized = await this.request("initialize", {
|
|
230
281
|
protocolVersion: 1,
|
|
231
282
|
clientInfo: { name: "openmausbot", version: "0.0.0" },
|
|
232
283
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
233
284
|
}, timeoutMs);
|
|
285
|
+
this.initializationComplete = true;
|
|
286
|
+
this.nativeStartupHint = undefined;
|
|
287
|
+
return initialized;
|
|
234
288
|
}
|
|
235
289
|
close() {
|
|
236
290
|
if (this.closed)
|
|
@@ -239,6 +293,22 @@ export class AntigravityAcpClient {
|
|
|
239
293
|
this.failAll(new Error("Antigravity ACP was closed."));
|
|
240
294
|
killCliTree(this.child);
|
|
241
295
|
}
|
|
296
|
+
/** Close, then wait (bounded) for the process to be gone. `killCliTree`
|
|
297
|
+
* on Windows asks taskkill and returns at once; the exit lands later. */
|
|
298
|
+
async closeAndWait(timeoutMs = 5_000) {
|
|
299
|
+
this.close();
|
|
300
|
+
let timer;
|
|
301
|
+
const timedOut = new Promise((resolve) => {
|
|
302
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
303
|
+
timer.unref?.();
|
|
304
|
+
});
|
|
305
|
+
try {
|
|
306
|
+
return await Promise.race([this.exited.then(() => true), timedOut]);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
242
312
|
}
|
|
243
313
|
export function parseAntigravityAuthorizationUrl(authorizationUrl) {
|
|
244
314
|
if (authorizationUrl.length > 16_384 || /\s/u.test(authorizationUrl)) {
|
|
@@ -407,26 +477,55 @@ export function isValidAntigravityInitializeResult(initialized, expectedVersion)
|
|
|
407
477
|
*/
|
|
408
478
|
export async function validateAntigravityRuntime(runtime, expectedVersion) {
|
|
409
479
|
const profileDirectory = await mkdtemp(join(tmpdir(), "openmaus-antigravity-verify-"));
|
|
480
|
+
let client;
|
|
481
|
+
let failed = false;
|
|
482
|
+
let failure;
|
|
410
483
|
try {
|
|
411
484
|
const profile = await prepareAntigravityProfile({
|
|
412
485
|
instanceId: `verify-${randomUUID()}`,
|
|
413
486
|
runtime,
|
|
414
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,
|
|
415
494
|
});
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
throw new Error("The download did not identify as the expected Google Antigravity ACP release.");
|
|
421
|
-
}
|
|
495
|
+
client = new AntigravityAcpClient(runtime, profile, profileDirectory);
|
|
496
|
+
const initialized = await client.initialize();
|
|
497
|
+
if (!isValidAntigravityInitializeResult(initialized, expectedVersion)) {
|
|
498
|
+
throw new Error("The download did not identify as the expected Google Antigravity ACP release.");
|
|
422
499
|
}
|
|
423
|
-
|
|
424
|
-
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
failed = true;
|
|
503
|
+
failure = error;
|
|
504
|
+
}
|
|
505
|
+
// The caller is about to rename the directory this executable runs from.
|
|
506
|
+
// Neither it nor the profile is safe to touch before confirmed close.
|
|
507
|
+
let stopped = !client;
|
|
508
|
+
try {
|
|
509
|
+
if (client) {
|
|
510
|
+
stopped = await client.closeAndWait();
|
|
511
|
+
if (!stopped)
|
|
512
|
+
throw new Error("Antigravity did not shut down after runtime verification.");
|
|
425
513
|
}
|
|
426
514
|
}
|
|
427
|
-
|
|
428
|
-
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (!failed) {
|
|
517
|
+
failed = true;
|
|
518
|
+
failure = error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (stopped) {
|
|
522
|
+
await rm(profileDirectory, { recursive: true, force: true, maxRetries: 4, retryDelay: 250 })
|
|
523
|
+
.catch((error) => {
|
|
524
|
+
console.warn(`antigravity: could not remove verification profile ${profileDirectory}: ${error instanceof Error ? error.message : String(error)}`);
|
|
525
|
+
});
|
|
429
526
|
}
|
|
527
|
+
if (failed)
|
|
528
|
+
throw failure;
|
|
430
529
|
}
|
|
431
530
|
/** One in-flight personal Google login per provider instance. */
|
|
432
531
|
export class AntigravityAuthController {
|
|
@@ -11,6 +11,33 @@ import { augmentedPath } from "../env-path.js";
|
|
|
11
11
|
import { resolveAntigravityReleaseAsset, } from "./antigravity-release.js";
|
|
12
12
|
const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
|
|
13
13
|
const DOWNLOAD_TIMEOUT_MS = 45 * 60_000;
|
|
14
|
+
const STAGING_PREFIX = ".install-";
|
|
15
|
+
/** Windows refuses to rename or delete a file something still holds open,
|
|
16
|
+
* with EPERM/EBUSY/EACCES rather than a clear "in use": the runtime the
|
|
17
|
+
* validator just stopped (taskkill is asynchronous) or an antivirus scan of
|
|
18
|
+
* a brand-new executable. Retry briefly; persistent refusals still fail. */
|
|
19
|
+
export function transientWindowsFileError(error) {
|
|
20
|
+
const code = typeof error === "object" && error !== null ? Reflect.get(error, "code") : undefined;
|
|
21
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOTEMPTY";
|
|
22
|
+
}
|
|
23
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
/** Retry a filesystem step on the transient Windows refusals above, with
|
|
25
|
+
* growing delays (default: 8 tries, 0.25 s → 2 s, about 10 s in total).
|
|
26
|
+
* Anything else, and the final failure, surface unchanged. */
|
|
27
|
+
export async function retryOnWindowsFileLock(step, options = {}) {
|
|
28
|
+
const attempts = options.attempts ?? 8;
|
|
29
|
+
const sleep = options.sleep ?? wait;
|
|
30
|
+
for (let attempt = 1;; attempt += 1) {
|
|
31
|
+
try {
|
|
32
|
+
return await step();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (attempt >= attempts || !transientWindowsFileError(error))
|
|
36
|
+
throw error;
|
|
37
|
+
await sleep(Math.min(2_000, (options.delayMs ?? 250) * 2 ** (attempt - 1)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
14
41
|
const INSTALL_RECORD = ".install-complete.json";
|
|
15
42
|
export class AntigravitySetupError extends Error {
|
|
16
43
|
status = 409;
|
|
@@ -293,7 +320,10 @@ async function installOnce(options) {
|
|
|
293
320
|
// statfs is unavailable on a few filesystems. The bounded download and
|
|
294
321
|
// extraction checks still fail safely if the disk fills.
|
|
295
322
|
}
|
|
296
|
-
|
|
323
|
+
// Another app/server process may be installing into this versions folder.
|
|
324
|
+
// The in-memory coalescing map cannot protect its staging directory.
|
|
325
|
+
const staging = await mkdtemp(join(versions, `${STAGING_PREFIX}${randomUUID()}-`));
|
|
326
|
+
let failed = false;
|
|
297
327
|
try {
|
|
298
328
|
const archivePath = join(staging, "download.zip");
|
|
299
329
|
const runtimeDirectory = join(staging, "runtime");
|
|
@@ -364,7 +394,7 @@ async function installOnce(options) {
|
|
|
364
394
|
};
|
|
365
395
|
await writeFile(join(runtimeDirectory, INSTALL_RECORD), `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
366
396
|
try {
|
|
367
|
-
await rename(runtimeDirectory, destination);
|
|
397
|
+
await retryOnWindowsFileLock(() => rename(runtimeDirectory, destination));
|
|
368
398
|
}
|
|
369
399
|
catch (error) {
|
|
370
400
|
const winner = await completedRelease(destination, asset);
|
|
@@ -376,8 +406,22 @@ async function installOnce(options) {
|
|
|
376
406
|
throw new Error("The installed Antigravity runtime is incomplete.");
|
|
377
407
|
return installed;
|
|
378
408
|
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
failed = true;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
379
413
|
finally {
|
|
380
|
-
|
|
414
|
+
// Cleanup must never replace the real error with its own: a user who
|
|
415
|
+
// saw "EPERM: unlink agy_acp_server.exe" was looking at this line, not
|
|
416
|
+
// at why the install failed. Only clean up this attempt's directory;
|
|
417
|
+
// if it stays locked, log and leave it without touching other attempts.
|
|
418
|
+
try {
|
|
419
|
+
await retryOnWindowsFileLock(() => rm(staging, { recursive: true, force: true }));
|
|
420
|
+
}
|
|
421
|
+
catch (cleanupError) {
|
|
422
|
+
const detail = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
423
|
+
console.warn(`antigravity: could not remove the install folder ${staging} (${detail})${failed ? "" : "; the install itself succeeded"}`);
|
|
424
|
+
}
|
|
381
425
|
}
|
|
382
426
|
}
|
|
383
427
|
/** Coalesce concurrent clicks so only one large Google artifact is fetched. */
|