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
|
@@ -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
|
+
}
|