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.
Files changed (56) hide show
  1. package/dist/assets/index-B433sv8K.css +1 -0
  2. package/dist/assets/index-DSh8AIUy.js +264 -0
  3. package/dist/assets/{index-D-u0LYsV.js → index-jWqt5dmY.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +218 -0
  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 +2020 -361
  10. package/dist-server/openmausbot.js +45 -386
  11. package/dist-server/pair-cli.js +45 -386
  12. package/dist-server/server/auto-approve.js +48 -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 +24 -0
  17. package/dist-server/server/channel-queue.js +1 -0
  18. package/dist-server/server/computer-control.js +15 -0
  19. package/dist-server/server/connector-proxy.js +37 -16
  20. package/dist-server/server/container-computer.js +9 -2
  21. package/dist-server/server/drivers/agents-proxy.js +63 -2
  22. package/dist-server/server/drivers/antigravity-acp.js +61 -13
  23. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  24. package/dist-server/server/drivers/claude.js +7 -1
  25. package/dist-server/server/exit.js +9 -0
  26. package/dist-server/server/index.js +707 -124
  27. package/dist-server/server/mcp-bridge.js +73 -44
  28. package/dist-server/server/openmausbot.js +3 -2
  29. package/dist-server/server/package-export.js +1 -0
  30. package/dist-server/server/pair-cli.js +3 -2
  31. package/dist-server/server/peer-approval.js +27 -0
  32. package/dist-server/server/peer-provenance.js +4 -1
  33. package/dist-server/server/peer-roster.js +15 -0
  34. package/dist-server/server/profile-requests.js +332 -0
  35. package/dist-server/server/profile-revision.js +15 -0
  36. package/dist-server/server/profile-versions.js +154 -0
  37. package/dist-server/server/request-auth.js +10 -0
  38. package/dist-server/server/routine-requests.js +34 -1
  39. package/dist-server/server/routines.js +70 -2
  40. package/dist-server/server/setup-mode.js +63 -0
  41. package/dist-server/server/skill-fetch.js +67 -11
  42. package/dist-server/server/skills.js +1 -1
  43. package/dist-server/server/store.js +87 -7
  44. package/dist-server/server/system-prompt.js +49 -0
  45. package/dist-server/server/team-backup.js +2 -2
  46. package/dist-server/server/team-manifest.js +7 -0
  47. package/dist-server/server/tunnel.js +1 -1
  48. package/dist-server/shared/bot-profile.js +5 -1
  49. package/dist-server/shared/line-diff.js +42 -0
  50. package/dist-server/shared/profile-request.js +9 -0
  51. package/dist-server/shared/team-backup.js +4 -0
  52. package/dist-server/tunnel-guardian.js +6 -19
  53. package/dist-server/vps-container-mcp.js +49 -31
  54. package/package.json +1 -1
  55. package/dist/assets/index-B9rLIY81.js +0 -263
  56. package/dist/assets/index-CJBrtDbC.css +0 -1
@@ -1,17 +1,17 @@
1
- // The one transparent stdio bridge behind both MCP entry points
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
- // The single exception to that transparency is the who-is-driving gate
6
- // (opt-in via `gate`). While the person holds control of this computer in
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
- // Two behaviors live here so neither entry point can drift:
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
- let detach;
178
- if (options.gate) {
179
- const client = createControlClient({ url: options.gate.url, token: options.gate.token });
180
- const inbound = createLineSplitter(createGateInterceptor({
181
- isHeld: async () => (await client.state(true)).held,
182
- forward: (line) => child.stdin.write(line + "\n"),
183
- refuse: (line) => process.stdout.write(line + "\n"),
184
- }));
185
- const onStdin = (chunk) => inbound.push(chunk);
186
- process.stdin.on("data", onStdin);
187
- process.stdin.on("end", () => {
188
- inbound.flush();
189
- child.stdin.end();
190
- });
191
- // Injected refusals must never land inside one of the child's
192
- // half-written frames, so the child's stdout is re-emitted at line
193
- // granularity as well.
194
- const outbound = createLineSplitter((line) => process.stdout.write(line + "\n"));
195
- child.stdout.on("data", (chunk) => outbound.push(chunk));
196
- child.stdout.on("end", () => outbound.flush());
197
- detach = () => {
198
- process.stdin.off("data", onStdin);
199
- process.stdin.pause();
200
- };
201
- }
202
- else {
203
- process.stdin.pipe(child.stdin);
204
- child.stdout.pipe(process.stdout);
205
- detach = () => {
206
- process.stdin.unpipe(child.stdin);
207
- process.stdin.pause();
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
- main().then((code) => process.exit(code), (error) => {
3
+ import { exitAfterFlush } from "./exit.js";
4
+ main().then(exitAfterFlush, (error) => {
4
5
  console.error(error instanceof Error ? error.message : String(error));
5
- process.exit(1);
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
- main(["pair", ...process.argv.slice(2)]).then((code) => process.exit(code), (error) => {
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
- process.exit(1);
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
+ }