openmausbot 0.1.79 → 0.1.81

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 (60) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/index-CKysBq-V.css +1 -0
  3. package/dist/assets/{index-C2Je5jdF.js → index-oLMljotU.js} +1 -1
  4. package/dist/assets/index-peLFiSH8.js +307 -0
  5. package/dist/index.html +2 -2
  6. package/dist-server/container-mcp.js +41 -10
  7. package/dist-server/drivers/agents-proxy.js +71 -17
  8. package/dist-server/index.js +4104 -1505
  9. package/dist-server/openmausbot.js +554 -163
  10. package/dist-server/pair-cli.js +554 -163
  11. package/dist-server/server/box-delete-journal.js +471 -0
  12. package/dist-server/server/box.js +345 -75
  13. package/dist-server/server/cli-phone-setup.js +2 -2
  14. package/dist-server/server/cli.js +84 -12
  15. package/dist-server/server/config.js +48 -18
  16. package/dist-server/server/container-computer.js +7 -0
  17. package/dist-server/server/contracts.js +5 -0
  18. package/dist-server/server/default-model-selection.js +1 -0
  19. package/dist-server/server/drivers/acp/core.js +112 -18
  20. package/dist-server/server/drivers/acp/opencode-go.js +6 -0
  21. package/dist-server/server/drivers/agents-proxy.js +76 -13
  22. package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
  23. package/dist-server/server/drivers/claude.js +87 -63
  24. package/dist-server/server/drivers/codex.js +248 -51
  25. package/dist-server/server/drivers/native.js +6 -1
  26. package/dist-server/server/harness/bus.js +4 -0
  27. package/dist-server/server/harness/registry.js +3 -2
  28. package/dist-server/server/index.js +1007 -222
  29. package/dist-server/server/local-vm-lease.js +4 -0
  30. package/dist-server/server/managed-desktop.js +205 -0
  31. package/dist-server/server/mcp-http.js +337 -0
  32. package/dist-server/server/mcp-probe.js +67 -18
  33. package/dist-server/server/mcp-registry.js +153 -58
  34. package/dist-server/server/member-turn.js +13 -0
  35. package/dist-server/server/message-db.js +74 -8
  36. package/dist-server/server/model-context-window.js +20 -0
  37. package/dist-server/server/recall-disclosure.js +4 -0
  38. package/dist-server/server/recent-work.js +142 -0
  39. package/dist-server/server/redact.js +1 -0
  40. package/dist-server/server/request-auth.js +3 -1
  41. package/dist-server/server/room-handoffs.js +16 -6
  42. package/dist-server/server/routines.js +17 -0
  43. package/dist-server/server/screen-frame-gate.js +4 -94
  44. package/dist-server/server/sessions.js +22 -3
  45. package/dist-server/server/store.js +29 -2
  46. package/dist-server/server/surface.js +41 -19
  47. package/dist-server/server/team-setup-requests.js +1 -1
  48. package/dist-server/server/thread-log-rotation.js +104 -0
  49. package/dist-server/server/tts/fish.js +155 -0
  50. package/dist-server/server/tts/index.js +25 -3
  51. package/dist-server/server/vps-computer.js +26 -4
  52. package/dist-server/server/vps-ssh.js +88 -0
  53. package/dist-server/server/workspace-backup.js +11 -0
  54. package/dist-server/server/workspace.js +2 -1
  55. package/dist-server/shared/credential-request.js +10 -0
  56. package/dist-server/shared/tool-surface.js +62 -0
  57. package/dist-server/vps-container-mcp.js +41 -10
  58. package/package.json +1 -1
  59. package/dist/assets/index-D_TFZLlo.js +0 -305
  60. package/dist/assets/index-Pbb6Ao0s.css +0 -1
@@ -13,7 +13,8 @@
13
13
  import { createHash } from "node:crypto";
14
14
  import { DATA_DIR } from "./config.js";
15
15
  import { loadEnvironmentId } from "./environment.js";
16
- import { adoptResolvedBox, beginBoxCreate, discardBoxCreate, rememberCreatedBox, resolveBoxCreate, retireDeletedBoxCreate, } from "./box-create-idempotency.js";
16
+ import { adoptResolvedBox, beginBoxCreate, boxCreateRecoverySnapshot, discardBoxCreate, rememberCreatedBox, resolveBoxCreate, retireDeletedBoxCreate, } from "./box-create-idempotency.js";
17
+ import { boxDeletionSnapshot, getBoxDeletion, hasPendingBoxDeletionForBot, markBoxDeletionAccepted, markBoxDeletionBlocked, prepareBoxDeletion, retireBoxDeletion, } from "./box-delete-journal.js";
17
18
  const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
18
19
  export const MAX_REMOTE_COMMAND_LENGTH = 4_000;
19
20
  /** Run an owner-supplied console command without inheriting provider or
@@ -63,6 +64,7 @@ const BOX_STATES = new Set([
63
64
  "provisioned",
64
65
  "cloning",
65
66
  "starting",
67
+ "removing",
66
68
  "error",
67
69
  ]);
68
70
  // Provider listings are account-wide. Hash the durable local environment id
@@ -129,7 +131,7 @@ function boxDeletionOperation(body, boxId, expectedOperationId) {
129
131
  || operation?.targetId !== boxId
130
132
  || !BOX_DELETE_OPERATION_STATES.has(status))
131
133
  return null;
132
- return { id, status: status };
134
+ return { id, kind: "box", targetId: boxId, status: status };
133
135
  }
134
136
  function deletionBlockedError(boxId) {
135
137
  return Object.assign(new Error(`ascii.dev accepted deletion of ${boxId}, but the deletion operation is blocked — check ascii.dev and retry`), { status: 409 });
@@ -137,22 +139,29 @@ function deletionBlockedError(boxId) {
137
139
  function boxDeleteProvedAbsent(result) {
138
140
  return result.status === 404 || result.status === 410;
139
141
  }
140
- /** DELETE /boxes only accepts background deletion. Keep the durable create
141
- * receipt until its operation completes. If operation polling is unavailable,
142
- * a direct 404/410 is the only alternate proof that this Box identity is gone. */
143
- async function confirmAcceptedBoxDeletion(cfg, boxId, acceptedBody) {
144
- let operation = boxDeletionOperation(acceptedBody, boxId);
145
- if (operation?.status === "completed")
146
- return;
147
- if (operation?.status === "blocked")
148
- throw deletionBlockedError(boxId);
149
- if (operation) {
150
- for (const delayMs of BOX_DELETE_OPERATION_POLL_DELAYS_MS) {
142
+ /** Retire the create receipt before the deletion fence. If that first durable
143
+ * write fails, the fence remains and no caller can reuse a Box whose ownership
144
+ * recovery is uncertain. */
145
+ function finishRecordedBoxDeletion(boxId) {
146
+ retireDeletedBoxCreate(boxId);
147
+ forgetBoxId(boxId);
148
+ retireBoxDeletion(boxId);
149
+ }
150
+ /** Reconcile one durable deletion against the exact provider operation/Box.
151
+ * Account LIST omission is never evidence: it is eventually consistent. */
152
+ async function reconcileRecordedBoxDeletion(cfg, initial, pollDelaysMs = []) {
153
+ let record = initial;
154
+ if (record.phase === "accepted" && record.status === "completed") {
155
+ finishRecordedBoxDeletion(record.boxId);
156
+ return "confirmed";
157
+ }
158
+ if (record.phase === "accepted" && record.operationId) {
159
+ for (const delayMs of pollDelaysMs) {
151
160
  if (delayMs > 0)
152
161
  await new Promise((resolve) => setTimeout(resolve, delayMs));
153
162
  let polled;
154
163
  try {
155
- polled = await boxJson(cfg, `/deletion-operations/${operation.id}`, {
164
+ polled = await boxJson(cfg, `/deletion-operations/${record.operationId}`, {
156
165
  signal: AbortSignal.timeout(20_000),
157
166
  });
158
167
  }
@@ -161,23 +170,120 @@ async function confirmAcceptedBoxDeletion(cfg, boxId, acceptedBody) {
161
170
  }
162
171
  if (!polled.ok)
163
172
  break;
164
- const current = boxDeletionOperation(polled.body, boxId, operation.id);
165
- if (!current)
173
+ const operation = boxDeletionOperation(polled.body, record.boxId, record.operationId);
174
+ if (!operation)
166
175
  break;
167
- operation = current;
168
- if (operation.status === "completed")
169
- return;
170
- if (operation.status === "blocked")
171
- throw deletionBlockedError(boxId);
176
+ if (operation.status === "blocked") {
177
+ record = markBoxDeletionBlocked(record.boxId, operation);
178
+ break;
179
+ }
180
+ record = markBoxDeletionAccepted(record.boxId, operation);
181
+ if (operation.status === "completed") {
182
+ finishRecordedBoxDeletion(record.boxId);
183
+ return "confirmed";
184
+ }
185
+ }
186
+ }
187
+ // A direct immutable-id 404/410 is the only alternate completion proof.
188
+ // A live identity keeps the fence even when the operation endpoint is down.
189
+ const inspected = await inspectBoxIdentity(cfg, record.boxId);
190
+ if (!inspected.available)
191
+ return record.phase === "blocked" ? "blocked" : "pending";
192
+ if (!inspected.identity) {
193
+ finishRecordedBoxDeletion(record.boxId);
194
+ return "confirmed";
195
+ }
196
+ if (inspected.identity.name !== record.name) {
197
+ throw Object.assign(new Error("A cloud computer being deleted no longer has its remembered name — repair it in ascii.dev before continuing"), { status: 503 });
198
+ }
199
+ return record.phase === "blocked" ? "blocked" : "pending";
200
+ }
201
+ /** Prove that a replacement token can see every durable deletion target
202
+ * before Settings swaps credentials. Unlike normal reconciliation, a bare
203
+ * 404 is not completion proof here: it may simply be a different account. */
204
+ export async function verifyBoxDeletionCredential(cfg) {
205
+ cfg = snapshotBoxConfig(cfg);
206
+ for (const initial of boxDeletionSnapshot()) {
207
+ let record = initial;
208
+ let operationAuthorized = false;
209
+ if (record.phase === "accepted" && record.operationId) {
210
+ let polled = null;
211
+ try {
212
+ polled = await boxJson(cfg, `/deletion-operations/${record.operationId}`, {
213
+ signal: AbortSignal.timeout(20_000),
214
+ });
215
+ }
216
+ catch {
217
+ // The exact Box identity below can still prove account continuity.
218
+ }
219
+ if (polled?.ok) {
220
+ const operation = boxDeletionOperation(polled.body, record.boxId, record.operationId);
221
+ if (operation) {
222
+ operationAuthorized = true;
223
+ record = operation.status === "blocked"
224
+ ? markBoxDeletionBlocked(record.boxId, operation)
225
+ : markBoxDeletionAccepted(record.boxId, operation);
226
+ if (operation.status === "completed") {
227
+ finishRecordedBoxDeletion(record.boxId);
228
+ continue;
229
+ }
230
+ }
231
+ }
172
232
  }
233
+ if (operationAuthorized)
234
+ continue;
235
+ const inspected = await inspectBoxIdentity(cfg, record.boxId);
236
+ if (inspected.available && inspected.identity?.name === record.name)
237
+ continue;
238
+ if (!inspected.available) {
239
+ throw Object.assign(new Error(`${inspected.problem ?? "a deleting cloud computer could not be verified"}. Retry with the Box account that owns it`), { status: 503 });
240
+ }
241
+ throw Object.assign(new Error("that Box token cannot access the cloud computers whose deletion is still being reconciled"), { status: 409 });
242
+ }
243
+ }
244
+ /** Bind a successful DELETE response to the durable target before polling.
245
+ * A malformed receipt leaves the prepared fence intact. */
246
+ async function confirmAcceptedBoxDeletion(cfg, record, acceptedBody, pollDelaysMs = BOX_DELETE_OPERATION_POLL_DELAYS_MS) {
247
+ const operation = boxDeletionOperation(acceptedBody, record.boxId);
248
+ if (!operation) {
249
+ const inspected = await inspectBoxIdentity(cfg, record.boxId);
250
+ if (inspected.available && !inspected.identity) {
251
+ finishRecordedBoxDeletion(record.boxId);
252
+ return "confirmed";
253
+ }
254
+ throw Object.assign(new Error(`ascii.dev returned an invalid deletion receipt for ${record.boxId}; its deletion fence was kept`), { status: 503 });
255
+ }
256
+ const next = operation.status === "blocked"
257
+ ? markBoxDeletionBlocked(record.boxId, operation)
258
+ : markBoxDeletionAccepted(record.boxId, operation);
259
+ return reconcileRecordedBoxDeletion(cfg, next, pollDelaysMs);
260
+ }
261
+ /** Send (or explicitly retry) DELETE only after the immutable target is on
262
+ * disk. The returned pending state always has a validated operation receipt. */
263
+ async function requestRecordedBoxDeletion(cfg, identity, pollDelaysMs = BOX_DELETE_OPERATION_POLL_DELAYS_MS) {
264
+ const deletion = prepareBoxDeletion(identity);
265
+ let removed;
266
+ try {
267
+ removed = await boxJson(cfg, `/boxes/${identity.boxId}`, {
268
+ method: "DELETE",
269
+ headers: { "X-Ascii-Confirm-Delete": identity.boxId },
270
+ });
173
271
  }
174
- const inspected = await inspectBoxIdentity(cfg, boxId);
175
- if (!inspected.available) {
176
- throw Object.assign(new Error(`${inspected.problem ?? "the cloud computer deletion could not be verified"}; its recovery record was kept`), { status: 503 });
272
+ catch (error) {
273
+ throw Object.assign(new Error("Could not confirm whether ascii.dev accepted the delete. The computer was kept fenced; retry Delete to reconcile it"), { status: 503, cause: error });
274
+ }
275
+ if (boxDeleteProvedAbsent(removed)) {
276
+ finishRecordedBoxDeletion(identity.boxId);
277
+ return "confirmed";
177
278
  }
178
- if (inspected.identity) {
179
- throw Object.assign(new Error(`ascii.dev accepted deletion of ${boxId}, but it is not confirmed yet — retry after it finishes`), { status: 503 });
279
+ if (!removed.ok) {
280
+ markBoxDeletionBlocked(identity.boxId);
281
+ throw Object.assign(new Error(boxErrorMessage(removed.status, "box delete", removed.body)), { status: removed.status });
180
282
  }
283
+ const confirmation = await confirmAcceptedBoxDeletion(cfg, deletion, removed.body, pollDelaysMs);
284
+ if (confirmation === "blocked")
285
+ throw deletionBlockedError(identity.boxId);
286
+ return confirmation;
181
287
  }
182
288
  function boxBotNameParts(botId) {
183
289
  const prefix = botId.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "") || "bot";
@@ -200,6 +306,7 @@ export async function boxNameMatchesBot(botId, name) {
200
306
  return name === await boxNameFor(botId) || name === legacyBoxNameFor(botId);
201
307
  }
202
308
  export async function runCommand(cfg, boxId, command, { timeoutMs = 120_000 } = {}) {
309
+ assertBoxNotDeleting(boxId);
203
310
  const res = await boxFetch(cfg, `/boxes/${boxId}/commands`, {
204
311
  method: "POST",
205
312
  body: JSON.stringify({ command }),
@@ -219,8 +326,10 @@ export async function runCommand(cfg, boxId, command, { timeoutMs = 120_000 } =
219
326
  // 2) WebRTC stream (POST /desktop) as fallback — STUN-only, can hang.
220
327
  // The desktopUrl stored on the box object is NOT usable on its own.
221
328
  async function mintDesktopUrl(cfg, boxId, { vncBudgetMs = 60_000 } = {}) {
329
+ assertBoxNotDeleting(boxId);
222
330
  const t0 = Date.now();
223
331
  while (Date.now() - t0 < vncBudgetMs) {
332
+ assertBoxNotDeleting(boxId);
224
333
  const { body } = await boxJson(cfg, `/boxes/${boxId}/desktop?vnc=1`, { method: "POST" });
225
334
  const url = body?.desktopUrl ?? body?.url;
226
335
  if (url)
@@ -233,8 +342,10 @@ async function mintDesktopUrl(cfg, boxId, { vncBudgetMs = 60_000 } = {}) {
233
342
  return body?.desktopUrl ?? body?.url ?? null;
234
343
  }
235
344
  async function waitReady(cfg, boxId, budgetMs = 90_000) {
345
+ assertBoxNotDeleting(boxId);
236
346
  const t0 = Date.now();
237
347
  while (Date.now() - t0 < budgetMs) {
348
+ assertBoxNotDeleting(boxId);
238
349
  const { body } = await boxJson(cfg, `/boxes/${boxId}`);
239
350
  const state = body?.box?.state;
240
351
  if (READY.has(state))
@@ -328,24 +439,132 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
328
439
  legacyName: legacyBoxNameFor(owner.botId),
329
440
  owner,
330
441
  })));
442
+ const invalidInventory = (problem) => ({
443
+ configured: true,
444
+ available: false,
445
+ problem,
446
+ instances: [],
447
+ });
448
+ // A successful create is journaled before the account-wide LIST is
449
+ // guaranteed to include it. Reconcile that durable identity with the
450
+ // authoritative direct endpoint so Settings can still display and delete
451
+ // the computer. Credential replacement probes deliberately opt out: their
452
+ // token must be judged only by the account inventory it can list.
453
+ let candidates = [...listed.boxes];
454
+ if (options.adoptLegacy !== false) {
455
+ const namedOwnerByBotId = new Map(namedOwners.map((entry) => [entry.owner.botId, entry]));
456
+ let recoveries;
457
+ try {
458
+ recoveries = boxCreateRecoverySnapshot();
459
+ }
460
+ catch {
461
+ return invalidInventory("OpenMausBot could not safely read its cloud computer recovery records");
462
+ }
463
+ for (const recovery of recoveries) {
464
+ if (!recovery.resolved || !recovery.boxId)
465
+ continue;
466
+ const namedOwner = namedOwnerByBotId.get(recovery.botId);
467
+ if (!namedOwner)
468
+ continue;
469
+ const matchingRows = candidates.filter((candidate) => candidate?.id === recovery.boxId);
470
+ if (matchingRows.length > 1) {
471
+ return invalidInventory("ascii.dev returned a conflicting id for an OpenMaus-managed cloud computer — refresh or repair it in ascii.dev");
472
+ }
473
+ if (matchingRows.length === 1) {
474
+ const listedName = typeof matchingRows[0]?.name === "string" ? matchingRows[0].name : "";
475
+ if (listedName !== namedOwner.currentName && listedName !== namedOwner.legacyName) {
476
+ return invalidInventory("A remembered cloud computer no longer has its OpenMausBot owner name — repair it in ascii.dev before continuing");
477
+ }
478
+ continue;
479
+ }
480
+ const inspected = await inspectBoxIdentity(cfg, recovery.boxId);
481
+ if (!inspected.available) {
482
+ return invalidInventory(inspected.problem ?? "A remembered cloud computer could not be verified");
483
+ }
484
+ if (!inspected.identity) {
485
+ // Direct 404/410 is stronger than an eventually-consistent LIST row.
486
+ candidates = candidates.filter((candidate) => candidate?.id !== recovery.boxId);
487
+ retireDeletedBoxCreate(recovery.boxId);
488
+ continue;
489
+ }
490
+ if (inspected.identity.name !== namedOwner.currentName
491
+ && inspected.identity.name !== namedOwner.legacyName) {
492
+ return invalidInventory("A remembered cloud computer no longer has its OpenMausBot owner name — repair it in ascii.dev before continuing");
493
+ }
494
+ const directCandidate = {
495
+ id: inspected.identity.boxId,
496
+ name: inspected.identity.name,
497
+ state: inspected.identity.state,
498
+ };
499
+ candidates.push(directCandidate);
500
+ }
501
+ let deletions;
502
+ try {
503
+ deletions = boxDeletionSnapshot();
504
+ }
505
+ catch {
506
+ return invalidInventory("OpenMausBot could not safely read its cloud computer deletion records");
507
+ }
508
+ for (const deletion of deletions) {
509
+ let state;
510
+ try {
511
+ state = await reconcileRecordedBoxDeletion(cfg, deletion, [0]);
512
+ }
513
+ catch (error) {
514
+ return invalidInventory(error instanceof Error ? error.message : "A cloud computer deletion could not be verified");
515
+ }
516
+ if (state === "confirmed") {
517
+ // LIST may still contain a stale row after the exact operation/direct
518
+ // endpoint proved deletion. Do not let it resurrect the computer.
519
+ candidates = candidates.filter((candidate) => candidate?.id !== deletion.boxId);
520
+ continue;
521
+ }
522
+ const matchingRows = candidates.filter((candidate) => candidate?.id === deletion.boxId);
523
+ if (matchingRows.length > 1) {
524
+ return invalidInventory("ascii.dev returned a conflicting id for a cloud computer being deleted");
525
+ }
526
+ if (matchingRows.length === 1) {
527
+ if (matchingRows[0]?.name !== deletion.name) {
528
+ return invalidInventory("A cloud computer being deleted no longer has its remembered name — repair it in ascii.dev before continuing");
529
+ }
530
+ if (getBoxDeletion(deletion.boxId)?.phase === "accepted") {
531
+ matchingRows[0] = { ...matchingRows[0], state: "removing" };
532
+ candidates = candidates.map((candidate) => candidate?.id === deletion.boxId ? matchingRows[0] : candidate);
533
+ }
534
+ continue;
535
+ }
536
+ const current = getBoxDeletion(deletion.boxId);
537
+ if (!current)
538
+ continue;
539
+ if (current.phase === "accepted") {
540
+ candidates.push({ id: current.boxId, name: current.name, state: "removing" });
541
+ continue;
542
+ }
543
+ // A prepared request may have lost its response, and a blocked request
544
+ // is retryable. Keep the exact row actionable only after a direct read.
545
+ const inspected = await inspectBoxIdentity(cfg, current.boxId);
546
+ if (!inspected.available || !inspected.identity || inspected.identity.name !== current.name) {
547
+ return invalidInventory(inspected.problem ?? "A cloud computer deletion target could not be verified");
548
+ }
549
+ candidates.push({
550
+ id: inspected.identity.boxId,
551
+ name: inspected.identity.name,
552
+ state: inspected.identity.state,
553
+ });
554
+ }
555
+ }
331
556
  const ownerByCurrentName = new Map(namedOwners.map(({ currentName, owner }) => [currentName, owner]));
332
557
  const ownerByLegacyName = new Map(namedOwners.map(({ legacyName, owner }) => [legacyName, owner]));
333
558
  const boxIdCounts = new Map();
334
- for (const candidate of listed.boxes) {
559
+ for (const candidate of candidates) {
335
560
  if (!candidate || typeof candidate !== "object")
336
561
  continue;
337
562
  const boxId = typeof candidate.id === "string" ? candidate.id : "";
338
563
  if (BOX_ID.test(boxId))
339
564
  boxIdCounts.set(boxId, (boxIdCounts.get(boxId) ?? 0) + 1);
340
565
  }
341
- const invalidInventory = (problem) => ({
342
- configured: true,
343
- available: false,
344
- problem,
345
- instances: [],
346
- });
347
566
  const ownedBoxByBot = new Map();
348
- for (const candidate of listed.boxes) {
567
+ for (const candidate of candidates) {
349
568
  if (!candidate || typeof candidate !== "object")
350
569
  continue;
351
570
  const name = typeof candidate.name === "string" ? candidate.name : "";
@@ -365,7 +584,7 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
365
584
  const instances = [];
366
585
  const seenBoxIds = new Set();
367
586
  const scopedPrefix = scopedBoxPrefix();
368
- for (const candidate of listed.boxes) {
587
+ for (const candidate of candidates) {
369
588
  if (!candidate || typeof candidate !== "object")
370
589
  continue;
371
590
  const boxId = typeof candidate.id === "string" ? candidate.id : "";
@@ -428,7 +647,8 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
428
647
  }
429
648
  /** Direct identity proof for a Box remembered in the local create journal.
430
649
  * Unlike account LIST, this endpoint is not eventually consistent. Only the
431
- * immutable id and provider name cross this boundary. */
650
+ * immutable id, provider name and allowlisted lifecycle state cross this
651
+ * boundary. */
432
652
  export async function inspectBoxIdentity(cfg, boxId) {
433
653
  cfg = snapshotBoxConfig(cfg);
434
654
  if (!BOX_ID.test(boxId)) {
@@ -457,7 +677,7 @@ export async function inspectBoxIdentity(cfg, boxId) {
457
677
  if (returnedId !== boxId || name.length === 0 || name.length > 100 || /[\r\n]/.test(name)) {
458
678
  return { available: false, identity: null, problem: "ascii.dev returned an invalid cloud computer identity" };
459
679
  }
460
- return { available: true, identity: { boxId, name }, problem: null };
680
+ return { available: true, identity: { boxId, name, state: safeBoxState(candidate.state) }, problem: null };
461
681
  }
462
682
  function inventoryFailure(inventory) {
463
683
  const error = new Error(inventory.configured
@@ -466,6 +686,17 @@ function inventoryFailure(inventory) {
466
686
  error.status = inventory.configured ? 503 : 409;
467
687
  return error;
468
688
  }
689
+ function deletionFenceError() {
690
+ return Object.assign(new Error("this cloud computer is being deleted — wait for it to finish, or retry Delete if it needs attention"), { status: 409 });
691
+ }
692
+ function assertBoxNotDeleting(boxId) {
693
+ if (getBoxDeletion(boxId))
694
+ throw deletionFenceError();
695
+ }
696
+ function assertBotBoxNotDeleting(botId) {
697
+ if (hasPendingBoxDeletionForBot(botId))
698
+ throw deletionFenceError();
699
+ }
469
700
  async function revalidateManagedBox(cfg, owners, boxId) {
470
701
  if (!BOX_ID.test(boxId))
471
702
  throw Object.assign(new Error("invalid cloud computer id"), { status: 400 });
@@ -483,6 +714,7 @@ const QUIESCE_BROWSER = [
483
714
  'for i in 1 2 3 4 5 6 7 8; do if ! pgrep -x chrome >/dev/null 2>&1 && ! pgrep -x google-chrome >/dev/null 2>&1 && ! pgrep -x chromium >/dev/null 2>&1 && ! pgrep -x chromium-browser >/dev/null 2>&1; then break; fi; sleep 0.25; done',
484
715
  ].join("; ");
485
716
  async function stopBox(cfg, boxId) {
717
+ assertBoxNotDeleting(boxId);
486
718
  // Browser shutdown is best-effort, but the provider stop is not: Settings
487
719
  // must never say a computer is sleeping when ascii.dev rejected the action.
488
720
  await runCommand(cfg, boxId, QUIESCE_BROWSER, { timeoutMs: 5_000 }).catch(() => null);
@@ -500,6 +732,7 @@ function forgetBoxId(boxId) {
500
732
  * renamed or foreign provider resource. This never wakes or joins a Box. */
501
733
  export async function sleepManagedBox(cfg, owners, boxId, claim) {
502
734
  cfg = snapshotBoxConfig(cfg);
735
+ assertBoxNotDeleting(boxId);
503
736
  const instance = await revalidateManagedBox(cfg, owners, boxId);
504
737
  if (instance.inUse) {
505
738
  throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
@@ -521,8 +754,22 @@ export async function sleepManagedBox(cfg, owners, boxId, claim) {
521
754
  /** Permanent Settings action. The caller must echo the exact freshly-listed
522
755
  * machine name as well as its id; ascii.dev independently requires the id in
523
756
  * its confirmation header. */
524
- export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim) {
757
+ export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim, options = {}) {
525
758
  cfg = snapshotBoxConfig(cfg);
759
+ const remembered = getBoxDeletion(boxId);
760
+ if (remembered) {
761
+ const reconciled = await reconcileRecordedBoxDeletion(cfg, remembered, [0]);
762
+ if (reconciled === "confirmed")
763
+ return { ok: true };
764
+ // A validated accepted operation owns this target. Retrying DELETE would
765
+ // create a second operation and weaken the only trustworthy receipt.
766
+ const current = getBoxDeletion(boxId);
767
+ if (current?.phase === "accepted") {
768
+ return { ok: true, pending: true };
769
+ }
770
+ // Prepared (ambiguous request) and blocked records may be retried only by
771
+ // this explicit Settings/bot-deletion path after fresh identity checks.
772
+ }
526
773
  const instance = await revalidateManagedBox(cfg, owners, boxId);
527
774
  if (instance.inUse) {
528
775
  throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
@@ -532,17 +779,15 @@ export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim) {
532
779
  }
533
780
  const release = claim?.(instance);
534
781
  try {
535
- const removed = await boxJson(cfg, `/boxes/${instance.boxId}`, {
536
- method: "DELETE",
537
- headers: { "X-Ascii-Confirm-Delete": instance.boxId },
538
- });
539
- if (!removed.ok && !boxDeleteProvedAbsent(removed)) {
540
- throw Object.assign(new Error(boxErrorMessage(removed.status, "box delete", removed.body)), { status: removed.status });
782
+ const confirmation = await requestRecordedBoxDeletion(cfg, {
783
+ boxId: instance.boxId,
784
+ name: instance.name,
785
+ ownerBotId: instance.ownerBotId,
786
+ }, options.pollDelaysMs);
787
+ if (confirmation === "pending") {
788
+ forgetBoxId(instance.boxId);
789
+ return { ok: true, pending: true };
541
790
  }
542
- if (removed.ok)
543
- await confirmAcceptedBoxDeletion(cfg, instance.boxId, removed.body);
544
- retireDeletedBoxCreate(instance.boxId);
545
- forgetBoxId(instance.boxId);
546
791
  return { ok: true };
547
792
  }
548
793
  finally {
@@ -551,6 +796,7 @@ export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim) {
551
796
  }
552
797
  export async function findBox(cfg, botId) {
553
798
  cfg = snapshotBoxConfig(cfg);
799
+ assertBotBoxNotDeleting(botId);
554
800
  const cachedId = boxIdCache.get(botId);
555
801
  if (cachedId) {
556
802
  let direct = null;
@@ -780,6 +1026,30 @@ async function createBox(cfg, botId, env) {
780
1026
  const trialTtl = trialBoxTtlSeconds(first.body);
781
1027
  return trialTtl === null ? first : requestBoxCreate(cfg, botId, trialTtl, env);
782
1028
  }
1029
+ /** A prior explicit delete always wins over provisioning. Reconcile/retry the
1030
+ * old immutable target, then require a fresh provision request so one click
1031
+ * can never both erase and silently recreate the same computer. */
1032
+ async function finishPriorDeletionBeforeProvision(cfg, botId) {
1033
+ const remembered = boxDeletionSnapshot().filter((record) => record.ownerBotId === botId);
1034
+ if (!remembered.length)
1035
+ return;
1036
+ for (const deletion of remembered) {
1037
+ let state = await reconcileRecordedBoxDeletion(cfg, deletion, [0]);
1038
+ if (state !== "confirmed") {
1039
+ const current = getBoxDeletion(deletion.boxId);
1040
+ if (current?.phase === "prepared" || current?.phase === "blocked") {
1041
+ state = await requestRecordedBoxDeletion(cfg, {
1042
+ boxId: current.boxId,
1043
+ name: current.name,
1044
+ ownerBotId: current.ownerBotId,
1045
+ });
1046
+ }
1047
+ }
1048
+ if (state !== "confirmed")
1049
+ throw deletionFenceError();
1050
+ }
1051
+ throw Object.assign(new Error("the previous cloud computer deletion finished — retry to create a new computer"), { status: 409 });
1052
+ }
783
1053
  /** Box state for the Computer panel. */
784
1054
  export async function boxStatus(cfg, botId) {
785
1055
  cfg = snapshotBoxConfig(cfg);
@@ -801,12 +1071,16 @@ export async function provisionBox(cfg, botId, _botName) {
801
1071
  if (!boxConfigured(cfg)) {
802
1072
  throw new Error('box provider not enabled — add {"box":{"token":"…"}} to ~/.openmausbot/config.json');
803
1073
  }
1074
+ await finishPriorDeletionBeforeProvision(cfg, botId);
804
1075
  const vmName = await boxNameFor(botId);
805
1076
  let box = await findBox(cfg, botId);
806
1077
  let created = false;
807
1078
  let createRequest = null;
808
1079
  try {
809
1080
  if (!box) {
1081
+ // Deletion can be prepared by another process after the initial lookup.
1082
+ // Never create a replacement until the durable fence is reconciled.
1083
+ assertBotBoxNotDeleting(botId);
810
1084
  // Provider-side backstop: archives itself (billing pauses, disk
811
1085
  // survives) if every stop path dies. Trial accounts get one narrower
812
1086
  // retry when ascii.dev reports their shorter TTL ceiling.
@@ -839,37 +1113,33 @@ export async function provisionBox(cfg, botId, _botName) {
839
1113
  catch (error) {
840
1114
  if (!created || !box?.id)
841
1115
  throw error;
842
- const cleanup = await boxJson(cfg, `/boxes/${box.id}`, {
843
- method: "DELETE",
844
- headers: { "X-Ascii-Confirm-Delete": box.id },
845
- }).catch(() => null);
846
- if (cleanup && (cleanup.ok || boxDeleteProvedAbsent(cleanup))) {
847
- if (cleanup.ok) {
848
- try {
849
- await confirmAcceptedBoxDeletion(cfg, box.id, cleanup.body);
850
- }
851
- catch (cleanupError) {
852
- const message = error instanceof Error ? error.message : String(error);
853
- const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
854
- throw new Error(`${message}. The new computer's deletion was not confirmed: ${cleanupMessage}. Check box ${box.id} in ascii.dev.`);
855
- }
856
- }
1116
+ const originalMessage = error instanceof Error ? error.message : String(error);
1117
+ // Capture the provider's current name when possible. Naming may be the
1118
+ // step that failed, so the desired deterministic name is only a fallback
1119
+ // for the durable fence, never proof of a later live identity.
1120
+ const inspected = await inspectBoxIdentity(cfg, box.id);
1121
+ if (inspected.available && !inspected.identity) {
1122
+ retireDeletedBoxCreate(box.id);
857
1123
  boxIdCache.delete(botId);
858
- if (createRequest) {
859
- // A stale record is safe (the next retry verifies its Box ID), so a
860
- // cleanup-journal write failure must not hide the original error.
861
- try {
862
- discardBoxCreate(createRequest);
863
- }
864
- catch {
865
- /* verified absent before any future create */
866
- }
867
- }
868
1124
  throw error;
869
1125
  }
1126
+ let cleanupConfirmation;
1127
+ try {
1128
+ cleanupConfirmation = await requestRecordedBoxDeletion(cfg, {
1129
+ boxId: box.id,
1130
+ name: inspected.identity?.name ?? vmName,
1131
+ ownerBotId: botId,
1132
+ });
1133
+ }
1134
+ catch (cleanupError) {
1135
+ const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
1136
+ throw new Error(`${originalMessage}. The new computer's deletion was not confirmed: ${cleanupMessage}. Check box ${box.id} in ascii.dev.`);
1137
+ }
1138
+ if (cleanupConfirmation === "confirmed")
1139
+ throw error;
870
1140
  boxIdCache.delete(botId);
871
- const message = error instanceof Error ? error.message : String(error);
872
- throw new Error(`${message}. The new computer could not be removed automatically; delete box ${box.id} in ascii.dev.`);
1141
+ throw Object.assign(new Error(`${originalMessage}. ascii.dev accepted deletion of the new computer, but it is still pending; `
1142
+ + `its recovery record was kept, along with its deletion fence. Check box ${box.id} in ascii.dev.`, { cause: error }), { status: 503 });
873
1143
  }
874
1144
  }
875
1145
  /** Wake the bot's box and return a FRESH desktop URL. */
@@ -50,7 +50,7 @@ export async function runPhoneSetup(options, io, deps) {
50
50
  const device = await io.choose("Use OpenMausBot on your phone?", [
51
51
  "Skip for now",
52
52
  "iPhone / iPad — native app or Safari",
53
- "Android — web browser",
53
+ "Android — app or browser",
54
54
  ], 0);
55
55
  if (device === 0)
56
56
  return skip();
@@ -122,7 +122,7 @@ export function phonePairingInstructions(phone, input) {
122
122
  return [
123
123
  phone === "ios"
124
124
  ? "On iPhone or iPad, scan the QR with Camera to open Safari. If you already have the OpenMausBot iOS app, use its pairing scanner or paste the full pairing link there."
125
- : "On Android, scan the QR with Camera and open it in your web browser. This link is for the web app, not the Android native pairing scanner.",
125
+ : "On Android, open the OpenMausBot app and scan the QR with its pairing scanner. The QR is an app link, so Camera will not open it in a browser.",
126
126
  `Or open ${origin}/pair on your phone and enter the code.`,
127
127
  "Choose Connect on the phone. Scanning a QR does not mean the phone is paired.",
128
128
  "The code works once and expires after five minutes. This phone receives client access: chat and approvals, not settings or pairing administration.",