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.
- package/README.md +6 -5
- package/dist/assets/index-CKysBq-V.css +1 -0
- package/dist/assets/{index-C2Je5jdF.js → index-oLMljotU.js} +1 -1
- package/dist/assets/index-peLFiSH8.js +307 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +41 -10
- package/dist-server/drivers/agents-proxy.js +71 -17
- package/dist-server/index.js +4104 -1505
- package/dist-server/openmausbot.js +554 -163
- package/dist-server/pair-cli.js +554 -163
- package/dist-server/server/box-delete-journal.js +471 -0
- package/dist-server/server/box.js +345 -75
- package/dist-server/server/cli-phone-setup.js +2 -2
- package/dist-server/server/cli.js +84 -12
- package/dist-server/server/config.js +48 -18
- package/dist-server/server/container-computer.js +7 -0
- package/dist-server/server/contracts.js +5 -0
- package/dist-server/server/default-model-selection.js +1 -0
- package/dist-server/server/drivers/acp/core.js +112 -18
- package/dist-server/server/drivers/acp/opencode-go.js +6 -0
- package/dist-server/server/drivers/agents-proxy.js +76 -13
- package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
- package/dist-server/server/drivers/claude.js +87 -63
- package/dist-server/server/drivers/codex.js +248 -51
- package/dist-server/server/drivers/native.js +6 -1
- package/dist-server/server/harness/bus.js +4 -0
- package/dist-server/server/harness/registry.js +3 -2
- package/dist-server/server/index.js +1007 -222
- package/dist-server/server/local-vm-lease.js +4 -0
- package/dist-server/server/managed-desktop.js +205 -0
- package/dist-server/server/mcp-http.js +337 -0
- package/dist-server/server/mcp-probe.js +67 -18
- package/dist-server/server/mcp-registry.js +153 -58
- package/dist-server/server/member-turn.js +13 -0
- package/dist-server/server/message-db.js +74 -8
- package/dist-server/server/model-context-window.js +20 -0
- package/dist-server/server/recall-disclosure.js +4 -0
- package/dist-server/server/recent-work.js +142 -0
- package/dist-server/server/redact.js +1 -0
- package/dist-server/server/request-auth.js +3 -1
- package/dist-server/server/room-handoffs.js +16 -6
- package/dist-server/server/routines.js +17 -0
- package/dist-server/server/screen-frame-gate.js +4 -94
- package/dist-server/server/sessions.js +22 -3
- package/dist-server/server/store.js +29 -2
- package/dist-server/server/surface.js +41 -19
- package/dist-server/server/team-setup-requests.js +1 -1
- package/dist-server/server/thread-log-rotation.js +104 -0
- package/dist-server/server/tts/fish.js +155 -0
- package/dist-server/server/tts/index.js +25 -3
- package/dist-server/server/vps-computer.js +26 -4
- package/dist-server/server/vps-ssh.js +88 -0
- package/dist-server/server/workspace-backup.js +11 -0
- package/dist-server/server/workspace.js +2 -1
- package/dist-server/shared/credential-request.js +10 -0
- package/dist-server/shared/tool-surface.js +62 -0
- package/dist-server/vps-container-mcp.js +41 -10
- package/package.json +1 -1
- package/dist/assets/index-D_TFZLlo.js +0 -305
- 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
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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/${
|
|
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
|
|
165
|
-
if (!
|
|
173
|
+
const operation = boxDeletionOperation(polled.body, record.boxId, record.operationId);
|
|
174
|
+
if (!operation)
|
|
166
175
|
break;
|
|
167
|
-
operation
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
179
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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 —
|
|
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,
|
|
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.",
|