openmausbot 0.1.56 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/index-B433sv8K.css +1 -0
  2. package/dist/assets/{index-Dq2j4Ikr.js → index-CE7W1AIk.js} +1 -1
  3. package/dist/assets/index-r-WPT-Pu.js +264 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -1
  6. package/dist-server/connector-proxy.js +31 -12
  7. package/dist-server/container-mcp.js +49 -31
  8. package/dist-server/drivers/agents-proxy.js +56 -2
  9. package/dist-server/index.js +2126 -487
  10. package/dist-server/openmausbot.js +15219 -305
  11. package/dist-server/pair-cli.js +15219 -305
  12. package/dist-server/server/auto-approve.js +24 -0
  13. package/dist-server/server/bot-folder.js +102 -0
  14. package/dist-server/server/bot-overview.js +206 -0
  15. package/dist-server/server/bot-package.js +5 -0
  16. package/dist-server/server/bot-profile.js +7 -0
  17. package/dist-server/server/browser-engine-release.js +44 -0
  18. package/dist-server/server/browser-engine.js +185 -0
  19. package/dist-server/server/cli.js +55 -1
  20. package/dist-server/server/connector-proxy.js +37 -16
  21. package/dist-server/server/container-computer.js +9 -2
  22. package/dist-server/server/drivers/agents-proxy.js +63 -2
  23. package/dist-server/server/drivers/antigravity-acp.js +119 -20
  24. package/dist-server/server/drivers/antigravity-runtime.js +47 -3
  25. package/dist-server/server/drivers/antigravity.js +23 -2
  26. package/dist-server/server/drivers/claude.js +7 -1
  27. package/dist-server/server/index.js +634 -107
  28. package/dist-server/server/mcp-bridge.js +73 -44
  29. package/dist-server/server/package-export.js +1 -0
  30. package/dist-server/server/profile-requests.js +332 -0
  31. package/dist-server/server/profile-revision.js +15 -0
  32. package/dist-server/server/profile-versions.js +154 -0
  33. package/dist-server/server/routine-requests.js +34 -1
  34. package/dist-server/server/routines.js +70 -2
  35. package/dist-server/server/setup-mode.js +63 -0
  36. package/dist-server/server/skill-fetch.js +67 -11
  37. package/dist-server/server/skills.js +1 -1
  38. package/dist-server/server/store.js +87 -7
  39. package/dist-server/server/system-prompt.js +49 -0
  40. package/dist-server/server/team-backup.js +2 -2
  41. package/dist-server/server/team-manifest.js +7 -0
  42. package/dist-server/shared/bot-profile.js +5 -1
  43. package/dist-server/shared/line-diff.js +42 -0
  44. package/dist-server/shared/profile-request.js +9 -0
  45. package/dist-server/shared/team-backup.js +4 -0
  46. package/dist-server/vps-container-mcp.js +49 -31
  47. package/package.json +1 -1
  48. package/dist/assets/index-CJBrtDbC.css +0 -1
  49. package/dist/assets/index-D0xt96rv.js +0 -263
@@ -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;
@@ -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);
@@ -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
+ }
@@ -0,0 +1,154 @@
1
+ // Per-bot profile history: an append-only NDJSON file in the bot folder,
2
+ // one row per changed field. Same discipline as decision-log.ts — 0600,
3
+ // through redactSecrets, serialized per file, fire-and-forget — because a
4
+ // history write must never fail the change it records. Full before/after
5
+ // text is kept only for the soul; redacted versions cannot be restored
6
+ // exactly and are not rollback targets. Other fields keep short one-liners.
7
+ import { appendFile } from "node:fs/promises";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { PROFILE_REQUEST_FIELDS } from "../shared/profile-request.js";
12
+ import { botFolder } from "./bot-folder.js";
13
+ import { redactSecrets } from "./redact.js";
14
+ const FILE_NAME = "history.ndjson";
15
+ const ONE_LINER = 200;
16
+ const MAX_HISTORY_READ_BYTES = 8 * 1024 * 1024;
17
+ const writeQueues = new Map();
18
+ export function historyFile(botId) {
19
+ return join(botFolder(botId), FILE_NAME);
20
+ }
21
+ function oneLiner(value) {
22
+ return value.replace(/\s+/g, " ").trim().slice(0, ONE_LINER);
23
+ }
24
+ function rowFor(field, at, actor, via, before, after) {
25
+ if (field === "soul") {
26
+ const b = Buffer.byteLength(before, "utf8");
27
+ const a = Buffer.byteLength(after, "utf8");
28
+ return { id: randomUUID(), at, actor, via, field, summary: `soul: ${b} → ${a} bytes`, before, after };
29
+ }
30
+ const b = oneLiner(before);
31
+ const a = oneLiner(after);
32
+ return { id: randomUUID(), at, actor, via, field, summary: `${field}: ${JSON.stringify(b)} → ${JSON.stringify(a)}`, before: b, after: a };
33
+ }
34
+ function withRestoreEligibility(row) {
35
+ const redacted = redactSecrets(row);
36
+ const canRestore = row.field === "soul" && typeof row.before === "string" &&
37
+ row.canRestore !== false && row.before === redacted.before &&
38
+ !/«redacted \d+ chars»/.test(row.before);
39
+ return {
40
+ ...redacted,
41
+ canRestore,
42
+ restoreUnavailableReason: row.field === "soul" && !canRestore
43
+ ? "This version is missing exact text or contains redacted sensitive text and cannot be restored."
44
+ : undefined,
45
+ };
46
+ }
47
+ async function writeRows(botId, rows) {
48
+ // A bot's folder is created when the bot is (writeSoulMirror on bot
49
+ // creation), so a real change always finds it there. If it is gone, the
50
+ // bot was deleted between the change firing and this write landing —
51
+ // skip rather than recreate a folder for a bot that no longer exists.
52
+ if (!existsSync(botFolder(botId)))
53
+ return;
54
+ const file = historyFile(botId);
55
+ mkdirSync(botFolder(botId), { recursive: true, mode: 0o700 });
56
+ const text = rows.map((row) => JSON.stringify(withRestoreEligibility(row))).join("\n") + "\n";
57
+ await appendFile(file, text, { mode: 0o600 });
58
+ }
59
+ /** Record every field that differs between `before` and `after`. */
60
+ export function recordProfileChange(botId, actor, via, before, after) {
61
+ const at = Date.now();
62
+ const rows = [];
63
+ for (const field of PROFILE_REQUEST_FIELDS) {
64
+ const b = before[field] ?? "";
65
+ const a = after[field] ?? "";
66
+ if (b !== a)
67
+ rows.push(rowFor(field, at, actor, via, b, a));
68
+ }
69
+ if (!rows.length)
70
+ return;
71
+ const previous = writeQueues.get(botId) ?? Promise.resolve();
72
+ const queued = previous.then(() => writeRows(botId, rows)).catch(() => {
73
+ /* history must never take down the change it records */
74
+ });
75
+ writeQueues.set(botId, queued);
76
+ void queued.finally(() => {
77
+ if (writeQueues.get(botId) === queued)
78
+ writeQueues.delete(botId);
79
+ });
80
+ }
81
+ /** Test/shutdown seam. */
82
+ export async function flushProfileHistory(botId) {
83
+ await writeQueues.get(botId);
84
+ }
85
+ /** Shutdown seam for every bot at once: awaits whatever is currently
86
+ * queued, so a history write in flight when the process stops still lands
87
+ * before it exits. Each queued promise already swallows its own error
88
+ * (`writeRows` must never take down the change it records), so this never
89
+ * rejects either. */
90
+ export async function flushAllProfileHistory() {
91
+ await Promise.allSettled(writeQueues.values());
92
+ }
93
+ const isRow = (value) => typeof value === "object" &&
94
+ value !== null &&
95
+ typeof value.at === "number" &&
96
+ typeof value.field === "string" &&
97
+ typeof value.actor === "string";
98
+ /** Newest first, bounded to 500 rows from the newest 8 MiB. The append-only
99
+ * log keeps older changes without making every settings read load them. */
100
+ export function readHistory(botId, limit = 100) {
101
+ let text;
102
+ let offset;
103
+ let end;
104
+ try {
105
+ const fd = openSync(historyFile(botId), "r");
106
+ try {
107
+ const size = fstatSync(fd).size;
108
+ offset = Math.max(0, size - MAX_HISTORY_READ_BYTES);
109
+ const buffer = Buffer.alloc(size - offset);
110
+ let length = 0;
111
+ while (length < buffer.length) {
112
+ const count = readSync(fd, buffer, length, buffer.length - length, offset + length);
113
+ if (!count)
114
+ break;
115
+ length += count;
116
+ }
117
+ end = offset + length;
118
+ text = buffer.subarray(0, length).toString("utf8");
119
+ }
120
+ finally {
121
+ closeSync(fd);
122
+ }
123
+ }
124
+ catch {
125
+ return [];
126
+ }
127
+ const rows = [];
128
+ const lines = text.split("\n");
129
+ const maxRows = Math.min(500, Math.max(1, limit));
130
+ for (let index = lines.length - 1; index >= (offset > 0 ? 1 : 0) && rows.length < maxRows; index--) {
131
+ const line = lines[index];
132
+ end -= Buffer.byteLength(line, "utf8") + (index < lines.length - 1 ? 1 : 0);
133
+ if (!line)
134
+ continue;
135
+ try {
136
+ const value = JSON.parse(line);
137
+ if (isRow(value)) {
138
+ // Old logs had only millisecond timestamps (not unique). Their
139
+ // append-only byte position gives each legacy entry a stable id,
140
+ // even as the bounded read window moves after later appends.
141
+ const id = typeof value.id === "string" && value.id
142
+ ? value.id
143
+ : `legacy-${createHash("sha256").update(`${end}:${line}`).digest("hex")}`;
144
+ // Recheck old logs too: they predate eligibility metadata and may
145
+ // already contain irreversible redaction markers in the before-text.
146
+ rows.push(withRestoreEligibility({ ...value, id }));
147
+ }
148
+ }
149
+ catch {
150
+ /* torn line — skip */
151
+ }
152
+ }
153
+ return rows;
154
+ }