openmausbot 0.1.55 → 0.1.57
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-DSh8AIUy.js +264 -0
- package/dist/assets/{index-D-u0LYsV.js → index-jWqt5dmY.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +218 -0
- 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 +2020 -361
- package/dist-server/openmausbot.js +45 -386
- package/dist-server/pair-cli.js +45 -386
- package/dist-server/server/auto-approve.js +48 -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 +24 -0
- package/dist-server/server/channel-queue.js +1 -0
- package/dist-server/server/computer-control.js +15 -0
- 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 +61 -13
- package/dist-server/server/drivers/antigravity-runtime.js +47 -3
- package/dist-server/server/drivers/claude.js +7 -1
- package/dist-server/server/exit.js +9 -0
- package/dist-server/server/index.js +707 -124
- package/dist-server/server/mcp-bridge.js +73 -44
- package/dist-server/server/openmausbot.js +3 -2
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/pair-cli.js +3 -2
- package/dist-server/server/peer-approval.js +27 -0
- package/dist-server/server/peer-provenance.js +4 -1
- package/dist-server/server/peer-roster.js +15 -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/request-auth.js +10 -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/server/tunnel.js +1 -1
- 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/tunnel-guardian.js +6 -19
- package/dist-server/vps-container-mcp.js +49 -31
- package/package.json +1 -1
- package/dist/assets/index-B9rLIY81.js +0 -263
- package/dist/assets/index-CJBrtDbC.css +0 -1
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// The one
|
|
1
|
+
// The one stdio bridge behind both MCP entry points
|
|
2
2
|
// (container-mcp.ts for the Local VM, vps-container-mcp.ts for the BYO VPS).
|
|
3
|
-
// It defines no tools and parses no MCP messages: bytes in, bytes out.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the app, a `tools/call` from the agent is answered with a refusal HERE,
|
|
8
|
-
// on the near side, and never forwarded — Cua Driver on the far side has
|
|
9
|
-
// no concept of a person holding the wheel, so the refusal cannot come
|
|
10
|
-
// from anywhere else. Everything that is not a tools/call still passes
|
|
11
|
-
// through untouched, and with no gate configured the bridge remains the
|
|
12
|
-
// byte-for-byte pipe described above.
|
|
4
|
+
// It is almost transparent — bytes in, bytes out — with two deliberate
|
|
5
|
+
// near-side exceptions:
|
|
13
6
|
//
|
|
14
|
-
//
|
|
7
|
+
// 1. `ping`. The bundled cua-driver (through at least v0.22.1) does not
|
|
8
|
+
// implement this MCP method and would answer with -32601. Answering it
|
|
9
|
+
// here keeps the handshake alive without reaching the driver.
|
|
10
|
+
// 2. The who-is-driving `gate` (opt-in via `gate`). While the person holds
|
|
11
|
+
// control of this computer, a `tools/call` from the agent is answered
|
|
12
|
+
// with a refusal here and never forwarded.
|
|
13
|
+
//
|
|
14
|
+
// Both behaviors live here so neither entry point can drift:
|
|
15
15
|
// 1. Exit without truncation. `process.exit()` in a close/error handler
|
|
16
16
|
// discards whatever is still buffered on stdout — a final MCP result
|
|
17
17
|
// would be cut mid-frame. The bridge sets exitCode and unpipes instead,
|
|
@@ -165,6 +165,36 @@ export function createGateInterceptor(options) {
|
|
|
165
165
|
});
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
|
+
/** The near-side MCP method filter. `ping` is answered here so the bundled
|
|
169
|
+
* cua-driver is never invoked for it; everything else is delegated to the
|
|
170
|
+
* gate (if configured) or forwarded untouched. */
|
|
171
|
+
export function createMcpBridgeInterceptor(options) {
|
|
172
|
+
const afterPing = options.gate
|
|
173
|
+
? createGateInterceptor({
|
|
174
|
+
isHeld: options.gate.isHeld,
|
|
175
|
+
forward: options.forward,
|
|
176
|
+
refuse: options.answer,
|
|
177
|
+
refusalText: options.gate.refusalText,
|
|
178
|
+
})
|
|
179
|
+
: options.forward;
|
|
180
|
+
return (line) => {
|
|
181
|
+
let frame = null;
|
|
182
|
+
try {
|
|
183
|
+
frame = JSON.parse(line);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// not a frame this bridge understands — forward untouched
|
|
187
|
+
}
|
|
188
|
+
if (frame && frame.method === "ping") {
|
|
189
|
+
// Notifications have no `id` and require no response.
|
|
190
|
+
if (frame.id !== undefined) {
|
|
191
|
+
options.answer(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }));
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
afterPing(line);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
168
198
|
export function runMcpBridge(options) {
|
|
169
199
|
const child = spawn(options.command, options.args, {
|
|
170
200
|
shell: false,
|
|
@@ -174,39 +204,38 @@ export function runMcpBridge(options) {
|
|
|
174
204
|
// docker may exit before it drains stdin; pipe() leaves this error unhandled.
|
|
175
205
|
child.stdin.on("error", () => { });
|
|
176
206
|
child.stderr.pipe(process.stderr);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
207
|
+
const client = options.gate
|
|
208
|
+
? createControlClient({ url: options.gate.url, token: options.gate.token })
|
|
209
|
+
: null;
|
|
210
|
+
const answer = (line) => process.stdout.write(line + "\n");
|
|
211
|
+
const forward = (line) => child.stdin.write(line + "\n");
|
|
212
|
+
const inbound = createLineSplitter(createMcpBridgeInterceptor({
|
|
213
|
+
answer,
|
|
214
|
+
forward,
|
|
215
|
+
...(options.gate
|
|
216
|
+
? {
|
|
217
|
+
gate: {
|
|
218
|
+
isHeld: async () => (await client.state(true)).held,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
: {}),
|
|
222
|
+
}));
|
|
223
|
+
const onStdin = (chunk) => inbound.push(chunk);
|
|
224
|
+
process.stdin.on("data", onStdin);
|
|
225
|
+
process.stdin.on("end", () => {
|
|
226
|
+
inbound.flush();
|
|
227
|
+
child.stdin.end();
|
|
228
|
+
});
|
|
229
|
+
// Injected responses and refusals must never land inside one of the
|
|
230
|
+
// child's half-written frames, so the child's stdout is re-emitted at
|
|
231
|
+
// line granularity as well.
|
|
232
|
+
const outbound = createLineSplitter((line) => process.stdout.write(line + "\n"));
|
|
233
|
+
child.stdout.on("data", (chunk) => outbound.push(chunk));
|
|
234
|
+
child.stdout.on("end", () => outbound.flush());
|
|
235
|
+
const detach = () => {
|
|
236
|
+
process.stdin.off("data", onStdin);
|
|
237
|
+
process.stdin.pause();
|
|
238
|
+
};
|
|
210
239
|
let watchdog = null;
|
|
211
240
|
if (options.liveness) {
|
|
212
241
|
const liveness = options.liveness;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Entry point for the `openmausbot` command (see cli.ts).
|
|
2
2
|
import { main } from "./cli.js";
|
|
3
|
-
|
|
3
|
+
import { exitAfterFlush } from "./exit.js";
|
|
4
|
+
main().then(exitAfterFlush, (error) => {
|
|
4
5
|
console.error(error instanceof Error ? error.message : String(error));
|
|
5
|
-
|
|
6
|
+
exitAfterFlush(1);
|
|
6
7
|
});
|
|
@@ -119,6 +119,7 @@ export function createBotPackageExport(input) {
|
|
|
119
119
|
name: bot.name,
|
|
120
120
|
title: bot.title,
|
|
121
121
|
description: bot.description,
|
|
122
|
+
...(bot.soul !== undefined ? { soul: bot.soul } : {}),
|
|
122
123
|
appearance,
|
|
123
124
|
};
|
|
124
125
|
const assigned = agentPlaybooks.get(bot.id);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Compatibility alias: `openmausbot pair` lives in cli.ts now. Kept because
|
|
2
2
|
// docs and images reference dist-server/pair-cli.js.
|
|
3
3
|
import { main } from "./cli.js";
|
|
4
|
-
|
|
4
|
+
import { exitAfterFlush } from "./exit.js";
|
|
5
|
+
main(["pair", ...process.argv.slice(2)]).then(exitAfterFlush, (error) => {
|
|
5
6
|
console.error(error instanceof Error ? error.message : String(error));
|
|
6
|
-
|
|
7
|
+
exitAfterFlush(1);
|
|
7
8
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// the user-facing Always-allow flow already mirrors that key back into
|
|
15
15
|
// `alwaysAllow`, so the two sides never disagree about what was granted.
|
|
16
16
|
import { newId } from "./contracts.js";
|
|
17
|
+
import { buildNotification } from "./notify.js";
|
|
17
18
|
import { peerAllowKey } from "./peer-approval-key.js";
|
|
18
19
|
export { peerAllowKey } from "./peer-approval-key.js";
|
|
19
20
|
/** Mark the card answered so the UI stops treating it as pending. Mirrors
|
|
@@ -28,6 +29,11 @@ function settleCard(pending, behavior, source) {
|
|
|
28
29
|
pending.bus.store.patchMessage(pending.threadId, pending.messageId, {
|
|
29
30
|
card: { ...existing.card, answered: behavior, dismissed: source !== "user" },
|
|
30
31
|
});
|
|
32
|
+
// answered (by whoever): the turn is working again — the same hand-back
|
|
33
|
+
// the request.resolved fold does for a provider's card
|
|
34
|
+
const waiting = pending.bus.store.bot(pending.fromBotId);
|
|
35
|
+
if (waiting?.activity === "waiting-on-you")
|
|
36
|
+
pending.bus.store.setActivity(waiting.id, "working");
|
|
31
37
|
}
|
|
32
38
|
/** requestId → pending ask. Lives only in memory — restarting the
|
|
33
39
|
* server cancels every in-flight approval, like provider permissions do. */
|
|
@@ -62,6 +68,26 @@ function pushApprovalCard(bus, from, target, message, action, requestId, sourceT
|
|
|
62
68
|
});
|
|
63
69
|
return note;
|
|
64
70
|
}
|
|
71
|
+
/** This card is the one bot-to-bot event that blocks on a person — for up
|
|
72
|
+
* to fifteen minutes — so it gets exactly what a provider's card gets: the
|
|
73
|
+
* bot shows as waiting rather than working, and a "needs approval" frame
|
|
74
|
+
* goes out aimed at the thread the card is in. Without this the sidebar
|
|
75
|
+
* read "Working…" with no dot and no banner until the timer denied it, and
|
|
76
|
+
* the bot then reported it could not reach a teammate nobody knew it had
|
|
77
|
+
* asked for. A bot speaking in a room is not reachable in its own thread,
|
|
78
|
+
* so the room the card landed in is named, the way takeover does. */
|
|
79
|
+
function announceCard(bus, from, card, sourceThreadId) {
|
|
80
|
+
// the bot is not working now — it is waiting on a person. A delegation
|
|
81
|
+
// drained after its turn has already settled is idle, and stays so.
|
|
82
|
+
const live = bus.store.bot(from.id);
|
|
83
|
+
if (live?.busy)
|
|
84
|
+
bus.store.setActivity(from.id, "waiting-on-you");
|
|
85
|
+
const room = bus.store.groupByThread(sourceThreadId);
|
|
86
|
+
const group = room && !room.dm ? { id: room.id, name: room.name } : undefined;
|
|
87
|
+
const title = card.card?.title ?? "";
|
|
88
|
+
const detail = card.card?.subtitle ? `${title} — ${card.card.subtitle}` : title;
|
|
89
|
+
bus.notify?.(buildNotification("approval", from, sourceThreadId, detail, { avatarUrl: from.avatarUrl, group }));
|
|
90
|
+
}
|
|
65
91
|
/** Ask the user (in the source task thread) whether `from` may `action` `target`.
|
|
66
92
|
* Resolves with `"allow"` or `"deny"`. If `from.alwaysAllow` already
|
|
67
93
|
* covers the (action, target) pair, returns `"allow"` immediately
|
|
@@ -75,6 +101,7 @@ export function requestPeerApproval(bus, from, target, message, action, sourceTh
|
|
|
75
101
|
// the card has to exist before the entry, so a timeout or an answer can
|
|
76
102
|
// always find it to settle
|
|
77
103
|
const card = pushApprovalCard(bus, from, target, message, action, requestId, sourceThreadId);
|
|
104
|
+
announceCard(bus, from, card, sourceThreadId);
|
|
78
105
|
const timer = setTimeout(() => {
|
|
79
106
|
// 15 minutes without an answer → deny. Keeps an unattended bot from
|
|
80
107
|
// stalling its own turn forever (matches the Claude broker timeout).
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// The note opens with "[Message from @Name" or "[Posted by @Name" because
|
|
17
17
|
// the shape of the first few words is what a model actually keys on, and
|
|
18
18
|
// that marker has been in the ask path since peer comms shipped.
|
|
19
|
+
import { peerName } from "./peer-roster.js";
|
|
19
20
|
/** The bracketed provenance line on its own. */
|
|
20
|
-
export function peerProvenanceNote({ botName, delivery, unattended }) {
|
|
21
|
+
export function peerProvenanceNote({ botName: rawName, delivery, unattended }) {
|
|
22
|
+
// the note is one bracketed line, and the name must not be able to end it
|
|
23
|
+
const botName = peerName(rawName);
|
|
21
24
|
const opening = delivery === "ask_bot"
|
|
22
25
|
? `Message from @${botName}, another bot in this OpenMausBot workspace`
|
|
23
26
|
: `Posted by @${botName}, another bot in this OpenMausBot workspace`;
|
|
@@ -57,6 +57,21 @@ const clip = (value, max) => {
|
|
|
57
57
|
const flat = oneLine(value);
|
|
58
58
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
59
59
|
};
|
|
60
|
+
/** A bot's name where it is about to be quoted INSIDE harness text — the
|
|
61
|
+
* bracketed provenance note, a room transcript's "Name: …" speaker line.
|
|
62
|
+
* Flattened and clipped like a roster entry, and with the brackets the
|
|
63
|
+
* provenance note is built from taken out: "Scout]" would otherwise close
|
|
64
|
+
* the note early and let whatever follows read as a speaker of its own. */
|
|
65
|
+
export function peerName(value) {
|
|
66
|
+
return clip(value.replace(/[[\]]/g, " "), ROSTER_NAME_MAX);
|
|
67
|
+
}
|
|
68
|
+
/** One member of a room as the room prompt lists it. Same discipline as the
|
|
69
|
+
* 1:1 roster: a title carrying a newline would otherwise land in every OTHER
|
|
70
|
+
* member's system prompt as a line of its own. */
|
|
71
|
+
export function roomRosterLine(member) {
|
|
72
|
+
const role = member.title ? clip(member.title, ROSTER_ROLE_MAX) : "";
|
|
73
|
+
return `@${clip(member.name, ROSTER_NAME_MAX)}${role ? ` (${role})` : ""}`;
|
|
74
|
+
}
|
|
60
75
|
/** Render a team as roster lines. Every knob is the caller's, not the
|
|
61
76
|
* renderer's: how many names a bot needs — and how much detail — depends on
|
|
62
77
|
* what it is expected to do with them. */
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// propose_profile: a bot proposes changes to its own name, title, description,
|
|
2
|
+
// SOUL.md, or working folder; the change lands only when the user confirms the card. Same
|
|
3
|
+
// shape as routine-requests.ts, much smaller: the profile commits in one
|
|
4
|
+
// store call, and staleness is a hash of the five fields instead of a
|
|
5
|
+
// scheduler revision. Everything here is re-validated at confirm time —
|
|
6
|
+
// a card can sit open for days.
|
|
7
|
+
import { lineDiff } from "../shared/line-diff.js";
|
|
8
|
+
import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
|
|
9
|
+
import { PROFILE_REQUEST_FIELDS } from "../shared/profile-request.js";
|
|
10
|
+
import { parseBotProfilePatch } from "./bot-profile.js";
|
|
11
|
+
import { validateBotCwd } from "./bot-cwd.js";
|
|
12
|
+
import { newId } from "./contracts.js";
|
|
13
|
+
import { profileRevision, profileSnapshot } from "./profile-revision.js";
|
|
14
|
+
import { recordProfileChange } from "./profile-versions.js";
|
|
15
|
+
import { redactSecretsInText } from "./redact.js";
|
|
16
|
+
const MAX_REASON = 500;
|
|
17
|
+
const MAX_DIFF_LINES = 400;
|
|
18
|
+
const STALE = "This bot's profile changed after this card was prepared. Ask the bot to review it and propose again.";
|
|
19
|
+
const NO_SUCH_BOT = "That bot no longer exists";
|
|
20
|
+
const LABELS = {
|
|
21
|
+
name: "Name",
|
|
22
|
+
title: "Title",
|
|
23
|
+
description: "Description",
|
|
24
|
+
};
|
|
25
|
+
const PRIVATE_WORKSPACE = "its private workspace";
|
|
26
|
+
const CHOOSE_ONE = "Choose at least one of name, title, description, soul, cwd";
|
|
27
|
+
export class ProfileRequestError extends Error {
|
|
28
|
+
status;
|
|
29
|
+
constructor(message, status = 400) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "ProfileRequestError";
|
|
32
|
+
this.status = status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function reasonText(value) {
|
|
36
|
+
if (typeof value !== "string")
|
|
37
|
+
throw new ProfileRequestError("reason is required");
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
throw new ProfileRequestError("reason is required");
|
|
41
|
+
if (trimmed.length > MAX_REASON) {
|
|
42
|
+
throw new ProfileRequestError(`reason must be ${MAX_REASON} characters or fewer`);
|
|
43
|
+
}
|
|
44
|
+
return redactSecretsInText(trimmed);
|
|
45
|
+
}
|
|
46
|
+
/** `parseBotProfilePatch` would silently accept a key like `notifications` —
|
|
47
|
+
* valid for the broader bot patch, not for a profile request. Reject keys
|
|
48
|
+
* outside the proposable fields ourselves first, for exact copy. */
|
|
49
|
+
function parseChanges(input) {
|
|
50
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
51
|
+
throw new ProfileRequestError(CHOOSE_ONE);
|
|
52
|
+
}
|
|
53
|
+
const keys = Object.keys(input);
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
if (!PROFILE_REQUEST_FIELDS.includes(key)) {
|
|
56
|
+
throw new ProfileRequestError(`unsupported profile field: ${key}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (keys.length === 0) {
|
|
60
|
+
throw new ProfileRequestError(CHOOSE_ONE);
|
|
61
|
+
}
|
|
62
|
+
// The working folder is not a profile-patch field (PATCH /api/bots checks
|
|
63
|
+
// it with validateBotCwd, not parseBotProfilePatch), so it is split off and
|
|
64
|
+
// checked the same way that route does: absolute, exists, is a folder.
|
|
65
|
+
// "" (or null) means the private workspace.
|
|
66
|
+
const { cwd: rawCwd, ...profileInput } = input;
|
|
67
|
+
let cwdChange;
|
|
68
|
+
if (rawCwd !== undefined) {
|
|
69
|
+
const checked = validateBotCwd(rawCwd);
|
|
70
|
+
if (!checked.ok)
|
|
71
|
+
throw new ProfileRequestError(checked.error, 400);
|
|
72
|
+
cwdChange = checked.cwd ?? "";
|
|
73
|
+
}
|
|
74
|
+
const parsed = Object.keys(profileInput).length
|
|
75
|
+
? parseBotProfilePatch(profileInput, true)
|
|
76
|
+
: { ok: true, patch: {} };
|
|
77
|
+
if (!parsed.ok)
|
|
78
|
+
throw new ProfileRequestError(parsed.error, 400);
|
|
79
|
+
const changes = {};
|
|
80
|
+
if (cwdChange !== undefined)
|
|
81
|
+
changes.cwd = cwdChange;
|
|
82
|
+
for (const field of PROFILE_REQUEST_FIELDS) {
|
|
83
|
+
if (field === "cwd")
|
|
84
|
+
continue;
|
|
85
|
+
const value = parsed.patch[field];
|
|
86
|
+
// This payload is hidden under the card's visible fields, so the store's
|
|
87
|
+
// shallow card redaction cannot reach it. Scrub before it is persisted.
|
|
88
|
+
if (typeof value !== "string")
|
|
89
|
+
continue;
|
|
90
|
+
const redacted = redactSecretsInText(value);
|
|
91
|
+
// A mask can be LONGER than the secret it replaces (e.g. an 8-char
|
|
92
|
+
// value becomes "«redacted 8 chars»"), so a value that passed the raw
|
|
93
|
+
// cap can come out the other side over it. Re-check with the exact
|
|
94
|
+
// copy the parser itself would have used.
|
|
95
|
+
if (field === "soul") {
|
|
96
|
+
if (Buffer.byteLength(redacted, "utf8") > BOT_PROFILE_LIMITS.soul) {
|
|
97
|
+
throw new ProfileRequestError("standing instructions must be at most 24000 bytes");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const limit = BOT_PROFILE_LIMITS[field];
|
|
102
|
+
if (redacted.length > limit) {
|
|
103
|
+
throw new ProfileRequestError(`${field} must be at most ${limit} characters`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
changes[field] = redacted;
|
|
107
|
+
}
|
|
108
|
+
return changes;
|
|
109
|
+
}
|
|
110
|
+
export function profileCardCopy(target, fullProfile, before, changes, reason) {
|
|
111
|
+
// Whether this is a first-time setup is a property of the bot's WHOLE
|
|
112
|
+
// profile, not of the fields this particular proposal happens to touch —
|
|
113
|
+
// otherwise a name-only rename of an established bot reads as "Set up X?"
|
|
114
|
+
// just because title/description/soul never appear in `before`.
|
|
115
|
+
const isSetup = !fullProfile.title && !fullProfile.description && !fullProfile.soul;
|
|
116
|
+
const title = target.crossBot
|
|
117
|
+
? `Update @${target.name}'s profile?`
|
|
118
|
+
: isSetup
|
|
119
|
+
? `Set up ${target.name}?`
|
|
120
|
+
: `Update ${target.name}'s profile?`;
|
|
121
|
+
const lines = target.crossBot ? [`Whose profile: @${target.name}`, `Why: ${reason}`] : [`Why: ${reason}`];
|
|
122
|
+
for (const field of PROFILE_REQUEST_FIELDS) {
|
|
123
|
+
if (field === "soul" || field === "cwd")
|
|
124
|
+
continue;
|
|
125
|
+
if (changes[field] === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
lines.push(`${LABELS[field]}: "${before[field] ?? ""}" → "${changes[field]}"`);
|
|
128
|
+
}
|
|
129
|
+
if (changes.cwd !== undefined) {
|
|
130
|
+
lines.push(`Working folder: ${before.cwd || PRIVATE_WORKSPACE} → ${changes.cwd || PRIVATE_WORKSPACE}`);
|
|
131
|
+
}
|
|
132
|
+
if (changes.soul !== undefined) {
|
|
133
|
+
const beforeSoul = before.soul ?? "";
|
|
134
|
+
const afterSoul = changes.soul;
|
|
135
|
+
const bytesBefore = Buffer.byteLength(beforeSoul, "utf8");
|
|
136
|
+
const bytesAfter = Buffer.byteLength(afterSoul, "utf8");
|
|
137
|
+
lines.push(`SOUL.md (${bytesBefore} → ${bytesAfter} bytes):`);
|
|
138
|
+
const diff = lineDiff(beforeSoul, afterSoul);
|
|
139
|
+
if (diff.length > MAX_DIFF_LINES) {
|
|
140
|
+
// Truncating a diff can hide the very instructions being approved.
|
|
141
|
+
// The full replacement is already byte-bounded by profile validation
|
|
142
|
+
// and is readable in the same generic card on desktop and phones.
|
|
143
|
+
lines.push("Large change — complete proposed instructions (replaces the current SOUL.md):");
|
|
144
|
+
lines.push(afterSoul || "(empty)");
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
lines.push(...diff);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// The closing line says the consequence of exactly what is on the card:
|
|
151
|
+
// a folder change moves where the bot's tools read and write; the other
|
|
152
|
+
// fields change what it is told. Either way nothing runs on confirm.
|
|
153
|
+
const onlyFolder = Object.keys(changes).every((field) => field === "cwd");
|
|
154
|
+
if (changes.cwd !== undefined)
|
|
155
|
+
lines.push(`${target.name}'s tools will read and write files in that folder.`);
|
|
156
|
+
lines.push(onlyFolder ? "Nothing runs." : `Changes what ${target.name} is told on every turn. Nothing runs.`);
|
|
157
|
+
const detail = lines.join("\n");
|
|
158
|
+
const fields = PROFILE_REQUEST_FIELDS.filter((field) => changes[field] !== undefined);
|
|
159
|
+
const summary = `${title} · ${fields.join(", ")}`;
|
|
160
|
+
return { title, summary, detail };
|
|
161
|
+
}
|
|
162
|
+
export class ProfileRequestService {
|
|
163
|
+
store;
|
|
164
|
+
now;
|
|
165
|
+
canPersist;
|
|
166
|
+
/** Public: a caller's section membership can change between propose and
|
|
167
|
+
* confirm, and tests flip this mid-scenario to model that. */
|
|
168
|
+
validateTarget;
|
|
169
|
+
constructor(options) {
|
|
170
|
+
this.store = options.store;
|
|
171
|
+
this.now = options.now ?? Date.now;
|
|
172
|
+
this.canPersist = options.canPersist;
|
|
173
|
+
this.validateTarget = options.validateTarget;
|
|
174
|
+
}
|
|
175
|
+
propose(args) {
|
|
176
|
+
const reason = reasonText(args.reason);
|
|
177
|
+
const changes = parseChanges(args.changes);
|
|
178
|
+
const targetBotId = args.targetBotId ?? args.botId;
|
|
179
|
+
const target = this.store.bot(targetBotId);
|
|
180
|
+
if (!target)
|
|
181
|
+
throw new ProfileRequestError(NO_SUCH_BOT, 404);
|
|
182
|
+
const crossBot = targetBotId !== args.botId;
|
|
183
|
+
if (crossBot && this.validateTarget) {
|
|
184
|
+
const refusal = this.validateTarget(args.botId, targetBotId);
|
|
185
|
+
if (refusal)
|
|
186
|
+
throw new ProfileRequestError(refusal, 403);
|
|
187
|
+
}
|
|
188
|
+
const snapshot = profileSnapshot(target);
|
|
189
|
+
const before = {};
|
|
190
|
+
const finalChanges = {};
|
|
191
|
+
for (const field of PROFILE_REQUEST_FIELDS) {
|
|
192
|
+
const value = changes[field];
|
|
193
|
+
if (value === undefined)
|
|
194
|
+
continue;
|
|
195
|
+
if (value === snapshot[field])
|
|
196
|
+
continue;
|
|
197
|
+
before[field] = redactSecretsInText(snapshot[field]);
|
|
198
|
+
finalChanges[field] = value;
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(finalChanges).length === 0) {
|
|
201
|
+
throw new ProfileRequestError("Nothing would change");
|
|
202
|
+
}
|
|
203
|
+
const requestId = newId();
|
|
204
|
+
const targetName = redactSecretsInText(target.name);
|
|
205
|
+
const payload = {
|
|
206
|
+
version: 1,
|
|
207
|
+
requestId,
|
|
208
|
+
botId: args.botId,
|
|
209
|
+
threadId: args.threadId,
|
|
210
|
+
targetBotId,
|
|
211
|
+
targetName,
|
|
212
|
+
createdAt: this.now(),
|
|
213
|
+
reason,
|
|
214
|
+
changes: finalChanges,
|
|
215
|
+
before,
|
|
216
|
+
expectedRevision: profileRevision(target),
|
|
217
|
+
};
|
|
218
|
+
const copy = profileCardCopy({ name: targetName, crossBot }, snapshot, before, finalChanges, reason);
|
|
219
|
+
const persistence = this.canPersist?.(args.botId, args.threadId);
|
|
220
|
+
if (persistence && !persistence.ok) {
|
|
221
|
+
throw new ProfileRequestError(persistence.error, persistence.status);
|
|
222
|
+
}
|
|
223
|
+
const messageInput = {
|
|
224
|
+
role: "bot",
|
|
225
|
+
kind: "options",
|
|
226
|
+
card: {
|
|
227
|
+
title: copy.title,
|
|
228
|
+
subtitle: copy.detail,
|
|
229
|
+
options: ["Confirm", "Cancel"],
|
|
230
|
+
requestId,
|
|
231
|
+
tool: "update_profile",
|
|
232
|
+
profileRequest: payload,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
if (args.from)
|
|
236
|
+
messageInput.from = args.from;
|
|
237
|
+
const message = this.store.appendMessage(args.threadId, messageInput);
|
|
238
|
+
return { requestId, messageId: message.id, title: copy.title, summary: copy.summary, detail: copy.detail };
|
|
239
|
+
}
|
|
240
|
+
/** Claims a profile card even after it was settled, so a duplicate click
|
|
241
|
+
* never re-applies an already-applied change. */
|
|
242
|
+
resolve(args) {
|
|
243
|
+
const message = this.store
|
|
244
|
+
.messagesFor(args.threadId)
|
|
245
|
+
.find((candidate) => candidate.card?.requestId === args.requestId && candidate.card.profileRequest);
|
|
246
|
+
const card = message?.card;
|
|
247
|
+
const payload = card?.profileRequest;
|
|
248
|
+
if (!message || !card || !payload)
|
|
249
|
+
return { claimed: false, state: "not_found" };
|
|
250
|
+
if (payload.requestId !== card.requestId) {
|
|
251
|
+
return { claimed: true, state: "invalid", error: "This profile request does not match its card", status: 409 };
|
|
252
|
+
}
|
|
253
|
+
if (args.behavior !== "allow" && args.behavior !== "deny") {
|
|
254
|
+
return { claimed: true, state: "invalid", error: "Profile confirmations must be confirmed or cancelled", status: 400 };
|
|
255
|
+
}
|
|
256
|
+
if (payload.botId !== args.botId || payload.threadId !== args.threadId) {
|
|
257
|
+
return { claimed: true, state: "invalid", error: "This profile request belongs to another conversation", status: 403 };
|
|
258
|
+
}
|
|
259
|
+
if (card.answered)
|
|
260
|
+
return { claimed: true, state: "already_settled", behavior: card.answered };
|
|
261
|
+
try {
|
|
262
|
+
const target = this.store.bot(payload.targetBotId);
|
|
263
|
+
// The profile and receipt share one durable write. If saving the card
|
|
264
|
+
// failed afterward, a retry only settles it; it never reapplies fields.
|
|
265
|
+
if (target?.lastProfileRequestId === payload.requestId) {
|
|
266
|
+
const settled = this.store.patchMessage(args.threadId, message.id, {
|
|
267
|
+
card: { ...card, answered: "allow", held: undefined, profileRequest: { ...payload, appliedAt: payload.appliedAt ?? this.now() } },
|
|
268
|
+
});
|
|
269
|
+
if (!settled)
|
|
270
|
+
throw new ProfileRequestError("This profile confirmation card is no longer available", 409);
|
|
271
|
+
return { claimed: true, state: "already_settled", behavior: "allow" };
|
|
272
|
+
}
|
|
273
|
+
if (args.behavior === "deny") {
|
|
274
|
+
this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } });
|
|
275
|
+
return { claimed: true, state: "denied" };
|
|
276
|
+
}
|
|
277
|
+
if (!target)
|
|
278
|
+
throw new ProfileRequestError(NO_SUCH_BOT, 404);
|
|
279
|
+
const crossBot = payload.targetBotId !== payload.botId;
|
|
280
|
+
if (crossBot && this.validateTarget) {
|
|
281
|
+
const refusal = this.validateTarget(payload.botId, payload.targetBotId);
|
|
282
|
+
if (refusal)
|
|
283
|
+
throw new ProfileRequestError(refusal, 404);
|
|
284
|
+
}
|
|
285
|
+
if (profileRevision(target) !== payload.expectedRevision) {
|
|
286
|
+
throw new ProfileRequestError(STALE, 409);
|
|
287
|
+
}
|
|
288
|
+
const { cwd, ...rest } = payload.changes;
|
|
289
|
+
const validated = Object.keys(rest).length ? parseChanges(rest) : {};
|
|
290
|
+
const patch = { ...validated, lastProfileRequestId: payload.requestId };
|
|
291
|
+
if (cwd !== undefined) {
|
|
292
|
+
// Re-checked at confirm: a card can sit open for days and the folder
|
|
293
|
+
// may be gone by then. 409 like the stale case — the card is no longer
|
|
294
|
+
// applicable as prepared.
|
|
295
|
+
const checked = validateBotCwd(cwd || null);
|
|
296
|
+
if (!checked.ok)
|
|
297
|
+
throw new ProfileRequestError(checked.error, 409);
|
|
298
|
+
patch.cwd = checked.cwd ?? undefined;
|
|
299
|
+
}
|
|
300
|
+
if (!this.store.patchBotProfile(target.id, patch))
|
|
301
|
+
throw new ProfileRequestError(NO_SUCH_BOT, 404);
|
|
302
|
+
recordProfileChange(target.id, "bot", `card:${message.id}`, payload.before, payload.changes);
|
|
303
|
+
const appliedAt = this.now();
|
|
304
|
+
const settled = this.store.patchMessage(args.threadId, message.id, {
|
|
305
|
+
card: { ...card, answered: "allow", held: undefined, profileRequest: { ...payload, appliedAt } },
|
|
306
|
+
});
|
|
307
|
+
if (!settled)
|
|
308
|
+
throw new ProfileRequestError("This profile confirmation card is no longer available", 409);
|
|
309
|
+
const fields = PROFILE_REQUEST_FIELDS.filter((field) => payload.changes[field] !== undefined);
|
|
310
|
+
return { claimed: true, state: "applied", targetBotId: target.id, fields };
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
const status = error instanceof ProfileRequestError ? error.status : 400;
|
|
314
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
315
|
+
const saved = this.store.bot(payload.targetBotId)?.lastProfileRequestId === payload.requestId;
|
|
316
|
+
const notice = "Profile saved. Confirm again to finish recording this decision; the changes will not be applied again.";
|
|
317
|
+
try {
|
|
318
|
+
this.store.patchMessage(args.threadId, message.id, {
|
|
319
|
+
card: { ...card, held: saved ? notice : redactSecretsInText(detail).slice(0, 500) },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch { /* The durable profile receipt still permits a safe retry. */ }
|
|
323
|
+
if (saved)
|
|
324
|
+
return {
|
|
325
|
+
claimed: true, state: "applied", targetBotId: payload.targetBotId,
|
|
326
|
+
fields: PROFILE_REQUEST_FIELDS.filter((field) => payload.changes[field] !== undefined),
|
|
327
|
+
settlementPending: true, message: notice,
|
|
328
|
+
};
|
|
329
|
+
return { claimed: true, state: "invalid", error: detail, status };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The revision token a profile card pins to. It covers exactly the
|
|
2
|
+
// fields a bot may propose (PROFILE_REQUEST_FIELDS), so a confirmation fails closed if any of them
|
|
3
|
+
// moved between the card and the click — the profile analogue of a
|
|
4
|
+
// routine's expectedUpdatedAt (bots have no updatedAt of their own).
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
export function profileSnapshot(bot) {
|
|
7
|
+
return { name: bot.name, title: bot.title, description: bot.description, soul: bot.soul ?? "", cwd: bot.cwd ?? "" };
|
|
8
|
+
}
|
|
9
|
+
export function profileRevision(bot) {
|
|
10
|
+
// A later proposal can restore identical text. Keep its private receipt
|
|
11
|
+
// in the opaque revision so an older, interrupted card cannot apply twice.
|
|
12
|
+
return createHash("sha256")
|
|
13
|
+
.update(JSON.stringify({ ...profileSnapshot(bot), lastProfileRequestId: bot.lastProfileRequestId ?? "" }), "utf8")
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|