openmausbot 0.1.83 → 0.1.85
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-Ba9G44HI.js +310 -0
- package/dist/assets/{index-nqTFZTxg.js → index-CTNNoqSJ.js} +1 -1
- package/dist/assets/index-CkRNp7wX.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +1 -0
- package/dist-server/container-mcp.js +212 -7
- package/dist-server/drivers/agents-proxy.js +161 -12
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +8025 -5661
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/openmausbot.js +796 -309
- package/dist-server/pair-cli.js +796 -309
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/bot-package.js +2 -0
- package/dist-server/server/box.js +23 -4
- package/dist-server/server/browser-engine.js +87 -10
- package/dist-server/server/browser-live.js +7 -5
- package/dist-server/server/browser-runtime.js +65 -10
- package/dist-server/server/checkpoints.js +71 -23
- package/dist-server/server/chief-of-staff.js +3 -0
- package/dist-server/server/cli-prompts.js +3 -1
- package/dist-server/server/commands.js +27 -0
- package/dist-server/server/compaction-summary.js +78 -0
- package/dist-server/server/computer-wait.js +33 -0
- package/dist-server/server/config.js +84 -3
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -0
- package/dist-server/server/delta-context.js +258 -0
- package/dist-server/server/digest.js +130 -0
- package/dist-server/server/drivers/acp/core.js +540 -229
- package/dist-server/server/drivers/agents-proxy.js +79 -12
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/claude.js +84 -15
- package/dist-server/server/drivers/codex.js +19 -5
- package/dist-server/server/drivers/openai-chat.js +66 -45
- package/dist-server/server/drivers/openai-compat.js +1 -0
- package/dist-server/server/hooks/omb-hook.js +103 -0
- package/dist-server/server/incidents.js +104 -0
- package/dist-server/server/index.js +1382 -318
- package/dist-server/server/local-computer.js +21 -2
- package/dist-server/server/mcp-bridge.js +10 -2
- package/dist-server/server/mcp-tool-images.js +37 -0
- package/dist-server/server/mcp-tool-schema.js +108 -0
- package/dist-server/server/message-db.js +80 -21
- package/dist-server/server/message-file.js +3 -2
- package/dist-server/server/notify.js +3 -1
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/peer-roster.js +4 -2
- package/dist-server/server/provider-icon.js +20 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/request-auth.js +1 -0
- package/dist-server/server/room-handoffs.js +16 -2
- package/dist-server/server/routine-requests.js +39 -1
- package/dist-server/server/routines.js +43 -9
- package/dist-server/server/shared-computer-control.js +31 -4
- package/dist-server/server/steer-queue.js +6 -0
- package/dist-server/server/store.js +111 -14
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +9 -1
- package/dist-server/server/tool-results.js +72 -0
- package/dist-server/server/tts/grok.js +74 -0
- package/dist-server/server/tts/index.js +19 -1
- package/dist-server/server/webhooks.js +28 -0
- package/dist-server/shared/approval-mode.js +7 -1
- package/dist-server/shared/digest.js +1 -0
- package/dist-server/shared/markdown-windows-paths.js +36 -0
- package/dist-server/shared/provider-icon.js +110 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/vps-container-mcp.js +217 -12
- package/enterprise/server/index.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-CKysBq-V.css +0 -1
- package/dist/assets/index-DSvqfBOf.js +0 -309
|
@@ -18,6 +18,7 @@ var SPAWNED_PROXIES = {
|
|
|
18
18
|
connectors: resolveProxy("connector-proxy"),
|
|
19
19
|
mcpGate: resolveProxy("mcp-gate"),
|
|
20
20
|
phone: resolveProxy("drivers/phone-proxy"),
|
|
21
|
+
hook: resolveProxy("hooks/omb-hook"),
|
|
21
22
|
// Loaded by the external `pi` process via `-e`, not by this server — but
|
|
22
23
|
// resolved through the same single source of truth so the packaged layout
|
|
23
24
|
// check can assert it ships.
|
|
@@ -170,6 +170,7 @@ const packageSchema = z.object({
|
|
|
170
170
|
schedule: packageRoutineScheduleSchema,
|
|
171
171
|
durationMinutes: z.number().int().min(5).max(240),
|
|
172
172
|
timeoutMinutes: z.number().int().min(5).max(240).optional(),
|
|
173
|
+
overlap: z.enum(["skip", "queue"]).optional(),
|
|
173
174
|
enabledAfterInstall: z.literal(false),
|
|
174
175
|
})).max(50).optional(),
|
|
175
176
|
playbooks: z.array(z.object({
|
|
@@ -339,6 +340,7 @@ export function renderBotPackageMarkdown(document) {
|
|
|
339
340
|
? intervalScheduleText(routine.schedule)
|
|
340
341
|
: `once at ${routine.schedule.at}`} `,
|
|
341
342
|
`**Run limit:** ${routine.timeoutMinutes === undefined ? "none" : `${routine.timeoutMinutes} minutes`} `,
|
|
343
|
+
`**While busy:** ${routine.overlap === "queue" ? "queue one scheduled run" : "skip scheduled occurrences"} `,
|
|
342
344
|
"**Initial state:** paused — the user must enable it",
|
|
343
345
|
"",
|
|
344
346
|
routine.prompt,
|
|
@@ -1229,23 +1229,42 @@ export function panelShotCommand({ width = PANEL_FRAME_WIDTH, quality = PANEL_FR
|
|
|
1229
1229
|
].join("; ");
|
|
1230
1230
|
}
|
|
1231
1231
|
const SHOT_CMD = panelShotCommand();
|
|
1232
|
+
// A compromised box can answer with an arbitrarily large "frame"; cap what
|
|
1233
|
+
// the server ever buffers for one (raw bytes, before base64) so a single
|
|
1234
|
+
// response cannot exhaust memory.
|
|
1235
|
+
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
1236
|
+
const FRAME_TOO_LARGE = "the box frame exceeds the 8 MB limit";
|
|
1232
1237
|
/** Read a file off the box as base64 — raw artifact bytes when the API
|
|
1233
1238
|
* supports it (33% less transfer, no JSON envelope), else the files API. */
|
|
1234
1239
|
async function readFileBase64(cfg, boxId, path) {
|
|
1240
|
+
let bytes = null;
|
|
1241
|
+
let tooLarge = false;
|
|
1235
1242
|
try {
|
|
1236
1243
|
const res = await boxFetch(cfg, `/boxes/${boxId}/artifacts?path=${encodeURIComponent(path)}`);
|
|
1237
1244
|
if (res.ok) {
|
|
1238
|
-
const
|
|
1239
|
-
if (
|
|
1240
|
-
|
|
1245
|
+
const declaredLength = res.headers.get("content-length");
|
|
1246
|
+
if (declaredLength !== null && Number(declaredLength) > MAX_FRAME_BYTES)
|
|
1247
|
+
tooLarge = true;
|
|
1248
|
+
else
|
|
1249
|
+
bytes = Buffer.from(await res.arrayBuffer());
|
|
1241
1250
|
}
|
|
1242
1251
|
}
|
|
1243
1252
|
catch {
|
|
1244
1253
|
/* fall through */
|
|
1245
1254
|
}
|
|
1255
|
+
if (tooLarge || (bytes !== null && bytes.length > MAX_FRAME_BYTES)) {
|
|
1256
|
+
throw new Error(FRAME_TOO_LARGE);
|
|
1257
|
+
}
|
|
1258
|
+
if (bytes?.length)
|
|
1259
|
+
return bytes.toString("base64");
|
|
1246
1260
|
const { ok, body } = await boxJson(cfg, `/boxes/${boxId}/files?path=${encodeURIComponent(path)}&encoding=base64`);
|
|
1247
1261
|
const content = body?.content;
|
|
1248
|
-
|
|
1262
|
+
if (ok && typeof content === "string" && content) {
|
|
1263
|
+
if (Buffer.byteLength(content, "base64") > MAX_FRAME_BYTES)
|
|
1264
|
+
throw new Error(FRAME_TOO_LARGE);
|
|
1265
|
+
return content;
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1249
1268
|
}
|
|
1250
1269
|
/** `knownBoxId` skips box resolution entirely — the screen poller holds
|
|
1251
1270
|
* the id for the whole turn and must not re-resolve it every frame. */
|
|
@@ -25,7 +25,7 @@ const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
|
25
25
|
/** Native restore looks up a filename prefix. Fixed-length keys prevent a
|
|
26
26
|
* legacy profile named work from accidentally restoring work-client. */
|
|
27
27
|
export function browserRestoreKey(session) {
|
|
28
|
-
if (!/^[A-Za-z0-9_
|
|
28
|
+
if (!/^[A-Za-z0-9_.-]{1,96}$/.test(session))
|
|
29
29
|
throw new Error("Invalid browser session.");
|
|
30
30
|
return `omb-${createHash("sha256").update(session).digest("hex")}`;
|
|
31
31
|
}
|
|
@@ -56,7 +56,7 @@ function ensureManagedBrowserConfig(env) {
|
|
|
56
56
|
}
|
|
57
57
|
/** Close exactly one daemon without the CLI's implicit launch envelope. */
|
|
58
58
|
export async function closeBrowserSession(binaryPath, env, timeoutMs = 15_000) {
|
|
59
|
-
if (!env.AGENT_BROWSER_SESSION || !/^[A-Za-z0-9_
|
|
59
|
+
if (!env.AGENT_BROWSER_SESSION || !/^[A-Za-z0-9_.-]{1,96}$/.test(env.AGENT_BROWSER_SESSION))
|
|
60
60
|
return false;
|
|
61
61
|
// Native CLI prepends a launch even to `close` when launch flags are set.
|
|
62
62
|
// Remove them, and bypass external project/user config, so closing a
|
|
@@ -85,7 +85,16 @@ export async function closeBrowserSession(binaryPath, env, timeoutMs = 15_000) {
|
|
|
85
85
|
catch {
|
|
86
86
|
return finish(false);
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
let exited = false;
|
|
89
|
+
let killTimer;
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
// TERM lets the daemon flush; KILL after a grace period reaps a stuck one.
|
|
92
|
+
child.kill("SIGTERM");
|
|
93
|
+
killTimer = setTimeout(() => { if (!exited)
|
|
94
|
+
child.kill("SIGKILL"); }, 5_000);
|
|
95
|
+
killTimer.unref?.();
|
|
96
|
+
finish(false);
|
|
97
|
+
}, Math.max(1, deadline - Date.now()));
|
|
89
98
|
timer.unref?.();
|
|
90
99
|
child.stdout?.on("data", (chunk) => {
|
|
91
100
|
output += String(chunk);
|
|
@@ -95,9 +104,9 @@ export async function closeBrowserSession(binaryPath, env, timeoutMs = 15_000) {
|
|
|
95
104
|
finish(false);
|
|
96
105
|
}
|
|
97
106
|
});
|
|
98
|
-
child.on("error", () => { clearTimeout(timer); finish(false); });
|
|
107
|
+
child.on("error", () => { exited = true; clearTimeout(timer); clearTimeout(killTimer); finish(false); });
|
|
99
108
|
// exit can precede the last piped stdout chunk; close follows stdio.
|
|
100
|
-
child.on("close", (code) => { clearTimeout(timer); finish(code === 0); });
|
|
109
|
+
child.on("close", (code) => { exited = true; clearTimeout(timer); clearTimeout(killTimer); finish(code === 0); });
|
|
101
110
|
});
|
|
102
111
|
if (!(await run(["close"])).ok)
|
|
103
112
|
return false;
|
|
@@ -241,6 +250,8 @@ export function resolveAgentBrowserBinary(options = {}) {
|
|
|
241
250
|
const pinned = pinnedBinaryPath(options.dataDir, platform, options.arch);
|
|
242
251
|
if (exists(pinned))
|
|
243
252
|
return pinned;
|
|
253
|
+
if (options.managedOnly)
|
|
254
|
+
return null;
|
|
244
255
|
return onPath(env, platform, exists);
|
|
245
256
|
}
|
|
246
257
|
/** Download the pinned release asset for this machine into the data dir,
|
|
@@ -263,7 +274,35 @@ export async function installAgentBrowserBinary(options = {}) {
|
|
|
263
274
|
const response = await (options.fetchImpl ?? fetch)(url, { redirect: "follow", signal: controller.signal });
|
|
264
275
|
if (!response.ok)
|
|
265
276
|
throw new Error(`the agent-browser download failed (HTTP ${response.status})`);
|
|
266
|
-
|
|
277
|
+
const declared = Number(response.headers.get("content-length") ?? NaN);
|
|
278
|
+
if (Number.isFinite(declared) && declared !== asset.bytes)
|
|
279
|
+
throw new Error("the agent-browser download did not match its pinned size; nothing was installed");
|
|
280
|
+
// Stream, never buffer whole: a body past the pin is cut off mid-flight.
|
|
281
|
+
const chunks = [];
|
|
282
|
+
let received = 0;
|
|
283
|
+
if (response.body) {
|
|
284
|
+
try {
|
|
285
|
+
for await (const chunk of response.body) {
|
|
286
|
+
chunks.push(chunk);
|
|
287
|
+
received += chunk.byteLength;
|
|
288
|
+
if (received > asset.bytes) {
|
|
289
|
+
controller.abort();
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
// Only the abort we asked for is swallowed; the size check reports it.
|
|
296
|
+
if (received <= asset.bytes)
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
body = Buffer.concat(chunks);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
body = Buffer.from(await response.arrayBuffer());
|
|
303
|
+
if (body.length > asset.bytes)
|
|
304
|
+
controller.abort();
|
|
305
|
+
}
|
|
267
306
|
}
|
|
268
307
|
finally {
|
|
269
308
|
clearTimeout(timer);
|
|
@@ -273,6 +312,18 @@ export async function installAgentBrowserBinary(options = {}) {
|
|
|
273
312
|
const digest = createHash("sha256").update(body).digest("hex");
|
|
274
313
|
if (digest !== asset.sha256)
|
|
275
314
|
throw new Error("the agent-browser download failed its SHA-256 check; nothing was installed");
|
|
315
|
+
// Sweep staging files abandoned by earlier runs before writing a new one.
|
|
316
|
+
try {
|
|
317
|
+
for (const name of readdirSync(directory)) {
|
|
318
|
+
if (!name.endsWith(".part"))
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(join(directory, name));
|
|
322
|
+
}
|
|
323
|
+
catch { /* already gone */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch { /* best effort */ }
|
|
276
327
|
const staging = `${destination}.${randomBytes(6).toString("hex")}.part`;
|
|
277
328
|
writeFileSync(staging, body, { mode: 0o755 });
|
|
278
329
|
if (platform !== "win32")
|
|
@@ -293,10 +344,34 @@ export function ensureChrome(binaryPath, options = {}) {
|
|
|
293
344
|
return new Promise((done, fail) => {
|
|
294
345
|
const child = spawn(binaryPath, args, { env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
295
346
|
let output = "";
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
347
|
+
// Keep only the tail: the failure path quotes the last lines, so a chatty
|
|
348
|
+
// installer cannot grow the buffer without bound while it hangs.
|
|
349
|
+
const record = (chunk) => {
|
|
350
|
+
output += String(chunk);
|
|
351
|
+
if (output.length > 262_144)
|
|
352
|
+
output = output.slice(-262_144);
|
|
353
|
+
};
|
|
354
|
+
child.stdout?.on("data", record);
|
|
355
|
+
child.stderr?.on("data", record);
|
|
356
|
+
let timedOut = false;
|
|
357
|
+
let exited = false;
|
|
358
|
+
// A hung installer gets the same wall clock as the download: TERM, then
|
|
359
|
+
// KILL after a grace period, with the promise failing at the deadline.
|
|
360
|
+
const timer = setTimeout(() => {
|
|
361
|
+
timedOut = true;
|
|
362
|
+
child.kill("SIGTERM");
|
|
363
|
+
const killTimer = setTimeout(() => { if (!exited)
|
|
364
|
+
child.kill("SIGKILL"); }, 5_000);
|
|
365
|
+
killTimer.unref?.();
|
|
366
|
+
fail(new Error("agent-browser install timed out"));
|
|
367
|
+
}, DOWNLOAD_TIMEOUT_MS);
|
|
368
|
+
timer.unref?.();
|
|
369
|
+
child.on("error", (error) => { exited = true; clearTimeout(timer); fail(error); });
|
|
299
370
|
child.on("exit", (code) => {
|
|
371
|
+
exited = true;
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
if (timedOut)
|
|
374
|
+
return; // already failed with the timeout error
|
|
300
375
|
if (code === 0) {
|
|
301
376
|
options.log?.("agent-browser: Chrome is ready");
|
|
302
377
|
done();
|
|
@@ -369,6 +444,8 @@ export function agentBrowserIntegration(input) {
|
|
|
369
444
|
};
|
|
370
445
|
if (input.headless !== false)
|
|
371
446
|
env.AGENT_BROWSER_HEADLESS = "1";
|
|
447
|
+
if (input.attachCdpUrl)
|
|
448
|
+
env.AGENT_BROWSER_CDP = input.attachCdpUrl;
|
|
372
449
|
// MCP clients may filter the parent environment. Carry the configured
|
|
373
450
|
// Chrome path explicitly without forwarding unrelated secrets or flags.
|
|
374
451
|
for (const name of ["PATH", "AGENT_BROWSER_EXECUTABLE_PATH"]) {
|
|
@@ -464,7 +541,7 @@ export const BUILT_IN_BROWSER_SYSTEM_PROMPT = " You have your own web browser th
|
|
|
464
541
|
export async function clearBrowserSessionState(binaryPath, session, options = {}) {
|
|
465
542
|
// The native state-clear CLI ignores the daemon session, and even its named
|
|
466
543
|
// form currently drops that name before dispatch. Never invoke it here.
|
|
467
|
-
if (!/^[A-Za-z0-9_
|
|
544
|
+
if (!/^[A-Za-z0-9_.-]{1,96}$/.test(session))
|
|
468
545
|
return false;
|
|
469
546
|
const env = browserRuntimeEnv({ ...options.env, AGENT_BROWSER_SESSION: session, AGENT_BROWSER_HEADLESS: "1" });
|
|
470
547
|
let directory;
|
|
@@ -45,7 +45,7 @@ export function normalizeBrowserLiveMessage(value) {
|
|
|
45
45
|
if (!message)
|
|
46
46
|
return null;
|
|
47
47
|
if (message.type === "error")
|
|
48
|
-
return { type: "error", message: "The browser stream was interrupted.
|
|
48
|
+
return { type: "error", retryable: true, message: "The browser stream was interrupted." };
|
|
49
49
|
if (message.type === "url")
|
|
50
50
|
return { type: "url", url: displayUrl(message.url) };
|
|
51
51
|
if (message.type === "tabs" && Array.isArray(message.tabs) && message.tabs.length <= 100) {
|
|
@@ -292,7 +292,7 @@ export class BrowserLive {
|
|
|
292
292
|
const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
|
|
293
293
|
try {
|
|
294
294
|
const { stdout } = await execute(viewer.spec.command, [...args, "--json", "--no-webmcp"], {
|
|
295
|
-
env, timeout: 30_000, maxBuffer: 1024 * 1024, encoding: "utf8", windowsHide: true,
|
|
295
|
+
env, timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, encoding: "utf8", windowsHide: true,
|
|
296
296
|
});
|
|
297
297
|
if (!this.current(viewer))
|
|
298
298
|
throw new Error("stale viewer");
|
|
@@ -302,7 +302,8 @@ export class BrowserLive {
|
|
|
302
302
|
throw new Error("browser command failed");
|
|
303
303
|
return data;
|
|
304
304
|
}
|
|
305
|
-
catch {
|
|
305
|
+
catch (error) {
|
|
306
|
+
console.warn("browser-live:", error);
|
|
306
307
|
throw new BrowserLiveError("The browser could not complete this action. Check that the browser engine is installed, then reconnect.", 503);
|
|
307
308
|
}
|
|
308
309
|
}
|
|
@@ -432,7 +433,7 @@ export class BrowserLive {
|
|
|
432
433
|
this.send(viewer, message);
|
|
433
434
|
});
|
|
434
435
|
socket.addEventListener("error", () => { if (!viewer.restarting) {
|
|
435
|
-
this.send(viewer, { type: "error", message: "The browser stream disconnected.
|
|
436
|
+
this.send(viewer, { type: "error", retryable: true, message: "The browser stream disconnected." });
|
|
436
437
|
this.close(viewer);
|
|
437
438
|
} });
|
|
438
439
|
socket.addEventListener("close", () => { if (!viewer.restarting)
|
|
@@ -461,8 +462,9 @@ export class BrowserLive {
|
|
|
461
462
|
viewer.heartbeat.unref();
|
|
462
463
|
}
|
|
463
464
|
catch (error) {
|
|
465
|
+
console.warn("browser-live:", error);
|
|
464
466
|
if (options.res.headersSent) {
|
|
465
|
-
this.send(viewer, { type: "error", message: "The browser stream could not start.
|
|
467
|
+
this.send(viewer, { type: "error", retryable: true, message: "The browser stream could not start." });
|
|
466
468
|
this.close(viewer);
|
|
467
469
|
return;
|
|
468
470
|
}
|
|
@@ -3,6 +3,9 @@ import { DEFAULT_BROWSER_RESULT_BUDGET, shapeBrowserToolResult, slimBrowserToolL
|
|
|
3
3
|
export const BROWSER_CONTROL_REFUSAL = "Browser tools are paused while a person controls this browser. Wait for them to hand control back; do not try another browser or execution tool.";
|
|
4
4
|
const MAX_REQUEST_BYTES = 1_048_576;
|
|
5
5
|
const MAX_RESPONSE_BYTES = 16_777_216;
|
|
6
|
+
/** Startup, not per-request work: a cold engine spawn can exceed a tight
|
|
7
|
+
* per-request budget before anything has been accepted to guard. */
|
|
8
|
+
const HANDSHAKE_TIMEOUT_MS = 1_000;
|
|
6
9
|
const HOST_ENV = ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATH", "Path", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR"];
|
|
7
10
|
/** MCP, viewer commands, and cleanup must resolve the same HOME/socket paths.
|
|
8
11
|
* Inherit OS plumbing, never the harness's model-provider credentials. */
|
|
@@ -27,11 +30,13 @@ class BrowserClient {
|
|
|
27
30
|
idleMs;
|
|
28
31
|
maxPending;
|
|
29
32
|
onClose;
|
|
30
|
-
|
|
33
|
+
onRequestTimeout;
|
|
34
|
+
constructor(spec, requestTimeoutMs, idleMs, maxPending, onClose, onRequestTimeout) {
|
|
31
35
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
32
36
|
this.idleMs = idleMs;
|
|
33
37
|
this.maxPending = maxPending;
|
|
34
38
|
this.onClose = onClose;
|
|
39
|
+
this.onRequestTimeout = onRequestTimeout;
|
|
35
40
|
this.child = spawnCli(spec.command, spec.args, {
|
|
36
41
|
env: browserRuntimeEnv(spec.env), stdio: ["pipe", "pipe", "pipe"], shell: false,
|
|
37
42
|
});
|
|
@@ -43,7 +48,7 @@ class BrowserClient {
|
|
|
43
48
|
this.ready = this.rpc("initialize", {
|
|
44
49
|
protocolVersion: "2024-11-05", capabilities: {},
|
|
45
50
|
clientInfo: { name: "openmausbot-browser", version: "1" },
|
|
46
|
-
}).then((result) => {
|
|
51
|
+
}, Math.max(this.requestTimeoutMs, HANDSHAKE_TIMEOUT_MS)).then((result) => {
|
|
47
52
|
if (!result || typeof result !== "object" || !("protocolVersion" in result)) {
|
|
48
53
|
throw new TransportError("Browser engine returned an invalid handshake.");
|
|
49
54
|
}
|
|
@@ -102,7 +107,7 @@ class BrowserClient {
|
|
|
102
107
|
void this.stop(new TransportError("Browser connection closed."));
|
|
103
108
|
});
|
|
104
109
|
}
|
|
105
|
-
rpc(method, params) {
|
|
110
|
+
rpc(method, params, timeoutMs = this.requestTimeoutMs) {
|
|
106
111
|
if (this.stopped)
|
|
107
112
|
return Promise.reject(new TransportError("Browser connection closed."));
|
|
108
113
|
if (this.pending.size >= this.maxPending)
|
|
@@ -114,7 +119,13 @@ class BrowserClient {
|
|
|
114
119
|
if (this.idleTimer)
|
|
115
120
|
clearTimeout(this.idleTimer);
|
|
116
121
|
return new Promise((resolve, reject) => {
|
|
117
|
-
const timer = setTimeout(() => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
// Seal the gate synchronously with the timer, before stop() teardown:
|
|
124
|
+
// the rejection can surface through the ready handshake, which sits
|
|
125
|
+
// outside agentRpc's uncertainty classifier.
|
|
126
|
+
this.onRequestTimeout();
|
|
127
|
+
void this.stop(new TransportError("Browser request timed out; restart the browser before taking control."));
|
|
128
|
+
}, timeoutMs);
|
|
118
129
|
timer.unref();
|
|
119
130
|
this.pending.set(id, { resolve, reject, timer });
|
|
120
131
|
try {
|
|
@@ -131,11 +142,35 @@ class BrowserClient {
|
|
|
131
142
|
if (!this.stopped && this.pending.size === 0) {
|
|
132
143
|
// Only the stateless MCP transport expires; saved browser state and the
|
|
133
144
|
// browser daemon itself belong to the profile, not this client.
|
|
134
|
-
this.idleTimer = setTimeout(() => { void this.stop(); }, this.idleMs);
|
|
145
|
+
this.idleTimer = setTimeout(() => { void this.stop(undefined, "transport"); }, this.idleMs);
|
|
135
146
|
this.idleTimer.unref();
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
|
-
|
|
149
|
+
/** Upstream's MCP loop exits on stdin EOF without closing the daemon.
|
|
150
|
+
* In particular, Windows taskkill /T would also kill that profile's Chrome,
|
|
151
|
+
* even when the daemon created a new process group. Idle is not shutdown. */
|
|
152
|
+
retireTransport() {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const finish = () => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
this.child.off("exit", finish);
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
try {
|
|
161
|
+
this.child.kill("SIGKILL");
|
|
162
|
+
}
|
|
163
|
+
catch { /* transport already exited */ }
|
|
164
|
+
finish();
|
|
165
|
+
}, 1_000);
|
|
166
|
+
this.child.once("exit", finish);
|
|
167
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null)
|
|
168
|
+
finish();
|
|
169
|
+
else
|
|
170
|
+
this.child.stdin.end();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
stop(error = new TransportError("Browser connection closed."), scope = "tree") {
|
|
139
174
|
if (this.stoppedPromise)
|
|
140
175
|
return this.stoppedPromise;
|
|
141
176
|
this.stopped = true;
|
|
@@ -148,7 +183,7 @@ class BrowserClient {
|
|
|
148
183
|
}
|
|
149
184
|
this.pending.clear();
|
|
150
185
|
this.onClose();
|
|
151
|
-
this.stoppedPromise = killCliTree(this.child, 1_000).then((stopped) => {
|
|
186
|
+
this.stoppedPromise = scope === "transport" ? this.retireTransport() : killCliTree(this.child, 1_000).then((stopped) => {
|
|
152
187
|
if (stopped)
|
|
153
188
|
return;
|
|
154
189
|
try {
|
|
@@ -211,6 +246,14 @@ export class BrowserRuntime {
|
|
|
211
246
|
async agentRpc(session, spec, method, params, beforeDispatch) {
|
|
212
247
|
if (method !== "tools/list" && method !== "tools/call")
|
|
213
248
|
throw new Error("Unsupported browser method.");
|
|
249
|
+
// tools/list bypasses withAgentAction (a human may hold control), so it
|
|
250
|
+
// must refuse the closing window itself or its client outlives restart().
|
|
251
|
+
if (method !== "tools/call" && this.gate(session).closing)
|
|
252
|
+
throw new Error("The browser is closing. Try again shortly.");
|
|
253
|
+
// Uncertainty survives client replacement and never self-resolves: refuse
|
|
254
|
+
// every new browser request until an explicit restart clears it.
|
|
255
|
+
if (this.gate(session).uncertain)
|
|
256
|
+
throw new Error("A browser action was interrupted. Restart this browser before continuing.");
|
|
214
257
|
const invoke = async () => {
|
|
215
258
|
const key = JSON.stringify([spec.command, spec.args, Object.entries(spec.env).sort(([a], [b]) => a.localeCompare(b))]);
|
|
216
259
|
let entry = this.clients.get(session);
|
|
@@ -220,6 +263,10 @@ export class BrowserRuntime {
|
|
|
220
263
|
const client = new BrowserClient(spec, this.options.requestTimeoutMs, this.options.idleMs, this.options.maxPending, () => {
|
|
221
264
|
if (this.clients.get(session)?.client === client)
|
|
222
265
|
this.clients.delete(session);
|
|
266
|
+
}, () => {
|
|
267
|
+
const gate = this.gate(session);
|
|
268
|
+
if (!gate.closing)
|
|
269
|
+
gate.uncertain = true;
|
|
223
270
|
});
|
|
224
271
|
entry = { key, client };
|
|
225
272
|
this.clients.set(session, entry);
|
|
@@ -242,8 +289,11 @@ export class BrowserRuntime {
|
|
|
242
289
|
catch (error) {
|
|
243
290
|
// An MCP timeout cannot prove the independent daemon stopped an
|
|
244
291
|
// accepted action. Recovery must close the browser, not just its pipe.
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
// A stop from close()/restart() is intentional, not uncertainty.
|
|
293
|
+
if (method === "tools/call" && error instanceof TransportError && !this.gate(session).closing) {
|
|
294
|
+
const gate = this.gate(session);
|
|
295
|
+
gate.uncertain = true;
|
|
296
|
+
}
|
|
247
297
|
throw error;
|
|
248
298
|
}
|
|
249
299
|
};
|
|
@@ -344,6 +394,10 @@ export class BrowserRuntime {
|
|
|
344
394
|
try {
|
|
345
395
|
await closeBrowser();
|
|
346
396
|
await this.clients.get(session)?.client.stop();
|
|
397
|
+
// A tools/list admitted before closing set in can register a client
|
|
398
|
+
// while that stop awaits; registration is synchronous, so one re-check
|
|
399
|
+
// is deterministic and no stray transport survives to idle expiry.
|
|
400
|
+
await this.clients.get(session)?.client.stop();
|
|
347
401
|
gate.uncertain = false;
|
|
348
402
|
gate.owner = null;
|
|
349
403
|
gate.releasing = false;
|
|
@@ -364,10 +418,11 @@ export class BrowserRuntime {
|
|
|
364
418
|
const gate = this.gate(session);
|
|
365
419
|
gate.closing = true;
|
|
366
420
|
gate.ready = false;
|
|
421
|
+
// Clear before the awaited stop: any uncertain latch after this point must win.
|
|
422
|
+
gate.uncertain = false;
|
|
367
423
|
this.changed(gate);
|
|
368
424
|
await this.clients.get(session)?.client.stop();
|
|
369
425
|
gate.closing = false;
|
|
370
|
-
gate.uncertain = false;
|
|
371
426
|
this.changed(gate);
|
|
372
427
|
if (!gate.owner && !gate.agents && !gate.humans)
|
|
373
428
|
this.gates.delete(session);
|
|
@@ -133,7 +133,7 @@ function disable(botId, message) {
|
|
|
133
133
|
let gitProbe = null;
|
|
134
134
|
function gitAvailable() {
|
|
135
135
|
gitProbe ??= new Promise((resolveProbe) => {
|
|
136
|
-
execFile("git", ["--version"], { windowsHide: true }, (err) => resolveProbe(!err));
|
|
136
|
+
execFile("git", ["--version"], { windowsHide: true, timeout: 5_000 }, (err) => resolveProbe(!err));
|
|
137
137
|
});
|
|
138
138
|
return gitProbe;
|
|
139
139
|
}
|
|
@@ -149,7 +149,7 @@ export function refusalReason(cwd) {
|
|
|
149
149
|
let dir;
|
|
150
150
|
try {
|
|
151
151
|
stat = statSync(requested);
|
|
152
|
-
dir = realpathSync(requested);
|
|
152
|
+
dir = realpathSync.native(requested);
|
|
153
153
|
}
|
|
154
154
|
catch {
|
|
155
155
|
return "the working folder does not exist";
|
|
@@ -158,16 +158,20 @@ export function refusalReason(cwd) {
|
|
|
158
158
|
return "the working folder is not a folder";
|
|
159
159
|
// Compare canonical paths too: otherwise /tmp/home-link -> $HOME bypasses
|
|
160
160
|
// the refusal while git still follows the symlink into the protected tree.
|
|
161
|
+
// The native realpath also settles Windows' spellings of one folder —
|
|
162
|
+
// c:\users\me, C:\Users\me\DOCUME~1 — and names compare case-insensitively
|
|
163
|
+
// there, as turn-resources.ts does for workspace claims.
|
|
164
|
+
const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
161
165
|
if (dir === parse(dir).root)
|
|
162
166
|
return "checkpoints are not taken at the filesystem root";
|
|
163
167
|
const requestedHome = resolve(homedir());
|
|
164
|
-
const home = existsSync(requestedHome) ? realpathSync(requestedHome) : requestedHome;
|
|
165
|
-
if (requested
|
|
168
|
+
const home = existsSync(requestedHome) ? realpathSync.native(requestedHome) : requestedHome;
|
|
169
|
+
if (same(requested, requestedHome) || same(dir, home))
|
|
166
170
|
return "checkpoints are not taken in the home folder";
|
|
167
171
|
for (const name of ["Desktop", "Documents", "Downloads"]) {
|
|
168
172
|
const requestedProtected = join(requestedHome, name);
|
|
169
|
-
const protectedDir = existsSync(requestedProtected) ? realpathSync(requestedProtected) : requestedProtected;
|
|
170
|
-
if (requested
|
|
173
|
+
const protectedDir = existsSync(requestedProtected) ? realpathSync.native(requestedProtected) : requestedProtected;
|
|
174
|
+
if (same(requested, requestedProtected) || same(dir, protectedDir)) {
|
|
171
175
|
return `checkpoints are not taken in the ${name} folder`;
|
|
172
176
|
}
|
|
173
177
|
}
|
|
@@ -201,9 +205,10 @@ function gitEnv(shadow, cwd) {
|
|
|
201
205
|
* "." pathspec in add/restore resolves relative to it. A hung git (index
|
|
202
206
|
* lock, dead network filesystem) would otherwise jam the per-repo queue for
|
|
203
207
|
* the whole session, so every call carries a hard timeout. */
|
|
204
|
-
function runGit(args, cwd, env) {
|
|
208
|
+
function runGit(args, cwd, env, signal) {
|
|
205
209
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
206
|
-
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
execFile("git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
207
212
|
if (err)
|
|
208
213
|
rejectPromise(new Error(`git ${args[0]}: ${(stderr || err.message).trim().slice(0, 400)}`));
|
|
209
214
|
else
|
|
@@ -215,9 +220,10 @@ function runGit(args, cwd, env) {
|
|
|
215
220
|
* instead of `status --porcelain` emptiness on purpose — a nested repo with
|
|
216
221
|
* a dirty work tree shows up in status forever while staging nothing, which
|
|
217
222
|
* would either commit empty churn every turn or fail the commit outright. */
|
|
218
|
-
function hasStagedChanges(cwd, env) {
|
|
223
|
+
function hasStagedChanges(cwd, env, signal) {
|
|
219
224
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
220
|
-
|
|
225
|
+
signal?.throwIfAborted();
|
|
226
|
+
execFile("git", ["diff", "--cached", "--quiet", "--ignore-submodules=dirty"], { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
|
221
227
|
if (err === null)
|
|
222
228
|
resolvePromise(false);
|
|
223
229
|
else if (err.code === 1)
|
|
@@ -244,46 +250,49 @@ function serialize(key, fn) {
|
|
|
244
250
|
* always resolves — it is filtered from listings and refused as a restore
|
|
245
251
|
* target, because "restore to empty" on a user's own project folder would
|
|
246
252
|
* delete their files. */
|
|
247
|
-
async function ensureShadow(cwd, env, shadow) {
|
|
253
|
+
async function ensureShadow(cwd, env, shadow, signal) {
|
|
254
|
+
signal?.throwIfAborted();
|
|
248
255
|
mkdirSync(shadow, { recursive: true, mode: 0o700 });
|
|
249
256
|
writeFileSync(join(shadow, "gitconfig"), GITCONFIG, { mode: 0o600 });
|
|
250
257
|
writeFileSync(join(shadow, "gitconfig_empty"), "", { mode: 0o600 });
|
|
251
258
|
if (!existsSync(join(shadow, ".git", "HEAD"))) {
|
|
252
259
|
// --template= keeps the user's init.templateDir hooks/config out
|
|
253
|
-
await runGit(["init", "--initial-branch=main", "--template="], cwd, env);
|
|
260
|
+
await runGit(["init", "--initial-branch=main", "--template="], cwd, env, signal);
|
|
254
261
|
}
|
|
255
262
|
mkdirSync(join(shadow, ".git", "info"), { recursive: true });
|
|
256
263
|
writeFileSync(join(shadow, ".git", "info", "exclude"), EXCLUDES);
|
|
257
264
|
try {
|
|
258
|
-
await runGit(["rev-parse", "--verify", "HEAD"], cwd, env);
|
|
265
|
+
await runGit(["rev-parse", "--verify", "HEAD"], cwd, env, signal);
|
|
259
266
|
}
|
|
260
267
|
catch {
|
|
261
268
|
// brand-new repo (or a crash between init and first commit)
|
|
262
|
-
await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env);
|
|
269
|
+
await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
272
|
/** Stage everything and commit if anything actually changed. `complete`
|
|
266
273
|
* records whether every path was indexable: ordinary snapshots may keep the
|
|
267
274
|
* useful subset, but restore must not delete files its safety point missed. */
|
|
268
|
-
async function commitAll(cwd, env, label) {
|
|
275
|
+
async function commitAll(cwd, env, label, signal) {
|
|
269
276
|
// --ignore-errors skips files git cannot index (unreadable, FIFOs) instead
|
|
270
277
|
// of aborting — but still exits 1 when it skipped an unreadable file, so
|
|
271
278
|
// the exit code is retained even though a partial snapshot remains useful.
|
|
272
279
|
// Restore treats an incomplete safety point as a hard stop before touching
|
|
273
280
|
// the work tree.
|
|
274
281
|
let complete = true;
|
|
275
|
-
await runGit(["add", "-A", "--ignore-errors", "."], cwd, env).catch(() => {
|
|
282
|
+
await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal).catch(() => {
|
|
276
283
|
complete = false;
|
|
277
284
|
});
|
|
278
|
-
if (await hasStagedChanges(cwd, env)) {
|
|
279
|
-
await runGit(["commit", "--no-verify", "-m", label], cwd, env);
|
|
285
|
+
if (await hasStagedChanges(cwd, env, signal)) {
|
|
286
|
+
await runGit(["commit", "--no-verify", "-m", label], cwd, env, signal);
|
|
280
287
|
}
|
|
281
|
-
return { hash: (await runGit(["rev-parse", "HEAD"], cwd, env)).trim(), complete };
|
|
288
|
+
return { hash: (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim(), complete };
|
|
282
289
|
}
|
|
283
290
|
/** Snapshot the folder. Returns the checkpoint hash, or null when the
|
|
284
291
|
* feature is off for this bot, git is missing, or the folder is refused.
|
|
285
292
|
* Never throws — this is called fire-and-forget on the turn path. */
|
|
286
|
-
export async function snapshot(botId, cwd, label) {
|
|
293
|
+
export async function snapshot(botId, cwd, label, signal) {
|
|
294
|
+
if (signal?.aborted)
|
|
295
|
+
return null;
|
|
287
296
|
if (disabledBots.has(botId))
|
|
288
297
|
return null;
|
|
289
298
|
if (!(await gitAvailable()))
|
|
@@ -295,12 +304,51 @@ export async function snapshot(botId, cwd, label) {
|
|
|
295
304
|
const shadow = shadowDir(botId, worktree);
|
|
296
305
|
return await serialize(shadow, async () => {
|
|
297
306
|
const env = gitEnv(shadow, worktree);
|
|
298
|
-
await ensureShadow(worktree, env, shadow);
|
|
299
|
-
return (await commitAll(worktree, env, label)).hash;
|
|
307
|
+
await ensureShadow(worktree, env, shadow, signal);
|
|
308
|
+
return (await commitAll(worktree, env, label, signal)).hash;
|
|
300
309
|
});
|
|
301
310
|
}
|
|
302
311
|
catch (e) {
|
|
303
|
-
|
|
312
|
+
if (!signal?.aborted)
|
|
313
|
+
disable(botId, e instanceof Error ? e.message : String(e));
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Paths that differ between two checkpoints of the same bot+folder, from
|
|
318
|
+
* `git diff --name-status` against the shadow repo. Null when the hashes
|
|
319
|
+
* are equal, the folder is refused or checkpoints are off, or git fails —
|
|
320
|
+
* the digest then simply omits its file list. Never throws (turn path). */
|
|
321
|
+
export async function diffStat(botId, cwd, fromHash, toHash, signal) {
|
|
322
|
+
if (signal?.aborted)
|
|
323
|
+
return null;
|
|
324
|
+
if (fromHash === toHash)
|
|
325
|
+
return null;
|
|
326
|
+
if (disabledBots.has(botId))
|
|
327
|
+
return null;
|
|
328
|
+
if (!(await gitAvailable()))
|
|
329
|
+
return null;
|
|
330
|
+
if (refusalReason(cwd) !== null)
|
|
331
|
+
return null;
|
|
332
|
+
try {
|
|
333
|
+
const worktree = realpathSync(resolve(cwd));
|
|
334
|
+
const shadow = shadowDir(botId, worktree);
|
|
335
|
+
const env = gitEnv(shadow, worktree);
|
|
336
|
+
const out = await serialize(shadow, () => runGit(["diff", "--name-status", "--no-renames", "-z", fromHash, toHash], worktree, env, signal));
|
|
337
|
+
const diff = { changed: [], added: [], deleted: [] };
|
|
338
|
+
const fields = out.split("\0").filter((field) => field.length > 0);
|
|
339
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
340
|
+
const status = fields[i];
|
|
341
|
+
const path = fields[i + 1];
|
|
342
|
+
if (status.startsWith("A"))
|
|
343
|
+
diff.added.push(path);
|
|
344
|
+
else if (status.startsWith("D"))
|
|
345
|
+
diff.deleted.push(path);
|
|
346
|
+
else
|
|
347
|
+
diff.changed.push(path);
|
|
348
|
+
}
|
|
349
|
+
return diff;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
304
352
|
return null;
|
|
305
353
|
}
|
|
306
354
|
}
|