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.
Files changed (49) hide show
  1. package/dist/assets/index-B433sv8K.css +1 -0
  2. package/dist/assets/{index-Dq2j4Ikr.js → index-CE7W1AIk.js} +1 -1
  3. package/dist/assets/index-r-WPT-Pu.js +264 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -1
  6. package/dist-server/connector-proxy.js +31 -12
  7. package/dist-server/container-mcp.js +49 -31
  8. package/dist-server/drivers/agents-proxy.js +56 -2
  9. package/dist-server/index.js +2126 -487
  10. package/dist-server/openmausbot.js +15219 -305
  11. package/dist-server/pair-cli.js +15219 -305
  12. package/dist-server/server/auto-approve.js +24 -0
  13. package/dist-server/server/bot-folder.js +102 -0
  14. package/dist-server/server/bot-overview.js +206 -0
  15. package/dist-server/server/bot-package.js +5 -0
  16. package/dist-server/server/bot-profile.js +7 -0
  17. package/dist-server/server/browser-engine-release.js +44 -0
  18. package/dist-server/server/browser-engine.js +185 -0
  19. package/dist-server/server/cli.js +55 -1
  20. package/dist-server/server/connector-proxy.js +37 -16
  21. package/dist-server/server/container-computer.js +9 -2
  22. package/dist-server/server/drivers/agents-proxy.js +63 -2
  23. package/dist-server/server/drivers/antigravity-acp.js +119 -20
  24. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  25. package/dist-server/server/drivers/antigravity.js +23 -2
  26. package/dist-server/server/drivers/claude.js +7 -1
  27. package/dist-server/server/index.js +634 -107
  28. package/dist-server/server/mcp-bridge.js +73 -44
  29. package/dist-server/server/package-export.js +1 -0
  30. package/dist-server/server/profile-requests.js +332 -0
  31. package/dist-server/server/profile-revision.js +15 -0
  32. package/dist-server/server/profile-versions.js +154 -0
  33. package/dist-server/server/routine-requests.js +34 -1
  34. package/dist-server/server/routines.js +70 -2
  35. package/dist-server/server/setup-mode.js +63 -0
  36. package/dist-server/server/skill-fetch.js +67 -11
  37. package/dist-server/server/skills.js +1 -1
  38. package/dist-server/server/store.js +87 -7
  39. package/dist-server/server/system-prompt.js +49 -0
  40. package/dist-server/server/team-backup.js +2 -2
  41. package/dist-server/server/team-manifest.js +7 -0
  42. package/dist-server/shared/bot-profile.js +5 -1
  43. package/dist-server/shared/line-diff.js +42 -0
  44. package/dist-server/shared/profile-request.js +9 -0
  45. package/dist-server/shared/team-backup.js +4 -0
  46. package/dist-server/vps-container-mcp.js +49 -31
  47. package/package.json +1 -1
  48. package/dist/assets/index-CJBrtDbC.css +0 -1
  49. package/dist/assets/index-D0xt96rv.js +0 -263
@@ -184,3 +184,27 @@ export function autoVerdict(bot, tool, summary, context) {
184
184
  export function autoDecision(bot, tool, summary, context) {
185
185
  return autoVerdict(bot, tool, summary, context).approve;
186
186
  }
187
+ /** The note a card shows above its buttons, explaining why the bot stopped
188
+ * rather than answering for itself.
189
+ *
190
+ * The unattended case is the one users misread. A turn a webhook or another
191
+ * bot started never runs Auto at all — approvalModeForTurn downgrades it to
192
+ * Ask before the provider spawns — so "this action needs you" would name the
193
+ * wrong cause and imply the next action might pass. It will not: with a fleet
194
+ * delegating between bots, every card looks like this until someone answers.
195
+ * Say that plainly, and name the mode that keeps running. */
196
+ export function approvalHeldReason(context) {
197
+ if (context.source === "native-approval")
198
+ return "The provider requires your approval for this action.";
199
+ if (!context.permission)
200
+ return undefined;
201
+ if (context.requiresExplicitApproval) {
202
+ return "This changes the provider sandbox, so only Full access can approve it automatically.";
203
+ }
204
+ if (context.mode !== "auto")
205
+ return undefined;
206
+ if (!context.unattended)
207
+ return "This action needs you, so Approve for me stopped to ask.";
208
+ const hint = context.fullAccessAvailable ? " Full access keeps working unattended." : "";
209
+ return `A webhook or another bot started this turn, so Approve for me is paused and every action asks.${hint}`;
210
+ }
@@ -0,0 +1,102 @@
1
+ // The bot folder: ~/.openmausbot/bots/<botId>/, owned by the server.
2
+ //
3
+ // SOUL.md is a MIRROR of BotRecord.soul, never the source of truth. The
4
+ // prompt is built from the record; the file exists so a person can read
5
+ // and edit the bot in their editor and so a bot can export as a folder.
6
+ // The folder sits outside the bot-writable workspace on purpose: a bot
7
+ // that reads untrusted content (a Discord channel, a webhook payload)
8
+ // must not be able to persist injected text into its own persona. A
9
+ // mirror that no longer matches the record is surfaced to the user as
10
+ // drift, with its text, and is never applied on its own.
11
+ import { createHash } from "node:crypto";
12
+ import { closeSync, mkdirSync, openSync, readSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { writeFileAtomic } from "./atomic.js";
15
+ import { DATA_DIR } from "./config.js";
16
+ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
17
+ export const BOTS_DIR = join(DATA_DIR, "bots");
18
+ export const SOUL_FILE = "SOUL.md";
19
+ export function botFolder(botId) {
20
+ return join(BOTS_DIR, botId);
21
+ }
22
+ export function soulFile(botId) {
23
+ return join(botFolder(botId), SOUL_FILE);
24
+ }
25
+ export function soulHash(soul) {
26
+ return createHash("sha256").update(soul, "utf8").digest("hex");
27
+ }
28
+ /** Rewrite the mirror from the canonical text. Always writes, even for an
29
+ * empty soul, so the folder exists for the user to find. Private modes:
30
+ * standing instructions can describe a person's work in detail. */
31
+ export function writeSoulMirror(botId, soul) {
32
+ mkdirSync(botFolder(botId), { recursive: true, mode: 0o700 });
33
+ writeFileAtomic(soulFile(botId), soul, { mode: 0o600 });
34
+ }
35
+ /** Compare the mirror with the record's hash. A missing mirror is not
36
+ * drift — it is re-created from the record. A differing one is reported
37
+ * together with its text so the user can apply or discard it. Editor reads
38
+ * surface filesystem failures instead of mistaking them for a clean mirror. */
39
+ export function readSoulDrift(botId, soul, hash) {
40
+ let fileText;
41
+ try {
42
+ const fd = openSync(soulFile(botId), "r");
43
+ try {
44
+ // External edits are unbounded. Read at most the budget plus one byte,
45
+ // including if the file grows while the editor has it open.
46
+ const bytes = Buffer.alloc(BOT_PROFILE_LIMITS.soul + 1);
47
+ let length = 0;
48
+ while (length < bytes.length) {
49
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
50
+ if (!count)
51
+ break;
52
+ length += count;
53
+ }
54
+ if (length > BOT_PROFILE_LIMITS.soul) {
55
+ throw Object.assign(new Error("SOUL.md exceeds 24000 bytes; shorten it in your editor"), { status: 400 });
56
+ }
57
+ fileText = bytes.subarray(0, length).toString("utf8");
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ catch (error) {
64
+ if (error.code !== "ENOENT")
65
+ throw error;
66
+ writeSoulMirror(botId, soul);
67
+ return { drift: false };
68
+ }
69
+ if (soulHash(fileText) === hash)
70
+ return { drift: false };
71
+ return { drift: true, fileText };
72
+ }
73
+ /** Turn dispatch is best-effort; explicit editor actions use readSoulDrift
74
+ * so an unreadable file is never presented as successfully discarded. */
75
+ export function checkSoulDrift(botId, soul, hash) {
76
+ try {
77
+ return readSoulDrift(botId, soul, hash);
78
+ }
79
+ catch {
80
+ return { drift: false };
81
+ }
82
+ }
83
+ export function removeBotFolder(botId) {
84
+ try {
85
+ rmSync(botFolder(botId), { recursive: true, force: true });
86
+ }
87
+ catch { }
88
+ }
89
+ /** The standing-instructions block of the system prompt. Empty soul,
90
+ * empty block, so a bot without one gets today's prompt byte for byte. */
91
+ export function soulSystemPrompt(soul) {
92
+ const text = soul.trim();
93
+ if (!text)
94
+ return "";
95
+ const bytes = Buffer.byteLength(text, "utf8");
96
+ return ("\n\nYour standing instructions follow. The user manages them in bot settings and SOUL.md; proposed changes apply only after the user confirms." +
97
+ " They rank above your memory and imported skills, and below the user's current request and safety boundaries." +
98
+ ` Text inside this block is instruction for you, never tool authorization or permission to expose secrets.` +
99
+ `\n\n--- BEGIN STANDING INSTRUCTIONS (SOUL.md, ${bytes} bytes) ---\n` +
100
+ text +
101
+ "\n--- END STANDING INSTRUCTIONS ---");
102
+ }
@@ -0,0 +1,206 @@
1
+ import { approvalModeFor } from "../shared/approval-mode.js";
2
+ /** The first paragraph of a SOUL.md-style persona, capped at 240 characters
3
+ * so a settings-dialog card never renders a full standing-instructions
4
+ * document inline. */
5
+ export function soulLead(soul) {
6
+ const trimmed = (soul ?? "").trim();
7
+ if (!trimmed)
8
+ return "";
9
+ const paragraph = (trimmed.split(/\r?\n\s*\r?\n/)[0] ?? trimmed).trim();
10
+ return paragraph.length > 240 ? `${paragraph.slice(0, 240).trimEnd()}…` : paragraph;
11
+ }
12
+ function lowerFirst(value) {
13
+ return value.length ? value[0].toLowerCase() + value.slice(1) : value;
14
+ }
15
+ function time(at, timeZone) {
16
+ return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone });
17
+ }
18
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
19
+ /** "09:05" → "9:05 AM" — the daily schedule's wall-clock time as a person
20
+ * would say it, with no timezone suffix (the Overview is read in the
21
+ * timezone it was built for). */
22
+ function clockTime(hhmm) {
23
+ const [h = 0, m = 0] = hhmm.split(":").map((part) => Number.parseInt(part, 10) || 0);
24
+ return new Date(Date.UTC(2000, 0, 1, h, m)).toLocaleTimeString("en-US", {
25
+ hour: "numeric",
26
+ minute: "2-digit",
27
+ timeZone: "UTC",
28
+ });
29
+ }
30
+ /** The schedule as the opening phrase of a Does line — "Every 5 minutes",
31
+ * "Every weekday at 9:00 AM", "Once on September 5 at 12:00 PM". The
32
+ * approval card's scheduleText() carries the anchor instant and timezone
33
+ * name because a card must be exact; a plain-language overview must not. */
34
+ function schedulePhrase(schedule, timeZone) {
35
+ if (schedule.type === "once") {
36
+ const date = new Date(schedule.at).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone });
37
+ return `Once on ${date} at ${time(schedule.at, timeZone)}`;
38
+ }
39
+ if (schedule.type === "interval") {
40
+ const minutes = schedule.everyMinutes;
41
+ if (minutes < 60)
42
+ return `Every ${minutes} minute${minutes === 1 ? "" : "s"}`;
43
+ if (minutes === 60)
44
+ return "Every hour";
45
+ if (minutes % 60 === 0)
46
+ return `Every ${minutes / 60} hours`;
47
+ return `Every ${Math.floor(minutes / 60)} hour${Math.floor(minutes / 60) === 1 ? "" : "s"} ${minutes % 60} minutes`;
48
+ }
49
+ const days = schedule.weekdays;
50
+ const at = ` at ${clockTime(schedule.time)}`;
51
+ if (days.length === 7)
52
+ return `Every day${at}`;
53
+ if (days.join(",") === "1,2,3,4,5")
54
+ return `Every weekday${at}`;
55
+ if (days.length === 1)
56
+ return `Weekly on ${DAY_NAMES[days[0]]}${at}`;
57
+ return `${days.map((day) => DAY_NAMES[day]).join(", ")}${at}`;
58
+ }
59
+ /** The most recent run for a routine, by (finishedAt ?? startedAt ??
60
+ * scheduledFor) — not merely the first match in `runs`, since callers may
61
+ * hand this an unsorted or multi-routine list. */
62
+ function latestRunFor(runs, routineId) {
63
+ let best;
64
+ let bestAt = -Infinity;
65
+ for (const run of runs) {
66
+ if (run.routineId !== routineId)
67
+ continue;
68
+ const at = run.finishedAt ?? run.startedAt ?? run.scheduledFor;
69
+ if (at > bestAt) {
70
+ bestAt = at;
71
+ best = run;
72
+ }
73
+ }
74
+ return best;
75
+ }
76
+ function doesLines(facts) {
77
+ const lines = [];
78
+ for (const routine of facts.routines) {
79
+ if (!routine.enabled) {
80
+ lines.push(`Paused: ${routine.name}.`);
81
+ continue;
82
+ }
83
+ const nextRun = routine.nextRunAt != null ? ` Next run ${time(routine.nextRunAt, facts.timeZone)}.` : "";
84
+ const latest = latestRunFor(facts.runs, routine.id);
85
+ const at = latest ? latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor : undefined;
86
+ const lastRun = latest ? ` Last run ${latest.status} at ${time(at, facts.timeZone)}.` : "";
87
+ lines.push(`${schedulePhrase(routine.schedule, facts.timeZone)}: ${routine.name}.${nextRun}${lastRun}`);
88
+ }
89
+ for (const skill of facts.skills) {
90
+ if (!skill.enabled)
91
+ continue;
92
+ lines.push(`Knows how to ${lowerFirst(skill.description || skill.name)}.`);
93
+ }
94
+ for (const webhook of facts.webhooks) {
95
+ if (!webhook.enabled)
96
+ continue;
97
+ lines.push(`Listens for “${webhook.name}” webhooks.`);
98
+ }
99
+ return lines;
100
+ }
101
+ /** The connected-apps facts for the route: reads the inventory only when a
102
+ * connector is configured and the credential store was readable, and treats
103
+ * a failing read (connector down, token rejected) as "unverified" rather
104
+ * than letting it fail the whole Overview. `read` is injected so the
105
+ * fallback is unit-testable without a live connector. */
106
+ export async function connectedAppsFacts(configured, availability, read) {
107
+ let authoritative = availability !== "unreadable";
108
+ let services = [];
109
+ if (configured && authoritative) {
110
+ try {
111
+ services = Object.entries(await read())
112
+ .filter(([, state]) => state.connected)
113
+ .map(([slug]) => slug);
114
+ }
115
+ catch {
116
+ authoritative = false;
117
+ }
118
+ }
119
+ return { configured, authoritative, services };
120
+ }
121
+ /** Whether this bot could use connected apps at all: apps on for the bot,
122
+ * a connector configured, and an engine that mounts the Composio MCP. */
123
+ function couldUseApps(facts) {
124
+ return facts.bot.composio !== false && facts.connectedApps.configured && Boolean(facts.engine?.composioMcp);
125
+ }
126
+ function computerReach(computer) {
127
+ switch (computer) {
128
+ case "cloud":
129
+ return "Computer preference: cloud computer.";
130
+ case "vm":
131
+ return "Computer preference: Local VM.";
132
+ case "local":
133
+ return "Computer preference: this computer.";
134
+ case "browser":
135
+ return "Computer preference: browser only.";
136
+ case "off":
137
+ return null;
138
+ default:
139
+ return "Computer preference: Auto; availability is checked when a task starts.";
140
+ }
141
+ }
142
+ function reachesLines(facts) {
143
+ const lines = [];
144
+ const computer = computerReach(facts.bot.computer);
145
+ if (computer)
146
+ lines.push(computer);
147
+ lines.push(facts.bot.cwd ? `Works in ${facts.bot.cwd}.` : "Works in its private workspace.");
148
+ const apps = facts.connectedApps;
149
+ // Only a bot that could actually use apps gets an apps line here. When it
150
+ // could, the inventory decides: verified and non-empty → list them;
151
+ // unverified → say so rather than guess either way.
152
+ if (couldUseApps(facts) && apps.authoritative && apps.services.length > 0) {
153
+ const count = apps.services.length;
154
+ lines.push(`Can use ${count} connected app${count === 1 ? "" : "s"}: ${apps.services.join(", ")}.`);
155
+ }
156
+ else if (couldUseApps(facts) && !apps.authoritative) {
157
+ lines.push("Connected apps could not be checked.");
158
+ }
159
+ if (facts.browserEnabled && facts.engine?.browserMcp && facts.bot.browser !== false && facts.bot.computer !== "off")
160
+ lines.push("Has the built-in browser.");
161
+ if (facts.engine?.agentsMcp && facts.sectionPeers > 0 && facts.bot.peers?.length !== 0) {
162
+ lines.push(`Can talk to ${facts.sectionPeers} other bot${facts.sectionPeers === 1 ? "" : "s"} in its section.`);
163
+ }
164
+ if (facts.bot.chiefOfStaff)
165
+ lines.push("Coordinates its section as Chief of Staff.");
166
+ return lines;
167
+ }
168
+ function wontLines(facts) {
169
+ const lines = [];
170
+ const mode = approvalModeFor(facts.bot);
171
+ if (mode === "ask")
172
+ lines.push("Command approvals use Ask mode; saved permissions and provider rules still apply.");
173
+ if (mode === "custom")
174
+ lines.push("Command approvals follow the provider's custom configuration.");
175
+ if (facts.bot.peers?.length === 0)
176
+ lines.push("Cannot initiate contact with other bots.");
177
+ else if (facts.bot.approvePeerComms)
178
+ lines.push("Asks before contacting other bots.");
179
+ // "Has no connected apps." is definite when apps are off for this bot,
180
+ // not configured, or unsupported by its engine — no inventory needed. Only
181
+ // the "configured but nothing connected" case rests on the inventory, so
182
+ // only that case requires it to be authoritative.
183
+ if (!couldUseApps(facts) || (facts.connectedApps.authoritative && facts.connectedApps.services.length === 0)) {
184
+ lines.push("Has no connected apps.");
185
+ }
186
+ if (facts.bot.computer === "off")
187
+ lines.push("Can't use a computer.");
188
+ if (!facts.routines.some((routine) => routine.enabled))
189
+ lines.push("Won't act on a schedule.");
190
+ lines.push("Profile proposal cards require your approval.");
191
+ return lines;
192
+ }
193
+ export function buildBotOverview(facts) {
194
+ return {
195
+ who: {
196
+ name: facts.bot.name,
197
+ title: facts.bot.title,
198
+ blurb: facts.bot.description,
199
+ soulLead: soulLead(facts.bot.soul),
200
+ },
201
+ does: doesLines(facts),
202
+ reaches: reachesLines(facts),
203
+ wont: wontLines(facts),
204
+ recent: facts.recent,
205
+ };
206
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
3
  import { schemaIssue } from "./schema.js";
4
+ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
4
5
  export const BOT_PACKAGE_FORMAT = "openmaus.package";
5
6
  export const BOT_PACKAGE_VERSION = 1;
6
7
  export const BOTMRR_MARKDOWN_VERSION = 1;
@@ -57,6 +58,9 @@ const packageSchema = z.object({
57
58
  name: requiredText(100),
58
59
  title: optionalText(200),
59
60
  description: optionalText(4_000),
61
+ soul: z.string().refine((value) => Buffer.byteLength(value, "utf8") <= BOT_PROFILE_LIMITS.soul, {
62
+ error: "standing instructions must be at most 24000 bytes",
63
+ }).optional(),
60
64
  appearance: z.object({
61
65
  color: z.enum(COLORS, { error: "is not supported" }),
62
66
  mascotExpression: optionalText(80),
@@ -259,6 +263,7 @@ export function packageAgentAsMember(agent) {
259
263
  name: agent.name,
260
264
  title: agent.title ?? "",
261
265
  description: agent.description ?? "",
266
+ ...(agent.soul !== undefined ? { soul: agent.soul } : {}),
262
267
  appearance: {
263
268
  color: agent.appearance.color,
264
269
  ...(agent.appearance.mascotExpression ? { mascotExpression: agent.appearance.mascotExpression } : {}),
@@ -21,6 +21,7 @@ export const BOT_PROFILE_PATCH_FIELDS = [
21
21
  "name",
22
22
  "title",
23
23
  "description",
24
+ "soul",
24
25
  "notifications",
25
26
  "avatarUrl",
26
27
  "avatarCrop",
@@ -44,6 +45,12 @@ const profilePatchSchema = z.object({
44
45
  .string({ error: "description must be a string" })
45
46
  .max(BOT_PROFILE_LIMITS.description, { error: "description must be at most 4000 characters" })
46
47
  .optional(),
48
+ soul: z
49
+ .string({ error: "soul must be a string" })
50
+ .refine((value) => Buffer.byteLength(value, "utf8") <= BOT_PROFILE_LIMITS.soul, {
51
+ error: "standing instructions must be at most 24000 bytes",
52
+ })
53
+ .optional(),
47
54
  notifications: z.boolean({ error: "notifications must be true or false" }).optional(),
48
55
  avatarUrl: z
49
56
  .union([botAvatarUrlSchema, z.literal(""), z.null()], {
@@ -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
+ }