openmausbot 0.1.89 → 0.1.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-yP-w8pRq.js} +1 -1
- package/dist/assets/{arc-Be-8GKxd.js → arc-DsivfZEh.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-CILw7lzT.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DEQUV3HF.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-lSXCZoKA.js} +1 -1
- package/dist/assets/channel-DrfGMu7X.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-QCD5bgWP.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-C5-iZJuL.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-cxsawMpY.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DV8tV8H7.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-CJFZyfbW.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BigbMXco.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-oVxTfpDy.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-CH4BiroW.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-KEDa6gax.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-BiPjfqVv.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-Caxew3OY.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-B_Pui369.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-X64ydIhP.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-TqYCI1E1.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-TeOt3rAQ.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-D70BLhDA.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BbQDLwUC.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-ChNJZ1u-.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-DMBl2APa.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-CEqDchOp.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-BVDKRp_-.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-CRkofO9B.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-DruOMh5e.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-8GMQeB7t.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-ORE9jcsM.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-BmsedsWh.js} +1 -1
- package/dist/assets/index-BlnCEcU6.js +588 -0
- package/dist/assets/{index-Oxk9x45i.js → index-DKNEM8Q6.js} +1 -1
- package/dist/assets/index-DVxjOvID.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-E_p7AooJ.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-Y-B2mKda.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-eA8RELcK.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DqIL2e8e.js} +1 -1
- package/dist/assets/{linear-CtJnKe6A.js → linear-yC_29R6e.js} +1 -1
- package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-VhEMkSND.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-oJ7kyQZy.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-BmfYcY0O.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-DKvgP7w9.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-BEKL3W0L.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-BEsiqR5s.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-DWBcWvAP.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-rpZ2_O4s.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-B99HQdJR.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-BjN0qT5T.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-BbsVgGsC.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DG3gKkeR.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-BTHOdtF_.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-acLSLFwc.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-C5JzNX9G.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-DL1-PrR_.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-Bvr1477p.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +14810 -0
- package/dist-server/container-mcp.js +136 -36
- package/dist-server/drivers/agents-proxy.js +31 -10
- package/dist-server/index.js +48251 -44124
- package/dist-server/openmausbot.js +628 -292
- package/dist-server/pair-cli.js +628 -292
- package/dist-server/permission-proxy.js +1 -1
- package/dist-server/server/admission.js +46 -0
- package/dist-server/server/{box-create-idempotency.js → boat-create-idempotency.js} +32 -31
- package/dist-server/server/{box-delete-journal.js → boat-delete-journal.js} +26 -25
- package/dist-server/server/{box.js → boat.js} +381 -334
- package/dist-server/server/bot-overview.js +7 -4
- package/dist-server/server/bot-profile.js +7 -1
- package/dist-server/server/channel-queue.js +58 -18
- package/dist-server/server/checkpoints.js +98 -52
- package/dist-server/server/claim-idle.js +33 -0
- package/dist-server/server/cloud-backend.js +5 -5
- package/dist-server/server/cloud-home-start.js +121 -0
- package/dist-server/server/cloud-home.js +241 -0
- package/dist-server/server/cloud-lending.js +103 -0
- package/dist-server/server/cloud-move-http.js +268 -0
- package/dist-server/server/cloud-move.js +418 -0
- package/dist-server/server/cloud-overflow.js +167 -0
- package/dist-server/server/composio.js +5 -0
- package/dist-server/server/computer-observation.js +2 -2
- package/dist-server/server/config.js +156 -9
- package/dist-server/server/container-computer.js +13 -0
- package/dist-server/server/control-client.js +2 -2
- package/dist-server/server/decider/index.js +238 -0
- package/dist-server/server/decider/jev.js +183 -0
- package/dist-server/server/decider/log.js +62 -0
- package/dist-server/server/decider/relay.js +52 -0
- package/dist-server/server/decider/room-routing.js +88 -0
- package/dist-server/server/decider/types.js +5 -0
- package/dist-server/server/delegations.js +11 -3
- package/dist-server/server/drivers/acp/core.js +5 -4
- package/dist-server/server/drivers/agents-call.js +5 -1
- package/dist-server/server/drivers/agents-catalog.js +25 -3
- package/dist-server/server/drivers/{boxagent.js → boatagent.js} +30 -21
- package/dist-server/server/drivers/builtIn.js +2 -2
- package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
- package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
- package/dist-server/server/drivers/claude.js +178 -13
- package/dist-server/server/drivers/native.js +1 -1
- package/dist-server/server/drivers/openai-chat.js +15 -4
- package/dist-server/server/drivers/pi.js +3 -3
- package/dist-server/server/group-goal-run.js +6 -4
- package/dist-server/server/harness-capabilities.js +2 -0
- package/dist-server/server/hosted-models.js +6 -1
- package/dist-server/server/included-services.js +70 -0
- package/dist-server/server/index.js +1462 -576
- package/dist-server/server/local-vm-inventory.js +11 -1
- package/dist-server/server/local-vm-seat-pool.js +141 -0
- package/dist-server/server/managed-policy.js +4 -1
- package/dist-server/server/memory-capture.js +194 -0
- package/dist-server/server/memory-entries.js +76 -0
- package/dist-server/server/memory-organize.js +102 -0
- package/dist-server/server/memory-tidy.js +144 -0
- package/dist-server/server/memory-topics.js +169 -0
- package/dist-server/server/memory-upkeep.js +452 -0
- package/dist-server/server/message-db.js +49 -5
- package/dist-server/server/model-context-window.js +4 -4
- package/dist-server/server/package-export.js +17 -10
- package/dist-server/server/package-import.js +6 -3
- package/dist-server/server/package-parts.js +3 -1
- package/dist-server/server/permission-proxy.js +3 -3
- package/dist-server/server/profile-learned.js +98 -0
- package/dist-server/server/profile-versions.js +45 -11
- package/dist-server/server/recall.js +172 -0
- package/dist-server/server/redact.js +1 -1
- package/dist-server/server/request-auth.js +2 -0
- package/dist-server/server/room-cwd.js +1 -1
- package/dist-server/server/room-handoffs.js +61 -6
- package/dist-server/server/routes/bot-memory.js +215 -0
- package/dist-server/server/routes/decider.js +19 -0
- package/dist-server/server/routine-requests.js +27 -16
- package/dist-server/server/routines.js +10 -6
- package/dist-server/server/shared-computers.js +116 -30
- package/dist-server/server/steer-queue.js +41 -10
- package/dist-server/server/store.js +42 -3
- package/dist-server/server/surface.js +14 -1
- package/dist-server/server/system-prompt.js +16 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +2 -2
- package/dist-server/server/team-manifest.js +11 -3
- package/dist-server/server/tightening-requests.js +34 -4
- package/dist-server/server/tool-summary.js +2 -2
- package/dist-server/server/tts/elevenlabs.js +12 -7
- package/dist-server/server/tts/index.js +17 -10
- package/dist-server/server/turn-resources.js +87 -11
- package/dist-server/server/vps-computer.js +1 -1
- package/dist-server/server/workspace-backup-policy.js +22 -3
- package/dist-server/server/workspace-backup.js +25 -8
- package/dist-server/server/workspace.js +57 -22
- package/dist-server/shared/ask-question.js +7 -5
- package/dist-server/shared/bot-avatar.js +19 -0
- package/dist-server/shared/cloud-home.js +4 -0
- package/dist-server/shared/credential-request.js +3 -3
- package/dist-server/shared/package-format.js +30 -4
- package/dist-server/shared/team-backup.js +20 -3
- package/dist-server/shared/tool-surface.js +1 -1
- package/dist-server/src/lib/composer-attachments.js +9 -0
- package/dist-server/vps-container-mcp.js +136 -36
- package/dist-server/workspace-backup.worker.js +165 -35
- package/package.json +1 -1
- package/dist/assets/channel-DqY_gMLg.js +0 -1
- package/dist/assets/index-BIMdIbaw.css +0 -1
- package/dist/assets/index-BVPdbq2r.js +0 -583
- package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +0 -8
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Boat (boat.dev) provider — the bot's cloud computer. Ported from
|
|
2
2
|
// agentcal-api src/providers/box.js, reshaped per-bot instead of
|
|
3
|
-
// per-customer: every bot gets one persistent
|
|
3
|
+
// per-customer: every bot gets one persistent boat (deterministic name),
|
|
4
4
|
// stop pauses billing while the disk survives, and Join always mints a
|
|
5
5
|
// FRESH desktop URL (stream tokens rotate on every state change — never
|
|
6
6
|
// persist one).
|
|
7
7
|
//
|
|
8
|
-
// Substrate facts (probed by agentcal 2026-07-24 on a live
|
|
8
|
+
// Substrate facts (probed by agentcal 2026-07-24 on a live boat):
|
|
9
9
|
// - REST only: POST /boxes/{id}/commands runs shell synchronously.
|
|
10
10
|
// - stop→archived ~5s, resume→idle ~8s; disk persists, tmux does not.
|
|
11
11
|
// - X11 desktop with Chrome + Ghostty; passwordless sudo; node 24.
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { DATA_DIR } from "./config.js";
|
|
15
15
|
import { loadEnvironmentId } from "./environment.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { boatCredential, boatProviderApi } from "./included-services.js";
|
|
17
|
+
import { adoptResolvedBoat, beginBoatCreate, boatCreateRecoverySnapshot, discardBoatCreate, rememberCreatedBoat, resolveBoatCreate, retireDeletedBoatCreate, } from "./boat-create-idempotency.js";
|
|
18
|
+
import { boatDeletionSnapshot, getBoatDeletion, hasPendingBoatDeletionForBot, markBoatDeletionAccepted, markBoatDeletionBlocked, prepareBoatDeletion, retireBoatDeletion, } from "./boat-delete-journal.js";
|
|
18
19
|
const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
19
20
|
export const MAX_REMOTE_COMMAND_LENGTH = 4_000;
|
|
20
21
|
/** Run an owner-supplied console command without inheriting provider or
|
|
21
|
-
* account credentials from the
|
|
22
|
+
* account credentials from the boat's environment. */
|
|
22
23
|
export function isolatedRemoteCommand(command) {
|
|
23
24
|
return [
|
|
24
25
|
"exec env -i",
|
|
@@ -34,24 +35,24 @@ export function isolatedRemoteCommand(command) {
|
|
|
34
35
|
shellQuote(command),
|
|
35
36
|
].join(" ");
|
|
36
37
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
38
|
+
// Boat's provider surface keeps its historical Box-era names: env OMB_BOX_API,
|
|
39
|
+
// base path /api/box/v1, REST paths /boxes/*, and the box_ token prefix.
|
|
39
40
|
const READY = new Set(["idle", "ready", "running"]);
|
|
40
41
|
const SLEEPING = new Set(["archived", "archiving", "stopped", "stopping"]);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
// Current self-serve accounts top out below 2,000
|
|
42
|
+
const DEFAULT_BOAT_TTL_SECONDS = 8 * 60 * 60;
|
|
43
|
+
const TRIAL_BOAT_TTL_SECONDS = 2 * 60 * 60;
|
|
44
|
+
const BOAT_CREATE_IN_PROGRESS_RETRY_DELAYS_MS = [250, 750, 1_500];
|
|
45
|
+
const BOAT_DELETE_OPERATION_POLL_DELAYS_MS = [0, 100, 250, 500, 1_000, 2_000];
|
|
46
|
+
const BOAT_INVENTORY_PAGE_SIZE = 200;
|
|
47
|
+
// Current self-serve accounts top out below 2,000 boats. Keep the walk
|
|
47
48
|
// bounded anyway: a broken or adversarial cursor must not hold Settings open.
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
49
|
+
const MAX_BOAT_INVENTORY_PAGES = 10;
|
|
50
|
+
const LEGACY_MANAGED_BOAT_NAME = /^ogb-[a-z0-9]{1,8}-[a-f0-9]{6}$/;
|
|
51
|
+
const SCOPED_MANAGED_BOAT_NAME = /^ogb-[a-f0-9]{12}-[a-z0-9]{1,8}-[a-f0-9]{6}$/;
|
|
52
|
+
const BOAT_ID = /^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/;
|
|
53
|
+
const BOAT_DELETE_OPERATION_ID = /^bdop_[a-f0-9]{32}$/;
|
|
54
|
+
const BOAT_DELETE_OPERATION_STATES = new Set(["pending", "processing", "blocked", "completed"]);
|
|
55
|
+
const BOAT_STATES = new Set([
|
|
55
56
|
"init",
|
|
56
57
|
"idle",
|
|
57
58
|
"ready",
|
|
@@ -68,26 +69,26 @@ const BOX_STATES = new Set([
|
|
|
68
69
|
"error",
|
|
69
70
|
]);
|
|
70
71
|
// Provider listings are account-wide. Hash the durable local environment id
|
|
71
|
-
// into every new name so another OpenMausBot installation using the same
|
|
72
|
+
// into every new name so another OpenMausBot installation using the same Boat
|
|
72
73
|
// account cannot mistake this installation's computers for abandoned ones.
|
|
73
74
|
// The environment UUID itself never leaves the local data directory.
|
|
74
|
-
let
|
|
75
|
+
let scopedBoatPrefixCache = null;
|
|
75
76
|
/** Resolve only after server startup has migrated the legacy data directory
|
|
76
77
|
* and acquired its writer lease. A static-import side effect here used to
|
|
77
78
|
* create the new directory too early and suppress that migration. */
|
|
78
|
-
function
|
|
79
|
-
if (
|
|
80
|
-
return
|
|
79
|
+
function scopedBoatPrefix() {
|
|
80
|
+
if (scopedBoatPrefixCache)
|
|
81
|
+
return scopedBoatPrefixCache;
|
|
81
82
|
const scope = createHash("sha256")
|
|
82
83
|
.update(loadEnvironmentId(DATA_DIR))
|
|
83
84
|
.digest("hex")
|
|
84
85
|
.slice(0, 12);
|
|
85
|
-
|
|
86
|
-
return
|
|
86
|
+
scopedBoatPrefixCache = `ogb-${scope}-`;
|
|
87
|
+
return scopedBoatPrefixCache;
|
|
87
88
|
}
|
|
88
|
-
/** Decide lifecycle work before a turn mounts
|
|
89
|
-
* attach an already-ready
|
|
90
|
-
export function
|
|
89
|
+
/** Decide lifecycle work before a turn mounts Boat. Auto may observe and
|
|
90
|
+
* attach an already-ready Boat, but only explicit Cloud may create or wake. */
|
|
91
|
+
export function boatTurnLifecycleAction({ explicitCloud, canMount, state, }) {
|
|
91
92
|
if (!canMount)
|
|
92
93
|
return "none";
|
|
93
94
|
if (state && READY.has(state))
|
|
@@ -99,60 +100,64 @@ export function boxTurnLifecycleAction({ explicitCloud, canMount, state, }) {
|
|
|
99
100
|
/** Keep one provider account for the whole logical operation. Settings may
|
|
100
101
|
* replace the shared config object after an async request has started; every
|
|
101
102
|
* follow-up (rename, readiness, cleanup, etc.) must keep using the credential
|
|
102
|
-
* that selected or created the
|
|
103
|
-
function
|
|
103
|
+
* that selected or created the Boat in the first place. */
|
|
104
|
+
function snapshotBoatConfig(cfg) {
|
|
104
105
|
return { box: cfg.box ? { token: cfg.box.token } : undefined };
|
|
105
106
|
}
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
/** The base URL follows the credential in use (included-services.ts): an own
|
|
108
|
+
* token goes to Boat (OMB_BOX_API points it at a stub in tests), Cloud Pro's
|
|
109
|
+
* included token only to its relay. */
|
|
110
|
+
function boatFetch(cfg, path, opts = {}) {
|
|
111
|
+
const account = boatAccount(cfg);
|
|
112
|
+
return fetch(`${account?.api ?? boatProviderApi()}${path}`, {
|
|
108
113
|
...opts,
|
|
109
114
|
headers: {
|
|
110
|
-
authorization: `Bearer ${
|
|
115
|
+
authorization: `Bearer ${account?.token}`,
|
|
111
116
|
"content-type": "application/json",
|
|
112
117
|
...opts.headers,
|
|
113
118
|
},
|
|
114
119
|
});
|
|
115
120
|
}
|
|
116
|
-
async function
|
|
117
|
-
const res = await
|
|
121
|
+
async function boatJson(cfg, path, opts = {}) {
|
|
122
|
+
const res = await boatFetch(cfg, path, opts);
|
|
118
123
|
const body = await res.json().catch(() => null);
|
|
119
124
|
return { ok: res.ok && body?.ok !== false, status: res.status, body };
|
|
120
125
|
}
|
|
121
126
|
/** Accept only the immutable identity fields needed to follow a delete. Any
|
|
122
|
-
* malformed success envelope falls back to a direct
|
|
127
|
+
* malformed success envelope falls back to a direct Boat read instead of
|
|
123
128
|
* authorizing journal retirement. */
|
|
124
|
-
function
|
|
129
|
+
function boatDeletionOperation(body, boxId, expectedOperationId) {
|
|
125
130
|
const operation = body?.operation;
|
|
126
131
|
const id = typeof operation?.id === "string" ? operation.id : "";
|
|
127
132
|
const status = typeof operation?.status === "string" ? operation.status : "";
|
|
128
|
-
if (!
|
|
133
|
+
if (!BOAT_DELETE_OPERATION_ID.test(id)
|
|
129
134
|
|| (expectedOperationId !== undefined && id !== expectedOperationId)
|
|
130
135
|
|| operation?.kind !== "box"
|
|
131
136
|
|| operation?.targetId !== boxId
|
|
132
|
-
|| !
|
|
137
|
+
|| !BOAT_DELETE_OPERATION_STATES.has(status))
|
|
133
138
|
return null;
|
|
134
139
|
return { id, kind: "box", targetId: boxId, status: status };
|
|
135
140
|
}
|
|
136
141
|
function deletionBlockedError(boxId) {
|
|
137
142
|
return Object.assign(new Error(`boat.dev accepted deletion of ${boxId}, but the deletion operation is blocked — check boat.dev and retry`), { status: 409 });
|
|
138
143
|
}
|
|
139
|
-
function
|
|
144
|
+
function boatDeleteProvedAbsent(result) {
|
|
140
145
|
return result.status === 404 || result.status === 410;
|
|
141
146
|
}
|
|
142
147
|
/** Retire the create receipt before the deletion fence. If that first durable
|
|
143
|
-
* write fails, the fence remains and no caller can reuse a
|
|
148
|
+
* write fails, the fence remains and no caller can reuse a Boat whose ownership
|
|
144
149
|
* recovery is uncertain. */
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
function finishRecordedBoatDeletion(boxId) {
|
|
151
|
+
retireDeletedBoatCreate(boxId);
|
|
152
|
+
forgetBoatId(boxId);
|
|
153
|
+
retireBoatDeletion(boxId);
|
|
149
154
|
}
|
|
150
155
|
/** Reconcile one durable deletion against the exact provider operation/Box.
|
|
151
156
|
* Account LIST omission is never evidence: it is eventually consistent. */
|
|
152
|
-
async function
|
|
157
|
+
async function reconcileRecordedBoatDeletion(cfg, initial, pollDelaysMs = []) {
|
|
153
158
|
let record = initial;
|
|
154
159
|
if (record.phase === "accepted" && record.status === "completed") {
|
|
155
|
-
|
|
160
|
+
finishRecordedBoatDeletion(record.boxId);
|
|
156
161
|
return "confirmed";
|
|
157
162
|
}
|
|
158
163
|
if (record.phase === "accepted" && record.operationId) {
|
|
@@ -161,7 +166,7 @@ async function reconcileRecordedBoxDeletion(cfg, initial, pollDelaysMs = []) {
|
|
|
161
166
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
162
167
|
let polled;
|
|
163
168
|
try {
|
|
164
|
-
polled = await
|
|
169
|
+
polled = await boatJson(cfg, `/deletion-operations/${record.operationId}`, {
|
|
165
170
|
signal: AbortSignal.timeout(20_000),
|
|
166
171
|
});
|
|
167
172
|
}
|
|
@@ -170,27 +175,27 @@ async function reconcileRecordedBoxDeletion(cfg, initial, pollDelaysMs = []) {
|
|
|
170
175
|
}
|
|
171
176
|
if (!polled.ok)
|
|
172
177
|
break;
|
|
173
|
-
const operation =
|
|
178
|
+
const operation = boatDeletionOperation(polled.body, record.boxId, record.operationId);
|
|
174
179
|
if (!operation)
|
|
175
180
|
break;
|
|
176
181
|
if (operation.status === "blocked") {
|
|
177
|
-
record =
|
|
182
|
+
record = markBoatDeletionBlocked(record.boxId, operation);
|
|
178
183
|
break;
|
|
179
184
|
}
|
|
180
|
-
record =
|
|
185
|
+
record = markBoatDeletionAccepted(record.boxId, operation);
|
|
181
186
|
if (operation.status === "completed") {
|
|
182
|
-
|
|
187
|
+
finishRecordedBoatDeletion(record.boxId);
|
|
183
188
|
return "confirmed";
|
|
184
189
|
}
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
192
|
// A direct immutable-id 404/410 is the only alternate completion proof.
|
|
188
193
|
// A live identity keeps the fence even when the operation endpoint is down.
|
|
189
|
-
const inspected = await
|
|
194
|
+
const inspected = await inspectBoatIdentity(cfg, record.boxId);
|
|
190
195
|
if (!inspected.available)
|
|
191
196
|
return record.phase === "blocked" ? "blocked" : "pending";
|
|
192
197
|
if (!inspected.identity) {
|
|
193
|
-
|
|
198
|
+
finishRecordedBoatDeletion(record.boxId);
|
|
194
199
|
return "confirmed";
|
|
195
200
|
}
|
|
196
201
|
if (inspected.identity.name !== record.name) {
|
|
@@ -201,30 +206,30 @@ async function reconcileRecordedBoxDeletion(cfg, initial, pollDelaysMs = []) {
|
|
|
201
206
|
/** Prove that a replacement token can see every durable deletion target
|
|
202
207
|
* before Settings swaps credentials. Unlike normal reconciliation, a bare
|
|
203
208
|
* 404 is not completion proof here: it may simply be a different account. */
|
|
204
|
-
export async function
|
|
205
|
-
cfg =
|
|
206
|
-
for (const initial of
|
|
209
|
+
export async function verifyBoatDeletionCredential(cfg) {
|
|
210
|
+
cfg = snapshotBoatConfig(cfg);
|
|
211
|
+
for (const initial of boatDeletionSnapshot()) {
|
|
207
212
|
let record = initial;
|
|
208
213
|
let operationAuthorized = false;
|
|
209
214
|
if (record.phase === "accepted" && record.operationId) {
|
|
210
215
|
let polled = null;
|
|
211
216
|
try {
|
|
212
|
-
polled = await
|
|
217
|
+
polled = await boatJson(cfg, `/deletion-operations/${record.operationId}`, {
|
|
213
218
|
signal: AbortSignal.timeout(20_000),
|
|
214
219
|
});
|
|
215
220
|
}
|
|
216
221
|
catch {
|
|
217
|
-
// The exact
|
|
222
|
+
// The exact Boat identity below can still prove account continuity.
|
|
218
223
|
}
|
|
219
224
|
if (polled?.ok) {
|
|
220
|
-
const operation =
|
|
225
|
+
const operation = boatDeletionOperation(polled.body, record.boxId, record.operationId);
|
|
221
226
|
if (operation) {
|
|
222
227
|
operationAuthorized = true;
|
|
223
228
|
record = operation.status === "blocked"
|
|
224
|
-
?
|
|
225
|
-
:
|
|
229
|
+
? markBoatDeletionBlocked(record.boxId, operation)
|
|
230
|
+
: markBoatDeletionAccepted(record.boxId, operation);
|
|
226
231
|
if (operation.status === "completed") {
|
|
227
|
-
|
|
232
|
+
finishRecordedBoatDeletion(record.boxId);
|
|
228
233
|
continue;
|
|
229
234
|
}
|
|
230
235
|
}
|
|
@@ -232,39 +237,39 @@ export async function verifyBoxDeletionCredential(cfg) {
|
|
|
232
237
|
}
|
|
233
238
|
if (operationAuthorized)
|
|
234
239
|
continue;
|
|
235
|
-
const inspected = await
|
|
240
|
+
const inspected = await inspectBoatIdentity(cfg, record.boxId);
|
|
236
241
|
if (inspected.available && inspected.identity?.name === record.name)
|
|
237
242
|
continue;
|
|
238
243
|
if (!inspected.available) {
|
|
239
|
-
throw Object.assign(new Error(`${inspected.problem ?? "a deleting cloud computer could not be verified"}. Retry with the
|
|
244
|
+
throw Object.assign(new Error(`${inspected.problem ?? "a deleting cloud computer could not be verified"}. Retry with the Boat account that owns it`), { status: 503 });
|
|
240
245
|
}
|
|
241
|
-
throw Object.assign(new Error("that
|
|
246
|
+
throw Object.assign(new Error("that Boat token cannot access the cloud computers whose deletion is still being reconciled"), { status: 409 });
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
/** Bind a successful DELETE response to the durable target before polling.
|
|
245
250
|
* A malformed receipt leaves the prepared fence intact. */
|
|
246
|
-
async function
|
|
247
|
-
const operation =
|
|
251
|
+
async function confirmAcceptedBoatDeletion(cfg, record, acceptedBody, pollDelaysMs = BOAT_DELETE_OPERATION_POLL_DELAYS_MS) {
|
|
252
|
+
const operation = boatDeletionOperation(acceptedBody, record.boxId);
|
|
248
253
|
if (!operation) {
|
|
249
|
-
const inspected = await
|
|
254
|
+
const inspected = await inspectBoatIdentity(cfg, record.boxId);
|
|
250
255
|
if (inspected.available && !inspected.identity) {
|
|
251
|
-
|
|
256
|
+
finishRecordedBoatDeletion(record.boxId);
|
|
252
257
|
return "confirmed";
|
|
253
258
|
}
|
|
254
259
|
throw Object.assign(new Error(`boat.dev returned an invalid deletion receipt for ${record.boxId}; its deletion fence was kept`), { status: 503 });
|
|
255
260
|
}
|
|
256
261
|
const next = operation.status === "blocked"
|
|
257
|
-
?
|
|
258
|
-
:
|
|
259
|
-
return
|
|
262
|
+
? markBoatDeletionBlocked(record.boxId, operation)
|
|
263
|
+
: markBoatDeletionAccepted(record.boxId, operation);
|
|
264
|
+
return reconcileRecordedBoatDeletion(cfg, next, pollDelaysMs);
|
|
260
265
|
}
|
|
261
266
|
/** Send (or explicitly retry) DELETE only after the immutable target is on
|
|
262
267
|
* disk. The returned pending state always has a validated operation receipt. */
|
|
263
|
-
async function
|
|
264
|
-
const deletion =
|
|
268
|
+
async function requestRecordedBoatDeletion(cfg, identity, pollDelaysMs = BOAT_DELETE_OPERATION_POLL_DELAYS_MS) {
|
|
269
|
+
const deletion = prepareBoatDeletion(identity);
|
|
265
270
|
let removed;
|
|
266
271
|
try {
|
|
267
|
-
removed = await
|
|
272
|
+
removed = await boatJson(cfg, `/boxes/${identity.boxId}`, {
|
|
268
273
|
method: "DELETE",
|
|
269
274
|
headers: { "X-Ascii-Confirm-Delete": identity.boxId },
|
|
270
275
|
});
|
|
@@ -272,42 +277,42 @@ async function requestRecordedBoxDeletion(cfg, identity, pollDelaysMs = BOX_DELE
|
|
|
272
277
|
catch (error) {
|
|
273
278
|
throw Object.assign(new Error("Could not confirm whether boat.dev accepted the delete. The computer was kept fenced; retry Delete to reconcile it"), { status: 503, cause: error });
|
|
274
279
|
}
|
|
275
|
-
if (
|
|
276
|
-
|
|
280
|
+
if (boatDeleteProvedAbsent(removed)) {
|
|
281
|
+
finishRecordedBoatDeletion(identity.boxId);
|
|
277
282
|
return "confirmed";
|
|
278
283
|
}
|
|
279
284
|
if (!removed.ok) {
|
|
280
|
-
|
|
281
|
-
throw Object.assign(new Error(
|
|
285
|
+
markBoatDeletionBlocked(identity.boxId);
|
|
286
|
+
throw Object.assign(new Error(boatErrorMessage(removed.status, "boat delete", removed.body, usesIncludedBoat(cfg))), { status: removed.status });
|
|
282
287
|
}
|
|
283
|
-
const confirmation = await
|
|
288
|
+
const confirmation = await confirmAcceptedBoatDeletion(cfg, deletion, removed.body, pollDelaysMs);
|
|
284
289
|
if (confirmation === "blocked")
|
|
285
290
|
throw deletionBlockedError(identity.boxId);
|
|
286
291
|
return confirmation;
|
|
287
292
|
}
|
|
288
|
-
function
|
|
293
|
+
function boatBotNameParts(botId) {
|
|
289
294
|
const prefix = botId.slice(0, 8).toLowerCase().replace(/[^a-z0-9]/g, "") || "bot";
|
|
290
295
|
const hash = createHash("sha256").update(botId).digest("hex").slice(0, 6);
|
|
291
296
|
return { prefix, hash };
|
|
292
297
|
}
|
|
293
|
-
function
|
|
294
|
-
const { prefix, hash } =
|
|
298
|
+
function legacyBoatNameFor(botId) {
|
|
299
|
+
const { prefix, hash } = boatBotNameParts(botId);
|
|
295
300
|
return `ogb-${prefix}-${hash}`;
|
|
296
301
|
}
|
|
297
302
|
// Deterministic per installation and bot. The bot hash kills truncated-id
|
|
298
303
|
// collisions; the environment scope prevents cross-install ownership claims.
|
|
299
|
-
export async function
|
|
300
|
-
const { prefix, hash } =
|
|
301
|
-
return `${
|
|
304
|
+
export async function boatNameFor(botId) {
|
|
305
|
+
const { prefix, hash } = boatBotNameParts(botId);
|
|
306
|
+
return `${scopedBoatPrefix()}${prefix}-${hash}`;
|
|
302
307
|
}
|
|
303
308
|
/** Credential restoration must accept both current installation-scoped names
|
|
304
309
|
* and durable pre-scope names that the ownership journal may have adopted. */
|
|
305
|
-
export async function
|
|
306
|
-
return name === await
|
|
310
|
+
export async function boatNameMatchesBot(botId, name) {
|
|
311
|
+
return name === await boatNameFor(botId) || name === legacyBoatNameFor(botId);
|
|
307
312
|
}
|
|
308
313
|
export async function runCommand(cfg, boxId, command, { timeoutMs = 120_000, signal } = {}) {
|
|
309
|
-
|
|
310
|
-
const res = await
|
|
314
|
+
assertBoatNotDeleting(boxId);
|
|
315
|
+
const res = await boatFetch(cfg, `/boxes/${boxId}/commands`, {
|
|
311
316
|
method: "POST",
|
|
312
317
|
body: JSON.stringify({ command }),
|
|
313
318
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
@@ -324,13 +329,13 @@ export async function runCommand(cfg, boxId, command, { timeoutMs = 120_000, sig
|
|
|
324
329
|
// 1) VNC (POST /desktop?vnc=1) — plain WebSocket, survives P2P-blocking
|
|
325
330
|
// networks; answers {provisioning:true} first, so poll for the URL.
|
|
326
331
|
// 2) WebRTC stream (POST /desktop) as fallback — STUN-only, can hang.
|
|
327
|
-
// The desktopUrl stored on the
|
|
332
|
+
// The desktopUrl stored on the boat object is NOT usable on its own.
|
|
328
333
|
async function mintDesktopUrl(cfg, boxId, { vncBudgetMs = 60_000 } = {}) {
|
|
329
|
-
|
|
334
|
+
assertBoatNotDeleting(boxId);
|
|
330
335
|
const t0 = Date.now();
|
|
331
336
|
while (Date.now() - t0 < vncBudgetMs) {
|
|
332
|
-
|
|
333
|
-
const { body } = await
|
|
337
|
+
assertBoatNotDeleting(boxId);
|
|
338
|
+
const { body } = await boatJson(cfg, `/boxes/${boxId}/desktop?vnc=1`, { method: "POST" });
|
|
334
339
|
const url = body?.desktopUrl ?? body?.url;
|
|
335
340
|
if (url)
|
|
336
341
|
return url;
|
|
@@ -338,68 +343,88 @@ async function mintDesktopUrl(cfg, boxId, { vncBudgetMs = 60_000 } = {}) {
|
|
|
338
343
|
break;
|
|
339
344
|
await new Promise((r) => setTimeout(r, 3000));
|
|
340
345
|
}
|
|
341
|
-
const { body } = await
|
|
346
|
+
const { body } = await boatJson(cfg, `/boxes/${boxId}/desktop`, { method: "POST" });
|
|
342
347
|
return body?.desktopUrl ?? body?.url ?? null;
|
|
343
348
|
}
|
|
344
349
|
async function waitReady(cfg, boxId, budgetMs = 90_000) {
|
|
345
|
-
|
|
350
|
+
assertBoatNotDeleting(boxId);
|
|
346
351
|
const t0 = Date.now();
|
|
352
|
+
// Boat's words for the last failed resume, if the wait runs out on them.
|
|
353
|
+
let resumeFailure = null;
|
|
347
354
|
while (Date.now() - t0 < budgetMs) {
|
|
348
|
-
|
|
349
|
-
const { body } = await
|
|
355
|
+
assertBoatNotDeleting(boxId);
|
|
356
|
+
const { body } = await boatJson(cfg, `/boxes/${boxId}`);
|
|
350
357
|
const state = body?.box?.state;
|
|
351
358
|
if (READY.has(state))
|
|
352
359
|
return body.box;
|
|
353
360
|
if (state === "error")
|
|
354
361
|
return null;
|
|
355
|
-
// an archiving
|
|
356
|
-
|
|
357
|
-
|
|
362
|
+
// an archiving boat can't resume until the snapshot lands — nudge after.
|
|
363
|
+
// A refusal (a plan limit, say) is final: report it now. A server error
|
|
364
|
+
// is retried on the next poll, as Boat asks; 409 is a state race with a
|
|
365
|
+
// wake already under way.
|
|
366
|
+
if (state === "archived") {
|
|
367
|
+
const resumed = await boatJson(cfg, `/boxes/${boxId}/resume`, { method: "POST" });
|
|
368
|
+
if (resumed.ok)
|
|
369
|
+
resumeFailure = null;
|
|
370
|
+
else if (resumed.status !== 409) {
|
|
371
|
+
const message = boatErrorMessage(resumed.status, "waking the cloud computer", resumed.body, usesIncludedBoat(cfg));
|
|
372
|
+
if (resumed.status < 500)
|
|
373
|
+
throw new Error(message);
|
|
374
|
+
resumeFailure = message;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
358
377
|
await new Promise((r) => setTimeout(r, 2500));
|
|
359
378
|
}
|
|
379
|
+
if (resumeFailure)
|
|
380
|
+
throw new Error(resumeFailure);
|
|
360
381
|
return null;
|
|
361
382
|
}
|
|
362
|
-
// Resolving a bot's
|
|
383
|
+
// Resolving a bot's boat means LISTing every boat in the account, so it is
|
|
363
384
|
// the most expensive thing on any hot path. The name is deterministic, so
|
|
364
385
|
// once we know the id we can go straight at it — the cache is refreshed
|
|
365
|
-
// whenever the direct read fails (deleted/renamed
|
|
386
|
+
// whenever the direct read fails (deleted/renamed boat) and always carries
|
|
366
387
|
// the live state so callers can still see "archived".
|
|
367
|
-
const
|
|
368
|
-
function
|
|
388
|
+
const boatIdCache = new Map();
|
|
389
|
+
function boatInventoryProblem(status, body, included = false) {
|
|
369
390
|
if (status === 401 || status === 403) {
|
|
370
|
-
return "boat.dev rejected the
|
|
391
|
+
return included ? INCLUDED_BOAT_UNAVAILABLE : "boat.dev rejected the Boat API key — update it in Settings → Connections";
|
|
371
392
|
}
|
|
372
393
|
if (status === 429)
|
|
373
394
|
return "boat.dev is rate-limiting this account — wait a minute and refresh";
|
|
374
395
|
const message = typeof body?.message === "string" ? body.message.trim() : "";
|
|
375
396
|
return message ? `boat.dev could not list cloud computers: ${message}` : `boat.dev could not list cloud computers (${status})`;
|
|
376
397
|
}
|
|
377
|
-
function
|
|
398
|
+
function safeBoatState(value) {
|
|
378
399
|
if (typeof value !== "string")
|
|
379
400
|
return "unknown";
|
|
380
401
|
const state = value.toLowerCase();
|
|
381
|
-
return
|
|
402
|
+
return BOAT_STATES.has(state) ? state : "unknown";
|
|
382
403
|
}
|
|
383
|
-
async function
|
|
384
|
-
const
|
|
404
|
+
async function listBoatPages(cfg) {
|
|
405
|
+
const boats = [];
|
|
385
406
|
const seenCursors = new Set();
|
|
386
407
|
let cursor = null;
|
|
387
|
-
for (let page = 0; page <
|
|
388
|
-
const path = `/boxes?limit=${
|
|
408
|
+
for (let page = 0; page < MAX_BOAT_INVENTORY_PAGES; page += 1) {
|
|
409
|
+
const path = `/boxes?limit=${BOAT_INVENTORY_PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
389
410
|
let listed;
|
|
390
411
|
try {
|
|
391
|
-
listed = await
|
|
412
|
+
listed = await boatJson(cfg, path, { signal: AbortSignal.timeout(20_000) });
|
|
392
413
|
}
|
|
393
414
|
catch {
|
|
394
415
|
return { ok: false, problem: "Could not reach boat.dev to list cloud computers — check your connection and refresh" };
|
|
395
416
|
}
|
|
396
417
|
if (!listed.ok || !Array.isArray(listed.body?.boxes)) {
|
|
397
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
problem: boatInventoryProblem(listed.status, listed.body, usesIncludedBoat(cfg)),
|
|
421
|
+
credentialRejected: listed.status === 401 || listed.status === 403,
|
|
422
|
+
};
|
|
398
423
|
}
|
|
399
|
-
|
|
424
|
+
boats.push(...listed.body.boxes);
|
|
400
425
|
const next = listed.body?.pageInfo?.nextCursor;
|
|
401
426
|
if (next === undefined || next === null || next === "")
|
|
402
|
-
return { ok: true,
|
|
427
|
+
return { ok: true, boats };
|
|
403
428
|
if (typeof next !== "string" || next.length > 4_096) {
|
|
404
429
|
return { ok: false, problem: "boat.dev returned an invalid cloud computer page cursor" };
|
|
405
430
|
}
|
|
@@ -412,7 +437,7 @@ async function listBoxPages(cfg) {
|
|
|
412
437
|
return { ok: false, problem: "boat.dev returned too many cloud computer pages — narrow the account inventory and refresh" };
|
|
413
438
|
}
|
|
414
439
|
/**
|
|
415
|
-
* One account listing for Settings and deletion guards. Only
|
|
440
|
+
* One account listing for Settings and deletion guards. Only boats
|
|
416
441
|
* carrying OpenMausBot's exact deterministic name shape leave this boundary;
|
|
417
442
|
* provider desktop links, IPs, environment details and other raw fields never
|
|
418
443
|
* reach the renderer. Only names scoped to this installation may become
|
|
@@ -420,12 +445,12 @@ async function listBoxPages(cfg) {
|
|
|
420
445
|
* ownership; foreign-install and ownerless legacy rows remain invisible and
|
|
421
446
|
* therefore cannot become deletion targets.
|
|
422
447
|
*/
|
|
423
|
-
export async function
|
|
424
|
-
cfg =
|
|
425
|
-
if (!
|
|
448
|
+
export async function listManagedBoats(cfg, owners, options = {}) {
|
|
449
|
+
cfg = snapshotBoatConfig(cfg);
|
|
450
|
+
if (!boatConfigured(cfg)) {
|
|
426
451
|
return { configured: false, available: false, problem: null, instances: [] };
|
|
427
452
|
}
|
|
428
|
-
const listed = await
|
|
453
|
+
const listed = await listBoatPages(cfg);
|
|
429
454
|
if (!listed.ok) {
|
|
430
455
|
return {
|
|
431
456
|
configured: true,
|
|
@@ -436,8 +461,8 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
436
461
|
};
|
|
437
462
|
}
|
|
438
463
|
const namedOwners = await Promise.all(owners.map(async (owner) => ({
|
|
439
|
-
currentName: await
|
|
440
|
-
legacyName:
|
|
464
|
+
currentName: await boatNameFor(owner.botId),
|
|
465
|
+
legacyName: legacyBoatNameFor(owner.botId),
|
|
441
466
|
owner,
|
|
442
467
|
})));
|
|
443
468
|
const invalidInventory = (problem) => ({
|
|
@@ -451,12 +476,12 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
451
476
|
// authoritative direct endpoint so Settings can still display and delete
|
|
452
477
|
// the computer. Credential replacement probes deliberately opt out: their
|
|
453
478
|
// token must be judged only by the account inventory it can list.
|
|
454
|
-
let candidates = [...listed.
|
|
479
|
+
let candidates = [...listed.boats];
|
|
455
480
|
if (options.adoptLegacy !== false) {
|
|
456
481
|
const namedOwnerByBotId = new Map(namedOwners.map((entry) => [entry.owner.botId, entry]));
|
|
457
482
|
let recoveries;
|
|
458
483
|
try {
|
|
459
|
-
recoveries =
|
|
484
|
+
recoveries = boatCreateRecoverySnapshot();
|
|
460
485
|
}
|
|
461
486
|
catch {
|
|
462
487
|
return invalidInventory("OpenMausBot could not safely read its cloud computer recovery records");
|
|
@@ -478,14 +503,14 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
478
503
|
}
|
|
479
504
|
continue;
|
|
480
505
|
}
|
|
481
|
-
const inspected = await
|
|
506
|
+
const inspected = await inspectBoatIdentity(cfg, recovery.boxId);
|
|
482
507
|
if (!inspected.available) {
|
|
483
508
|
return invalidInventory(inspected.problem ?? "A remembered cloud computer could not be verified");
|
|
484
509
|
}
|
|
485
510
|
if (!inspected.identity) {
|
|
486
511
|
// Direct 404/410 is stronger than an eventually-consistent LIST row.
|
|
487
512
|
candidates = candidates.filter((candidate) => candidate?.id !== recovery.boxId);
|
|
488
|
-
|
|
513
|
+
retireDeletedBoatCreate(recovery.boxId);
|
|
489
514
|
continue;
|
|
490
515
|
}
|
|
491
516
|
if (inspected.identity.name !== namedOwner.currentName
|
|
@@ -501,7 +526,7 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
501
526
|
}
|
|
502
527
|
let deletions;
|
|
503
528
|
try {
|
|
504
|
-
deletions =
|
|
529
|
+
deletions = boatDeletionSnapshot();
|
|
505
530
|
}
|
|
506
531
|
catch {
|
|
507
532
|
return invalidInventory("OpenMausBot could not safely read its cloud computer deletion records");
|
|
@@ -509,7 +534,7 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
509
534
|
for (const deletion of deletions) {
|
|
510
535
|
let state;
|
|
511
536
|
try {
|
|
512
|
-
state = await
|
|
537
|
+
state = await reconcileRecordedBoatDeletion(cfg, deletion, [0]);
|
|
513
538
|
}
|
|
514
539
|
catch (error) {
|
|
515
540
|
return invalidInventory(error instanceof Error ? error.message : "A cloud computer deletion could not be verified");
|
|
@@ -528,13 +553,13 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
528
553
|
if (matchingRows[0]?.name !== deletion.name) {
|
|
529
554
|
return invalidInventory("A cloud computer being deleted no longer has its remembered name — repair it in boat.dev before continuing");
|
|
530
555
|
}
|
|
531
|
-
if (
|
|
556
|
+
if (getBoatDeletion(deletion.boxId)?.phase === "accepted") {
|
|
532
557
|
matchingRows[0] = { ...matchingRows[0], state: "removing" };
|
|
533
558
|
candidates = candidates.map((candidate) => candidate?.id === deletion.boxId ? matchingRows[0] : candidate);
|
|
534
559
|
}
|
|
535
560
|
continue;
|
|
536
561
|
}
|
|
537
|
-
const current =
|
|
562
|
+
const current = getBoatDeletion(deletion.boxId);
|
|
538
563
|
if (!current)
|
|
539
564
|
continue;
|
|
540
565
|
if (current.phase === "accepted") {
|
|
@@ -543,7 +568,7 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
543
568
|
}
|
|
544
569
|
// A prepared request may have lost its response, and a blocked request
|
|
545
570
|
// is retryable. Keep the exact row actionable only after a direct read.
|
|
546
|
-
const inspected = await
|
|
571
|
+
const inspected = await inspectBoatIdentity(cfg, current.boxId);
|
|
547
572
|
if (!inspected.available || !inspected.identity || inspected.identity.name !== current.name) {
|
|
548
573
|
return invalidInventory(inspected.problem ?? "A cloud computer deletion target could not be verified");
|
|
549
574
|
}
|
|
@@ -556,15 +581,15 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
556
581
|
}
|
|
557
582
|
const ownerByCurrentName = new Map(namedOwners.map(({ currentName, owner }) => [currentName, owner]));
|
|
558
583
|
const ownerByLegacyName = new Map(namedOwners.map(({ legacyName, owner }) => [legacyName, owner]));
|
|
559
|
-
const
|
|
584
|
+
const boatIdCounts = new Map();
|
|
560
585
|
for (const candidate of candidates) {
|
|
561
586
|
if (!candidate || typeof candidate !== "object")
|
|
562
587
|
continue;
|
|
563
588
|
const boxId = typeof candidate.id === "string" ? candidate.id : "";
|
|
564
|
-
if (
|
|
565
|
-
|
|
589
|
+
if (BOAT_ID.test(boxId))
|
|
590
|
+
boatIdCounts.set(boxId, (boatIdCounts.get(boxId) ?? 0) + 1);
|
|
566
591
|
}
|
|
567
|
-
const
|
|
592
|
+
const ownedBoatByBot = new Map();
|
|
568
593
|
for (const candidate of candidates) {
|
|
569
594
|
if (!candidate || typeof candidate !== "object")
|
|
570
595
|
continue;
|
|
@@ -573,18 +598,18 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
573
598
|
if (!owner)
|
|
574
599
|
continue;
|
|
575
600
|
const boxId = typeof candidate.id === "string" ? candidate.id : "";
|
|
576
|
-
if (!
|
|
601
|
+
if (!BOAT_ID.test(boxId)) {
|
|
577
602
|
return invalidInventory("boat.dev returned an invalid id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
|
|
578
603
|
}
|
|
579
|
-
const existing =
|
|
604
|
+
const existing = ownedBoatByBot.get(owner.botId);
|
|
580
605
|
if (existing && existing !== boxId) {
|
|
581
606
|
return invalidInventory("boat.dev returned conflicting cloud computers for one OpenMaus bot — repair them in boat.dev before continuing");
|
|
582
607
|
}
|
|
583
|
-
|
|
608
|
+
ownedBoatByBot.set(owner.botId, boxId);
|
|
584
609
|
}
|
|
585
610
|
const instances = [];
|
|
586
|
-
const
|
|
587
|
-
const scopedPrefix =
|
|
611
|
+
const seenBoatIds = new Set();
|
|
612
|
+
const scopedPrefix = scopedBoatPrefix();
|
|
588
613
|
for (const candidate of candidates) {
|
|
589
614
|
if (!candidate || typeof candidate !== "object")
|
|
590
615
|
continue;
|
|
@@ -592,14 +617,14 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
592
617
|
const name = typeof candidate.name === "string" ? candidate.name : "";
|
|
593
618
|
let owner = null;
|
|
594
619
|
let legacyOwner = false;
|
|
595
|
-
if (
|
|
620
|
+
if (SCOPED_MANAGED_BOAT_NAME.test(name)) {
|
|
596
621
|
// A valid OMB name for another environment is account-visible but not
|
|
597
622
|
// ours to display or mutate.
|
|
598
623
|
if (!name.startsWith(scopedPrefix))
|
|
599
624
|
continue;
|
|
600
625
|
owner = ownerByCurrentName.get(name) ?? null;
|
|
601
626
|
}
|
|
602
|
-
else if (
|
|
627
|
+
else if (LEGACY_MANAGED_BOAT_NAME.test(name)) {
|
|
603
628
|
// Pre-scope names have no installation provenance. A live local bot is
|
|
604
629
|
// the only safe ownership proof; unmatched legacy rows stay provider-
|
|
605
630
|
// managed until the person handles them in boat.dev directly.
|
|
@@ -614,25 +639,25 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
614
639
|
// Once a row names this installation (or a live bot through its legacy
|
|
615
640
|
// deterministic name), silently skipping a malformed/duplicated identity
|
|
616
641
|
// could let bot deletion mistake provider corruption for absence.
|
|
617
|
-
if (!
|
|
642
|
+
if (!BOAT_ID.test(boxId)) {
|
|
618
643
|
return invalidInventory("boat.dev returned an invalid id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
|
|
619
644
|
}
|
|
620
|
-
if ((
|
|
645
|
+
if ((boatIdCounts.get(boxId) ?? 0) !== 1 || seenBoatIds.has(boxId)) {
|
|
621
646
|
return invalidInventory("boat.dev returned a conflicting id for an OpenMaus-managed cloud computer — refresh or repair it in boat.dev");
|
|
622
647
|
}
|
|
623
648
|
if (legacyOwner && owner && options.adoptLegacy !== false) {
|
|
624
649
|
try {
|
|
625
|
-
|
|
650
|
+
adoptResolvedBoat(owner.botId, boxId);
|
|
626
651
|
}
|
|
627
652
|
catch {
|
|
628
653
|
return invalidInventory("OpenMausBot could not safely remember this legacy cloud computer's owner — repair it in boat.dev before continuing");
|
|
629
654
|
}
|
|
630
655
|
}
|
|
631
|
-
|
|
656
|
+
seenBoatIds.add(boxId);
|
|
632
657
|
instances.push({
|
|
633
658
|
boxId,
|
|
634
659
|
name,
|
|
635
|
-
state:
|
|
660
|
+
state: safeBoatState(candidate.state),
|
|
636
661
|
ownerBotId: owner?.botId ?? null,
|
|
637
662
|
ownerName: owner?.name ?? null,
|
|
638
663
|
orphaned: owner === null,
|
|
@@ -646,18 +671,18 @@ export async function listManagedBoxes(cfg, owners, options = {}) {
|
|
|
646
671
|
});
|
|
647
672
|
return { configured: true, available: true, problem: null, instances };
|
|
648
673
|
}
|
|
649
|
-
/** Direct identity proof for a
|
|
674
|
+
/** Direct identity proof for a Boat remembered in the local create journal.
|
|
650
675
|
* Unlike account LIST, this endpoint is not eventually consistent. Only the
|
|
651
676
|
* immutable id, provider name and allowlisted lifecycle state cross this
|
|
652
677
|
* boundary. */
|
|
653
|
-
export async function
|
|
654
|
-
cfg =
|
|
655
|
-
if (!
|
|
678
|
+
export async function inspectBoatIdentity(cfg, boxId) {
|
|
679
|
+
cfg = snapshotBoatConfig(cfg);
|
|
680
|
+
if (!BOAT_ID.test(boxId)) {
|
|
656
681
|
return { available: false, identity: null, problem: "the remembered cloud computer id is invalid" };
|
|
657
682
|
}
|
|
658
683
|
let inspected;
|
|
659
684
|
try {
|
|
660
|
-
inspected = await
|
|
685
|
+
inspected = await boatJson(cfg, `/boxes/${boxId}`, { signal: AbortSignal.timeout(20_000) });
|
|
661
686
|
}
|
|
662
687
|
catch {
|
|
663
688
|
return {
|
|
@@ -670,7 +695,7 @@ export async function inspectBoxIdentity(cfg, boxId) {
|
|
|
670
695
|
return { available: true, identity: null, problem: null };
|
|
671
696
|
}
|
|
672
697
|
if (!inspected.ok) {
|
|
673
|
-
return { available: false, identity: null, problem:
|
|
698
|
+
return { available: false, identity: null, problem: boatInventoryProblem(inspected.status, inspected.body, usesIncludedBoat(cfg)) };
|
|
674
699
|
}
|
|
675
700
|
const candidate = inspected.body?.box;
|
|
676
701
|
const returnedId = typeof candidate?.id === "string" ? candidate.id : "";
|
|
@@ -678,30 +703,30 @@ export async function inspectBoxIdentity(cfg, boxId) {
|
|
|
678
703
|
if (returnedId !== boxId || name.length === 0 || name.length > 100 || /[\r\n]/.test(name)) {
|
|
679
704
|
return { available: false, identity: null, problem: "boat.dev returned an invalid cloud computer identity" };
|
|
680
705
|
}
|
|
681
|
-
return { available: true, identity: { boxId, name, state:
|
|
706
|
+
return { available: true, identity: { boxId, name, state: safeBoatState(candidate.state) }, problem: null };
|
|
682
707
|
}
|
|
683
708
|
function inventoryFailure(inventory) {
|
|
684
709
|
const error = new Error(inventory.configured
|
|
685
710
|
? (inventory.problem ?? "Cloud computer inventory is unavailable")
|
|
686
|
-
: "
|
|
711
|
+
: "Boat is not configured — add its API key in Settings → Connections");
|
|
687
712
|
error.status = inventory.configured ? 503 : 409;
|
|
688
713
|
return error;
|
|
689
714
|
}
|
|
690
715
|
function deletionFenceError() {
|
|
691
716
|
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 });
|
|
692
717
|
}
|
|
693
|
-
function
|
|
694
|
-
if (
|
|
718
|
+
function assertBoatNotDeleting(boxId) {
|
|
719
|
+
if (getBoatDeletion(boxId))
|
|
695
720
|
throw deletionFenceError();
|
|
696
721
|
}
|
|
697
|
-
function
|
|
698
|
-
if (
|
|
722
|
+
function assertBotBoatNotDeleting(botId) {
|
|
723
|
+
if (hasPendingBoatDeletionForBot(botId))
|
|
699
724
|
throw deletionFenceError();
|
|
700
725
|
}
|
|
701
|
-
async function
|
|
702
|
-
if (!
|
|
726
|
+
async function revalidateManagedBoat(cfg, owners, boxId) {
|
|
727
|
+
if (!BOAT_ID.test(boxId))
|
|
703
728
|
throw Object.assign(new Error("invalid cloud computer id"), { status: 400 });
|
|
704
|
-
const inventory = await
|
|
729
|
+
const inventory = await listManagedBoats(cfg, owners);
|
|
705
730
|
if (!inventory.available)
|
|
706
731
|
throw inventoryFailure(inventory);
|
|
707
732
|
const instance = inventory.instances.find((candidate) => candidate.boxId === boxId);
|
|
@@ -714,27 +739,28 @@ const QUIESCE_BROWSER = [
|
|
|
714
739
|
'for name in chrome google-chrome chromium chromium-browser; do pid=$(pgrep -o -x "$name" 2>/dev/null || true); [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true; done',
|
|
715
740
|
'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',
|
|
716
741
|
].join("; ");
|
|
717
|
-
async function
|
|
718
|
-
|
|
742
|
+
async function stopBoat(cfg, boxId) {
|
|
743
|
+
assertBoatNotDeleting(boxId);
|
|
719
744
|
// Browser shutdown is best-effort, but the provider stop is not: Settings
|
|
720
745
|
// must never say a computer is sleeping when boat.dev rejected the action.
|
|
721
746
|
await runCommand(cfg, boxId, QUIESCE_BROWSER, { timeoutMs: 5_000 }).catch(() => null);
|
|
722
|
-
const stopped = await
|
|
723
|
-
if (!stopped.ok)
|
|
724
|
-
throw Object.assign(new Error(
|
|
747
|
+
const stopped = await boatJson(cfg, `/boxes/${boxId}/stop`, { method: "POST" });
|
|
748
|
+
if (!stopped.ok) {
|
|
749
|
+
throw Object.assign(new Error(boatErrorMessage(stopped.status, "boat sleep", stopped.body, usesIncludedBoat(cfg))), { status: stopped.status });
|
|
750
|
+
}
|
|
725
751
|
}
|
|
726
|
-
function
|
|
727
|
-
for (const [botId, cachedId] of
|
|
752
|
+
function forgetBoatId(boxId) {
|
|
753
|
+
for (const [botId, cachedId] of boatIdCache) {
|
|
728
754
|
if (cachedId === boxId)
|
|
729
|
-
|
|
755
|
+
boatIdCache.delete(botId);
|
|
730
756
|
}
|
|
731
757
|
}
|
|
732
758
|
/** Explicit Settings action. Re-listing prevents a stale row from targeting a
|
|
733
|
-
* renamed or foreign provider resource. This never wakes or joins a
|
|
734
|
-
export async function
|
|
735
|
-
cfg =
|
|
736
|
-
|
|
737
|
-
const instance = await
|
|
759
|
+
* renamed or foreign provider resource. This never wakes or joins a Boat. */
|
|
760
|
+
export async function sleepManagedBoat(cfg, owners, boxId, claim) {
|
|
761
|
+
cfg = snapshotBoatConfig(cfg);
|
|
762
|
+
assertBoatNotDeleting(boxId);
|
|
763
|
+
const instance = await revalidateManagedBoat(cfg, owners, boxId);
|
|
738
764
|
if (instance.inUse) {
|
|
739
765
|
throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
|
|
740
766
|
}
|
|
@@ -744,8 +770,8 @@ export async function sleepManagedBox(cfg, owners, boxId, claim) {
|
|
|
744
770
|
const release = claim?.(instance);
|
|
745
771
|
try {
|
|
746
772
|
if (!SLEEPING.has(instance.state))
|
|
747
|
-
await
|
|
748
|
-
|
|
773
|
+
await stopBoat(cfg, instance.boxId);
|
|
774
|
+
forgetBoatId(instance.boxId);
|
|
749
775
|
return { ok: true };
|
|
750
776
|
}
|
|
751
777
|
finally {
|
|
@@ -755,23 +781,23 @@ export async function sleepManagedBox(cfg, owners, boxId, claim) {
|
|
|
755
781
|
/** Permanent Settings action. The caller must echo the exact freshly-listed
|
|
756
782
|
* machine name as well as its id; boat.dev independently requires the id in
|
|
757
783
|
* its confirmation header. */
|
|
758
|
-
export async function
|
|
759
|
-
cfg =
|
|
760
|
-
const remembered =
|
|
784
|
+
export async function deleteManagedBoat(cfg, owners, boxId, confirmName, claim, options = {}) {
|
|
785
|
+
cfg = snapshotBoatConfig(cfg);
|
|
786
|
+
const remembered = getBoatDeletion(boxId);
|
|
761
787
|
if (remembered) {
|
|
762
|
-
const reconciled = await
|
|
788
|
+
const reconciled = await reconcileRecordedBoatDeletion(cfg, remembered, [0]);
|
|
763
789
|
if (reconciled === "confirmed")
|
|
764
790
|
return { ok: true };
|
|
765
791
|
// A validated accepted operation owns this target. Retrying DELETE would
|
|
766
792
|
// create a second operation and weaken the only trustworthy receipt.
|
|
767
|
-
const current =
|
|
793
|
+
const current = getBoatDeletion(boxId);
|
|
768
794
|
if (current?.phase === "accepted") {
|
|
769
795
|
return { ok: true, pending: true };
|
|
770
796
|
}
|
|
771
797
|
// Prepared (ambiguous request) and blocked records may be retried only by
|
|
772
798
|
// this explicit Settings/bot-deletion path after fresh identity checks.
|
|
773
799
|
}
|
|
774
|
-
const instance = await
|
|
800
|
+
const instance = await revalidateManagedBoat(cfg, owners, boxId);
|
|
775
801
|
if (instance.inUse) {
|
|
776
802
|
throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
|
|
777
803
|
}
|
|
@@ -780,13 +806,13 @@ export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim, o
|
|
|
780
806
|
}
|
|
781
807
|
const release = claim?.(instance);
|
|
782
808
|
try {
|
|
783
|
-
const confirmation = await
|
|
809
|
+
const confirmation = await requestRecordedBoatDeletion(cfg, {
|
|
784
810
|
boxId: instance.boxId,
|
|
785
811
|
name: instance.name,
|
|
786
812
|
ownerBotId: instance.ownerBotId,
|
|
787
813
|
}, options.pollDelaysMs);
|
|
788
814
|
if (confirmation === "pending") {
|
|
789
|
-
|
|
815
|
+
forgetBoatId(instance.boxId);
|
|
790
816
|
return { ok: true, pending: true };
|
|
791
817
|
}
|
|
792
818
|
return { ok: true };
|
|
@@ -795,72 +821,87 @@ export async function deleteManagedBox(cfg, owners, boxId, confirmName, claim, o
|
|
|
795
821
|
release?.();
|
|
796
822
|
}
|
|
797
823
|
}
|
|
798
|
-
export async function
|
|
799
|
-
cfg =
|
|
800
|
-
|
|
801
|
-
const cachedId =
|
|
824
|
+
export async function findBoat(cfg, botId) {
|
|
825
|
+
cfg = snapshotBoatConfig(cfg);
|
|
826
|
+
assertBotBoatNotDeleting(botId);
|
|
827
|
+
const cachedId = boatIdCache.get(botId);
|
|
802
828
|
if (cachedId) {
|
|
803
829
|
let direct = null;
|
|
804
830
|
try {
|
|
805
|
-
direct = await
|
|
831
|
+
direct = await boatJson(cfg, `/boxes/${cachedId}`);
|
|
806
832
|
}
|
|
807
833
|
catch {
|
|
808
834
|
// A direct read can fail while the account listing still succeeds.
|
|
809
835
|
// Fall through to the authoritative paginated lookup before deciding.
|
|
810
836
|
}
|
|
811
|
-
const
|
|
812
|
-
if (direct?.ok &&
|
|
813
|
-
return
|
|
814
|
-
if (direct?.ok &&
|
|
837
|
+
const directBoat = direct?.body?.box;
|
|
838
|
+
if (direct?.ok && directBoat?.id === cachedId && directBoat.state !== "error")
|
|
839
|
+
return directBoat;
|
|
840
|
+
if (direct?.ok && directBoat?.id !== cachedId) {
|
|
815
841
|
throw Object.assign(new Error("boat.dev returned an invalid cloud computer identity"), { status: 503 });
|
|
816
842
|
}
|
|
817
|
-
|
|
843
|
+
boatIdCache.delete(botId); // gone or broken — fall back to the listing
|
|
818
844
|
}
|
|
819
|
-
const name = await
|
|
820
|
-
const legacyName =
|
|
821
|
-
const listed = await
|
|
845
|
+
const name = await boatNameFor(botId);
|
|
846
|
+
const legacyName = legacyBoatNameFor(botId);
|
|
847
|
+
const listed = await listBoatPages(cfg);
|
|
822
848
|
if (!listed.ok) {
|
|
823
849
|
throw Object.assign(new Error(listed.problem), { status: 503 });
|
|
824
850
|
}
|
|
825
851
|
// Prefer the installation-scoped identity. A legacy name remains
|
|
826
852
|
// discoverable only for this exact local bot id.
|
|
827
|
-
const expected = listed.
|
|
828
|
-
if (expected.some((candidate) => !
|
|
853
|
+
const expected = listed.boats.filter((candidate) => candidate?.name === name || candidate?.name === legacyName);
|
|
854
|
+
if (expected.some((candidate) => !BOAT_ID.test(candidate?.id))) {
|
|
829
855
|
throw Object.assign(new Error("boat.dev returned an invalid cloud computer identity"), { status: 503 });
|
|
830
856
|
}
|
|
831
857
|
const found = expected.find((candidate) => candidate.name === name && candidate.state !== "error")
|
|
832
858
|
?? expected.find((candidate) => candidate.name === legacyName && candidate.state !== "error")
|
|
833
859
|
?? null;
|
|
834
860
|
if (found) {
|
|
835
|
-
const duplicateId = listed.
|
|
861
|
+
const duplicateId = listed.boats.filter((candidate) => candidate?.id === found.id).length !== 1;
|
|
836
862
|
if (duplicateId) {
|
|
837
863
|
throw Object.assign(new Error("boat.dev returned a conflicting cloud computer identity"), { status: 503 });
|
|
838
864
|
}
|
|
839
865
|
if (found.name === legacyName)
|
|
840
|
-
|
|
841
|
-
|
|
866
|
+
adoptResolvedBoat(botId, found.id);
|
|
867
|
+
boatIdCache.set(botId, found.id);
|
|
842
868
|
}
|
|
843
869
|
return found;
|
|
844
870
|
}
|
|
845
|
-
/** Ready-or-null without the LIST when we already know the
|
|
846
|
-
export async function
|
|
847
|
-
cfg =
|
|
848
|
-
const
|
|
849
|
-
if (!
|
|
871
|
+
/** Ready-or-null without the LIST when we already know the boat. */
|
|
872
|
+
export async function readyBoat(cfg, botId, budgetMs = 60_000) {
|
|
873
|
+
cfg = snapshotBoatConfig(cfg);
|
|
874
|
+
const boat = await findBoat(cfg, botId);
|
|
875
|
+
if (!boat)
|
|
850
876
|
return null;
|
|
851
|
-
if (READY.has(
|
|
852
|
-
return
|
|
853
|
-
return waitReady(cfg,
|
|
877
|
+
if (READY.has(boat.state))
|
|
878
|
+
return boat;
|
|
879
|
+
return waitReady(cfg, boat.id, budgetMs);
|
|
880
|
+
}
|
|
881
|
+
/** The Boat credential a request uses: the person's own token, else Cloud
|
|
882
|
+
* Pro's included one. Settings' own-key flows read cfg.box.token instead. */
|
|
883
|
+
export function boatAccount(cfg) {
|
|
884
|
+
return boatCredential(cfg.box?.token);
|
|
885
|
+
}
|
|
886
|
+
export function boatConfigured(cfg) {
|
|
887
|
+
return Boolean(boatAccount(cfg));
|
|
888
|
+
}
|
|
889
|
+
function usesIncludedBoat(cfg) {
|
|
890
|
+
return boatAccount(cfg)?.included === true;
|
|
854
891
|
}
|
|
855
|
-
|
|
856
|
-
|
|
892
|
+
/** What Settings shows: configured-or-not, and whether that is Cloud Pro's
|
|
893
|
+
* included account rather than a saved key. Never the token. */
|
|
894
|
+
export function describeBoatAccount(cfg) {
|
|
895
|
+
const account = boatAccount(cfg);
|
|
896
|
+
return { configured: Boolean(account), ...(account?.included ? { included: true } : {}) };
|
|
857
897
|
}
|
|
858
898
|
/** Ask the provider whether a token is real, before we let someone save
|
|
859
899
|
* it. Without this the paste "succeeds", and the first sign of trouble is
|
|
860
|
-
* a 401 in a different panel minutes later, with nothing to act on.
|
|
900
|
+
* a 401 in a different panel minutes later, with nothing to act on. Only
|
|
901
|
+
* ever an own token, so only ever Boat itself. */
|
|
861
902
|
export async function verifyToken(token) {
|
|
862
903
|
try {
|
|
863
|
-
const res = await fetch(`${
|
|
904
|
+
const res = await fetch(`${boatProviderApi()}/boxes`, {
|
|
864
905
|
headers: { authorization: `Bearer ${token}` },
|
|
865
906
|
signal: AbortSignal.timeout(20_000),
|
|
866
907
|
});
|
|
@@ -868,7 +909,7 @@ export async function verifyToken(token) {
|
|
|
868
909
|
return { ok: true };
|
|
869
910
|
if (res.status === 401 || res.status === 403) {
|
|
870
911
|
// the common mistake is pasting some other credential entirely —
|
|
871
|
-
//
|
|
912
|
+
// boat API keys are prefixed, so say which thing is wrong
|
|
872
913
|
return {
|
|
873
914
|
ok: false,
|
|
874
915
|
message: token.startsWith("box_")
|
|
@@ -882,18 +923,24 @@ export async function verifyToken(token) {
|
|
|
882
923
|
return { ok: false, message: "Couldn't reach boat.dev to check that token — check your connection and retry." };
|
|
883
924
|
}
|
|
884
925
|
}
|
|
926
|
+
/** A rejected Cloud Pro relay token: nothing the person pasted, so nothing
|
|
927
|
+
* for them to fix in Settings. */
|
|
928
|
+
const INCLUDED_BOAT_UNAVAILABLE = "Cloud Pro's included cloud computers aren't available right now. Try again later.";
|
|
885
929
|
/** Turn a provider refusal into something a person can act on. The
|
|
886
930
|
* provider's own message is better than anything we can invent — it knows
|
|
887
931
|
* the plan, the limit and the link — so prefer it and only fall back to
|
|
888
|
-
* our own wording when it says nothing useful.
|
|
889
|
-
|
|
932
|
+
* our own wording when it says nothing useful. `included`: the request used
|
|
933
|
+
* Cloud Pro's included token, not the person's own. */
|
|
934
|
+
export function boatErrorMessage(status, what, body, included = false) {
|
|
890
935
|
const theirs = typeof body?.message === "string" ? body.message.trim() : "";
|
|
891
936
|
const link = typeof body?.error?.details?.billingUrl === "string" ? body.error.details.billingUrl : "";
|
|
892
937
|
if (status === 402) {
|
|
893
|
-
// e.g. "Start the $20/month
|
|
894
|
-
return [theirs || "boat.dev needs a paid
|
|
938
|
+
// e.g. "Start the $20/month Boat plan to create sandboxes."
|
|
939
|
+
return [theirs || "boat.dev needs a paid Boat plan before it will create a computer.", link].filter(Boolean).join(" ");
|
|
895
940
|
}
|
|
896
941
|
if (status === 401 || status === 403) {
|
|
942
|
+
if (included)
|
|
943
|
+
return INCLUDED_BOAT_UNAVAILABLE;
|
|
897
944
|
return "your box token was rejected by boat.dev — open App Settings and paste a current token (it starts with box_)";
|
|
898
945
|
}
|
|
899
946
|
if (status === 429) {
|
|
@@ -905,26 +952,26 @@ export function boxErrorMessage(status, what, body) {
|
|
|
905
952
|
* structured `trial_auto_stop_required` refusal. Retry that one condition
|
|
906
953
|
* once at the provider's advertised maximum (or the documented two-hour
|
|
907
954
|
* trial ceiling). Other create failures must retain their original error. */
|
|
908
|
-
function
|
|
955
|
+
function trialBoatTtlSeconds(body) {
|
|
909
956
|
const code = body?.error?.code ?? body?.code;
|
|
910
957
|
if (code !== "trial_auto_stop_required")
|
|
911
958
|
return null;
|
|
912
959
|
const details = body?.error?.details ?? body?.details ?? {};
|
|
913
960
|
for (const value of [details.maxTtlSeconds, details.maximumTtlSeconds, details.maxAutoStopSeconds]) {
|
|
914
|
-
if (Number.isInteger(value) && value > 0 && value <=
|
|
961
|
+
if (Number.isInteger(value) && value > 0 && value <= DEFAULT_BOAT_TTL_SECONDS)
|
|
915
962
|
return value;
|
|
916
963
|
}
|
|
917
|
-
return
|
|
964
|
+
return TRIAL_BOAT_TTL_SECONDS;
|
|
918
965
|
}
|
|
919
966
|
function idempotentCreateInProgress(result) {
|
|
920
967
|
const code = result.body?.error?.code ?? result.body?.code;
|
|
921
968
|
return result.status === 409 && code === "idempotency_in_progress";
|
|
922
969
|
}
|
|
923
970
|
/** The keys this OpenMausBot already holds, as the environment its bots'
|
|
924
|
-
* agents read on the
|
|
925
|
-
* boat.dev account's own logins never land in the guest: the
|
|
926
|
-
* these and nothing else (see "Whose keys" in the
|
|
927
|
-
export function
|
|
971
|
+
* agents read on the boat. The boat is created with `noEnv: true`, so the
|
|
972
|
+
* boat.dev account's own logins never land in the guest: the boat has exactly
|
|
973
|
+
* these and nothing else (see "Whose keys" in the Boat integrated-agents docs). */
|
|
974
|
+
export function boatCredentialEnv(cfg, env = process.env) {
|
|
928
975
|
const out = {};
|
|
929
976
|
const put = (name, value) => {
|
|
930
977
|
if (typeof value === "string" && value.trim())
|
|
@@ -933,13 +980,13 @@ export function boxCredentialEnv(cfg, env = process.env) {
|
|
|
933
980
|
// The workspace key only: an ANTHROPIC_API_KEY in the server's own env is
|
|
934
981
|
// never the workspace key (see loadConfig), so it is not forwarded either.
|
|
935
982
|
put("ANTHROPIC_API_KEY", cfg.anthropic?.key);
|
|
936
|
-
for (const name of
|
|
983
|
+
for (const name of BOAT_FORWARDED_CREDENTIAL_ENV)
|
|
937
984
|
put(name, env[name]);
|
|
938
985
|
return out;
|
|
939
986
|
}
|
|
940
|
-
/** Names the
|
|
987
|
+
/** Names the boat's agents read (Claude Code, Codex, pi, OpenCode, Prime
|
|
941
988
|
* Agent, Kimi), forwarded verbatim from this server's environment when set. */
|
|
942
|
-
const
|
|
989
|
+
const BOAT_FORWARDED_CREDENTIAL_ENV = [
|
|
943
990
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
944
991
|
"OPENAI_API_KEY",
|
|
945
992
|
"OPENROUTER_API_KEY",
|
|
@@ -949,22 +996,22 @@ const BOX_FORWARDED_CREDENTIAL_ENV = [
|
|
|
949
996
|
"KIMI_CODE_ACCESS_TOKEN",
|
|
950
997
|
"KIMI_CODE_REFRESH_TOKEN",
|
|
951
998
|
];
|
|
952
|
-
async function
|
|
999
|
+
async function requestBoatCreate(cfg, botId, ttlSeconds, env) {
|
|
953
1000
|
// The computer needs the user's desktop session, not the account owner's
|
|
954
1001
|
// host credentials. Keep provider-side env injection off; the only keys the
|
|
955
1002
|
// guest ever has are the ones this OpenMausBot forwards (`env`), which its
|
|
956
|
-
// agents need now that the turn runs on the
|
|
1003
|
+
// agents need now that the turn runs on the boat. The idempotency identity
|
|
957
1004
|
// stays the secret-free part: a trial-TTL retry must receive a different
|
|
958
1005
|
// key, and the journal on disk never carries a credential.
|
|
959
1006
|
const body = JSON.stringify({ ttlSeconds, noEnv: true });
|
|
960
1007
|
const wireBody = JSON.stringify({ ttlSeconds, noEnv: true, ...(Object.keys(env).length ? { env } : {}) });
|
|
961
|
-
let attempt =
|
|
1008
|
+
let attempt = beginBoatCreate(botId, body);
|
|
962
1009
|
let request = attempt.request;
|
|
963
1010
|
let createdThisAttempt = attempt.startedNow;
|
|
964
|
-
// A previous process received the
|
|
1011
|
+
// A previous process received the Boat but died before naming it. Resolve
|
|
965
1012
|
// the durable identity directly; never issue a second create first.
|
|
966
1013
|
if (request.boxId) {
|
|
967
|
-
const recovered = await
|
|
1014
|
+
const recovered = await boatJson(cfg, `/boxes/${request.boxId}`, {
|
|
968
1015
|
signal: AbortSignal.timeout(20_000),
|
|
969
1016
|
});
|
|
970
1017
|
if (recovered.ok && recovered.body?.box?.id === request.boxId) {
|
|
@@ -973,8 +1020,8 @@ async function requestBoxCreate(cfg, botId, ttlSeconds, env) {
|
|
|
973
1020
|
if (recovered.status !== 404 && recovered.status !== 410) {
|
|
974
1021
|
return { ...recovered, request, createdThisAttempt: false };
|
|
975
1022
|
}
|
|
976
|
-
|
|
977
|
-
attempt =
|
|
1023
|
+
discardBoatCreate(request);
|
|
1024
|
+
attempt = beginBoatCreate(botId, body);
|
|
978
1025
|
request = attempt.request;
|
|
979
1026
|
createdThisAttempt = attempt.startedNow;
|
|
980
1027
|
}
|
|
@@ -983,7 +1030,7 @@ async function requestBoxCreate(cfg, botId, ttlSeconds, env) {
|
|
|
983
1030
|
let inProgressRetries = 0;
|
|
984
1031
|
for (;;) {
|
|
985
1032
|
try {
|
|
986
|
-
last = await
|
|
1033
|
+
last = await boatJson(cfg, "/boxes", {
|
|
987
1034
|
method: "POST",
|
|
988
1035
|
headers: { "Idempotency-Key": request.idempotencyKey },
|
|
989
1036
|
signal: AbortSignal.timeout(45_000),
|
|
@@ -992,18 +1039,18 @@ async function requestBoxCreate(cfg, botId, ttlSeconds, env) {
|
|
|
992
1039
|
}
|
|
993
1040
|
catch (error) {
|
|
994
1041
|
// A dropped response is ambiguous: boat.dev may already have created
|
|
995
|
-
// the
|
|
1042
|
+
// the Boat. One retry with the same key recovers it safely.
|
|
996
1043
|
if (ambiguousRetries++ === 0)
|
|
997
1044
|
continue;
|
|
998
1045
|
throw error;
|
|
999
1046
|
}
|
|
1000
1047
|
const boxId = last.body?.box?.id;
|
|
1001
1048
|
if (last.ok && typeof boxId === "string" && boxId) {
|
|
1002
|
-
request =
|
|
1049
|
+
request = rememberCreatedBoat(request, boxId);
|
|
1003
1050
|
return { ...last, request, createdThisAttempt };
|
|
1004
1051
|
}
|
|
1005
1052
|
if (idempotentCreateInProgress(last)) {
|
|
1006
|
-
const delay =
|
|
1053
|
+
const delay = BOAT_CREATE_IN_PROGRESS_RETRY_DELAYS_MS[inProgressRetries++];
|
|
1007
1054
|
if (delay !== undefined) {
|
|
1008
1055
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1009
1056
|
continue;
|
|
@@ -1014,32 +1061,32 @@ async function requestBoxCreate(cfg, botId, ttlSeconds, env) {
|
|
|
1014
1061
|
continue;
|
|
1015
1062
|
// A 5xx or any idempotency conflict can follow a provider-side create;
|
|
1016
1063
|
// keep its key for recovery. Only a definitive client rejection proves
|
|
1017
|
-
// this request did not create a
|
|
1064
|
+
// this request did not create a Boat and may be replaced safely.
|
|
1018
1065
|
if (last.status < 500 && last.status !== 409 && !last.ok)
|
|
1019
|
-
|
|
1066
|
+
discardBoatCreate(request);
|
|
1020
1067
|
return { ...last, request, createdThisAttempt };
|
|
1021
1068
|
}
|
|
1022
1069
|
}
|
|
1023
|
-
async function
|
|
1024
|
-
const first = await
|
|
1070
|
+
async function createBoat(cfg, botId, env) {
|
|
1071
|
+
const first = await requestBoatCreate(cfg, botId, DEFAULT_BOAT_TTL_SECONDS, env);
|
|
1025
1072
|
if (first.ok)
|
|
1026
1073
|
return first;
|
|
1027
|
-
const trialTtl =
|
|
1028
|
-
return trialTtl === null ? first :
|
|
1074
|
+
const trialTtl = trialBoatTtlSeconds(first.body);
|
|
1075
|
+
return trialTtl === null ? first : requestBoatCreate(cfg, botId, trialTtl, env);
|
|
1029
1076
|
}
|
|
1030
1077
|
/** A prior explicit delete always wins over provisioning. Reconcile/retry the
|
|
1031
1078
|
* old immutable target, then require a fresh provision request so one click
|
|
1032
1079
|
* can never both erase and silently recreate the same computer. */
|
|
1033
1080
|
async function finishPriorDeletionBeforeProvision(cfg, botId) {
|
|
1034
|
-
const remembered =
|
|
1081
|
+
const remembered = boatDeletionSnapshot().filter((record) => record.ownerBotId === botId);
|
|
1035
1082
|
if (!remembered.length)
|
|
1036
1083
|
return;
|
|
1037
1084
|
for (const deletion of remembered) {
|
|
1038
|
-
let state = await
|
|
1085
|
+
let state = await reconcileRecordedBoatDeletion(cfg, deletion, [0]);
|
|
1039
1086
|
if (state !== "confirmed") {
|
|
1040
|
-
const current =
|
|
1087
|
+
const current = getBoatDeletion(deletion.boxId);
|
|
1041
1088
|
if (current?.phase === "prepared" || current?.phase === "blocked") {
|
|
1042
|
-
state = await
|
|
1089
|
+
state = await requestRecordedBoatDeletion(cfg, {
|
|
1043
1090
|
boxId: current.boxId,
|
|
1044
1091
|
name: current.name,
|
|
1045
1092
|
ownerBotId: current.ownerBotId,
|
|
@@ -1051,150 +1098,150 @@ async function finishPriorDeletionBeforeProvision(cfg, botId) {
|
|
|
1051
1098
|
}
|
|
1052
1099
|
throw Object.assign(new Error("the previous cloud computer deletion finished — retry to create a new computer"), { status: 409 });
|
|
1053
1100
|
}
|
|
1054
|
-
/**
|
|
1055
|
-
export async function
|
|
1056
|
-
cfg =
|
|
1057
|
-
if (!
|
|
1101
|
+
/** Boat state for the Computer panel. */
|
|
1102
|
+
export async function boatStatus(cfg, botId) {
|
|
1103
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1104
|
+
if (!boatConfigured(cfg))
|
|
1058
1105
|
return { configured: false, box: null };
|
|
1059
|
-
const
|
|
1106
|
+
const boat = await findBoat(cfg, botId);
|
|
1060
1107
|
return {
|
|
1061
1108
|
configured: true,
|
|
1062
|
-
box:
|
|
1109
|
+
box: boat ? { boxId: boat.id, state: boat.state, desktopAvailable: boat.desktopAvailable ?? null } : null,
|
|
1063
1110
|
};
|
|
1064
1111
|
}
|
|
1065
1112
|
/**
|
|
1066
|
-
* Find-or-create the bot's persistent
|
|
1067
|
-
* desktop URL. The
|
|
1113
|
+
* Find-or-create the bot's persistent boat, wait for ready, and mint a fresh
|
|
1114
|
+
* desktop URL. The boat ships its own computer-use driver and agent runner.
|
|
1068
1115
|
*/
|
|
1069
|
-
export async function
|
|
1070
|
-
const credentialEnv =
|
|
1071
|
-
cfg =
|
|
1072
|
-
if (!
|
|
1116
|
+
export async function provisionBoat(cfg, botId, _botName) {
|
|
1117
|
+
const credentialEnv = boatCredentialEnv(cfg);
|
|
1118
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1119
|
+
if (!boatConfigured(cfg)) {
|
|
1073
1120
|
throw new Error('box provider not enabled — add {"box":{"token":"…"}} to ~/.openmausbot/config.json');
|
|
1074
1121
|
}
|
|
1075
1122
|
await finishPriorDeletionBeforeProvision(cfg, botId);
|
|
1076
|
-
const vmName = await
|
|
1077
|
-
let
|
|
1123
|
+
const vmName = await boatNameFor(botId);
|
|
1124
|
+
let boat = await findBoat(cfg, botId);
|
|
1078
1125
|
let created = false;
|
|
1079
1126
|
let createRequest = null;
|
|
1080
1127
|
try {
|
|
1081
|
-
if (!
|
|
1128
|
+
if (!boat) {
|
|
1082
1129
|
// Deletion can be prepared by another process after the initial lookup.
|
|
1083
1130
|
// Never create a replacement until the durable fence is reconciled.
|
|
1084
|
-
|
|
1131
|
+
assertBotBoatNotDeleting(botId);
|
|
1085
1132
|
// Provider-side backstop: archives itself (billing pauses, disk
|
|
1086
1133
|
// survives) if every stop path dies. Trial accounts get one narrower
|
|
1087
1134
|
// retry when boat.dev reports their shorter TTL ceiling.
|
|
1088
|
-
const createRes = await
|
|
1135
|
+
const createRes = await createBoat(cfg, botId, credentialEnv);
|
|
1089
1136
|
if (!createRes.ok || !createRes.body?.box?.id) {
|
|
1090
|
-
throw new Error(
|
|
1137
|
+
throw new Error(boatErrorMessage(createRes.status, "boat create", createRes.body, usesIncludedBoat(cfg)));
|
|
1091
1138
|
}
|
|
1092
|
-
|
|
1139
|
+
boat = createRes.body.box;
|
|
1093
1140
|
createRequest = createRes.request;
|
|
1094
1141
|
created = createRes.createdThisAttempt;
|
|
1095
|
-
const rename = await
|
|
1142
|
+
const rename = await boatJson(cfg, `/boxes/${boat.id}`, {
|
|
1096
1143
|
method: "PATCH",
|
|
1097
1144
|
body: JSON.stringify({ name: vmName }),
|
|
1098
1145
|
});
|
|
1099
1146
|
if (!rename.ok)
|
|
1100
|
-
throw new Error(
|
|
1147
|
+
throw new Error(boatErrorMessage(rename.status, "box naming", rename.body, usesIncludedBoat(cfg)));
|
|
1101
1148
|
if (createRequest)
|
|
1102
|
-
createRequest =
|
|
1149
|
+
createRequest = resolveBoatCreate(createRequest);
|
|
1103
1150
|
}
|
|
1104
|
-
const ready = await waitReady(cfg,
|
|
1151
|
+
const ready = await waitReady(cfg, boat.id);
|
|
1105
1152
|
if (!ready)
|
|
1106
1153
|
throw new Error("box did not become ready within 90s — retry in a minute");
|
|
1107
|
-
// Nothing to install: every
|
|
1154
|
+
// Nothing to install: every boat ships its own computer-use driver and
|
|
1108
1155
|
// registers it with every harness it runs.
|
|
1109
|
-
const joinUrl = await mintDesktopUrl(cfg,
|
|
1156
|
+
const joinUrl = await mintDesktopUrl(cfg, boat.id);
|
|
1110
1157
|
if (!joinUrl)
|
|
1111
1158
|
throw new Error("box desktop link could not be created");
|
|
1112
|
-
return { boxId:
|
|
1159
|
+
return { boxId: boat.id, machineName: vmName, reused: !created, state: ready.state, joinUrl };
|
|
1113
1160
|
}
|
|
1114
1161
|
catch (error) {
|
|
1115
|
-
if (!created || !
|
|
1162
|
+
if (!created || !boat?.id)
|
|
1116
1163
|
throw error;
|
|
1117
1164
|
const originalMessage = error instanceof Error ? error.message : String(error);
|
|
1118
1165
|
// Capture the provider's current name when possible. Naming may be the
|
|
1119
1166
|
// step that failed, so the desired deterministic name is only a fallback
|
|
1120
1167
|
// for the durable fence, never proof of a later live identity.
|
|
1121
|
-
const inspected = await
|
|
1168
|
+
const inspected = await inspectBoatIdentity(cfg, boat.id);
|
|
1122
1169
|
if (inspected.available && !inspected.identity) {
|
|
1123
|
-
|
|
1124
|
-
|
|
1170
|
+
retireDeletedBoatCreate(boat.id);
|
|
1171
|
+
boatIdCache.delete(botId);
|
|
1125
1172
|
throw error;
|
|
1126
1173
|
}
|
|
1127
1174
|
let cleanupConfirmation;
|
|
1128
1175
|
try {
|
|
1129
|
-
cleanupConfirmation = await
|
|
1130
|
-
boxId:
|
|
1176
|
+
cleanupConfirmation = await requestRecordedBoatDeletion(cfg, {
|
|
1177
|
+
boxId: boat.id,
|
|
1131
1178
|
name: inspected.identity?.name ?? vmName,
|
|
1132
1179
|
ownerBotId: botId,
|
|
1133
1180
|
});
|
|
1134
1181
|
}
|
|
1135
1182
|
catch (cleanupError) {
|
|
1136
1183
|
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
1137
|
-
throw new Error(`${originalMessage}. The new computer's deletion was not confirmed: ${cleanupMessage}. Check box ${
|
|
1184
|
+
throw new Error(`${originalMessage}. The new computer's deletion was not confirmed: ${cleanupMessage}. Check box ${boat.id} in boat.dev.`);
|
|
1138
1185
|
}
|
|
1139
1186
|
if (cleanupConfirmation === "confirmed")
|
|
1140
1187
|
throw error;
|
|
1141
|
-
|
|
1188
|
+
boatIdCache.delete(botId);
|
|
1142
1189
|
throw Object.assign(new Error(`${originalMessage}. boat.dev accepted deletion of the new computer, but it is still pending; `
|
|
1143
|
-
+ `its recovery record was kept, along with its deletion fence. Check box ${
|
|
1190
|
+
+ `its recovery record was kept, along with its deletion fence. Check box ${boat.id} in boat.dev.`, { cause: error }), { status: 503 });
|
|
1144
1191
|
}
|
|
1145
1192
|
}
|
|
1146
|
-
/** Wake the bot's
|
|
1147
|
-
export async function
|
|
1148
|
-
cfg =
|
|
1149
|
-
const
|
|
1150
|
-
if (!
|
|
1193
|
+
/** Wake the bot's boat and return a FRESH desktop URL. */
|
|
1194
|
+
export async function joinBoat(cfg, botId) {
|
|
1195
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1196
|
+
const boat = await findBoat(cfg, botId);
|
|
1197
|
+
if (!boat)
|
|
1151
1198
|
throw new Error("no computer yet — provision it first");
|
|
1152
|
-
const ready = await waitReady(cfg,
|
|
1199
|
+
const ready = await waitReady(cfg, boat.id);
|
|
1153
1200
|
if (!ready)
|
|
1154
1201
|
throw new Error("the box did not wake in time — try again");
|
|
1155
|
-
// Provider archive/resume preserves disk but not processes; the
|
|
1202
|
+
// Provider archive/resume preserves disk but not processes; the boat brings
|
|
1156
1203
|
// its own driver daemon back up, so there is nothing to reattach here.
|
|
1157
|
-
return { joinUrl: await mintDesktopUrl(cfg,
|
|
1204
|
+
return { joinUrl: await mintDesktopUrl(cfg, boat.id), state: ready.state ?? null };
|
|
1158
1205
|
}
|
|
1159
1206
|
/** Mint a human-control URL without changing provider lifecycle or guest
|
|
1160
1207
|
* processes. This is the only join path allowed while a bot turn is active. */
|
|
1161
|
-
export async function
|
|
1162
|
-
cfg =
|
|
1163
|
-
const
|
|
1164
|
-
if (!
|
|
1208
|
+
export async function joinReadyBoat(cfg, botId) {
|
|
1209
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1210
|
+
const boat = await findBoat(cfg, botId);
|
|
1211
|
+
if (!boat)
|
|
1165
1212
|
throw Object.assign(new Error("no computer yet — provision it first"), { status: 409 });
|
|
1166
|
-
if (!READY.has(
|
|
1213
|
+
if (!READY.has(boat.state)) {
|
|
1167
1214
|
throw Object.assign(new Error("the cloud computer is sleeping or starting — interrupt the bot before waking it"), { status: 409 });
|
|
1168
1215
|
}
|
|
1169
|
-
return { joinUrl: await mintDesktopUrl(cfg,
|
|
1216
|
+
return { joinUrl: await mintDesktopUrl(cfg, boat.id), state: boat.state ?? null };
|
|
1170
1217
|
}
|
|
1171
|
-
/** Archive the bot's
|
|
1172
|
-
export async function
|
|
1173
|
-
cfg =
|
|
1174
|
-
const
|
|
1175
|
-
if (!
|
|
1218
|
+
/** Archive the bot's boat now (billing pauses, disk survives). */
|
|
1219
|
+
export async function sleepBoat(cfg, botId) {
|
|
1220
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1221
|
+
const boat = await findBoat(cfg, botId);
|
|
1222
|
+
if (!boat)
|
|
1176
1223
|
throw new Error("no computer for this bot");
|
|
1177
|
-
await
|
|
1178
|
-
|
|
1224
|
+
await stopBoat(cfg, boat.id);
|
|
1225
|
+
forgetBoatId(boat.id);
|
|
1179
1226
|
return { ok: true };
|
|
1180
1227
|
}
|
|
1181
1228
|
/** Owner-scoped shell for the Computer panel's console. */
|
|
1182
|
-
export async function
|
|
1183
|
-
cfg =
|
|
1229
|
+
export async function execOnBoat(cfg, botId, command) {
|
|
1230
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1184
1231
|
if (command.length > MAX_REMOTE_COMMAND_LENGTH) {
|
|
1185
1232
|
throw new RangeError(`command is too long (maximum ${MAX_REMOTE_COMMAND_LENGTH} characters)`);
|
|
1186
1233
|
}
|
|
1187
|
-
const
|
|
1188
|
-
if (!
|
|
1234
|
+
const boat = await findBoat(cfg, botId);
|
|
1235
|
+
if (!boat)
|
|
1189
1236
|
throw new Error("no computer for this bot yet");
|
|
1190
|
-
const ready = await waitReady(cfg,
|
|
1237
|
+
const ready = await waitReady(cfg, boat.id, 60_000);
|
|
1191
1238
|
if (!ready)
|
|
1192
1239
|
throw new Error("box did not wake");
|
|
1193
|
-
const out = await runCommand(cfg,
|
|
1240
|
+
const out = await runCommand(cfg, boat.id, isolatedRemoteCommand(command));
|
|
1194
1241
|
return { exitCode: out.exitCode, stdout: out.stdout.slice(-4000), stderr: out.stderr.slice(-2000) };
|
|
1195
1242
|
}
|
|
1196
1243
|
// Screenshot for the Computer panel + screen-in-chat. Two hops: capture
|
|
1197
|
-
// to a file on the
|
|
1244
|
+
// to a file on the boat (scrot straight to JPEG — no ImageMagick startup
|
|
1198
1245
|
// unless a downscale is actually needed), then read the bytes back.
|
|
1199
1246
|
// Base64 over command stdout is NOT reliable for the panel's full-size
|
|
1200
1247
|
// frames (probed 2026-08-12: an otherwise-complete payload came back with
|
|
@@ -1203,7 +1250,7 @@ export async function execOnBox(cfg, botId, command) {
|
|
|
1203
1250
|
// The frame is for a person: it fills the panel and opens in the chat's
|
|
1204
1251
|
// image viewer, so it keeps the desktop's native size up to 1080p and a
|
|
1205
1252
|
// quality where page text stays legible. (Sizing it is now the only say
|
|
1206
|
-
// OpenMausBot has over any frame off this box: the turn runs on the
|
|
1253
|
+
// OpenMausBot has over any frame off this box: the turn runs on the boat's
|
|
1207
1254
|
// own agent, so the model's own captures never pass through here.) Only
|
|
1208
1255
|
// wider displays are scaled down, with -resize rather than -thumbnail so
|
|
1209
1256
|
// the resample is not the fast-and-blurry kind meant for icons. The
|
|
@@ -1215,7 +1262,7 @@ export const PANEL_FRAME_WIDTH = 1920;
|
|
|
1215
1262
|
export const PANEL_FRAME_QUALITY = 85;
|
|
1216
1263
|
// ffmpeg's -q:v runs 2 (best) to 31; 3 lands near JPEG quality 85.
|
|
1217
1264
|
const PANEL_FRAME_FFMPEG_Q = 3;
|
|
1218
|
-
/** The shell that captures one panel frame on the
|
|
1265
|
+
/** The shell that captures one panel frame on the boat. Exported for tests. */
|
|
1219
1266
|
export function panelShotCommand({ width = PANEL_FRAME_WIDTH, quality = PANEL_FRAME_QUALITY, framePath = PANEL_PATH, nativeSize = false } = {}) {
|
|
1220
1267
|
return [
|
|
1221
1268
|
"export DISPLAY=${DISPLAY:-:0}",
|
|
@@ -1230,18 +1277,18 @@ export function panelShotCommand({ width = PANEL_FRAME_WIDTH, quality = PANEL_FR
|
|
|
1230
1277
|
].join("; ");
|
|
1231
1278
|
}
|
|
1232
1279
|
const SHOT_CMD = panelShotCommand();
|
|
1233
|
-
// A compromised
|
|
1280
|
+
// A compromised boat can answer with an arbitrarily large "frame"; cap what
|
|
1234
1281
|
// the server ever buffers for one (raw bytes, before base64) so a single
|
|
1235
1282
|
// response cannot exhaust memory.
|
|
1236
1283
|
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
1237
1284
|
const FRAME_TOO_LARGE = "the box frame exceeds the 8 MB limit";
|
|
1238
|
-
/** Read a file off the
|
|
1285
|
+
/** Read a file off the boat as base64 — raw artifact bytes when the API
|
|
1239
1286
|
* supports it (33% less transfer, no JSON envelope), else the files API. */
|
|
1240
1287
|
async function readFileBase64(cfg, boxId, path, signal) {
|
|
1241
1288
|
let bytes = null;
|
|
1242
1289
|
let tooLarge = false;
|
|
1243
1290
|
try {
|
|
1244
|
-
const res = await
|
|
1291
|
+
const res = await boatFetch(cfg, `/boxes/${boxId}/artifacts?path=${encodeURIComponent(path)}`, { signal });
|
|
1245
1292
|
if (res.ok) {
|
|
1246
1293
|
const declaredLength = res.headers.get("content-length");
|
|
1247
1294
|
if (declaredLength !== null && Number(declaredLength) > MAX_FRAME_BYTES)
|
|
@@ -1259,7 +1306,7 @@ async function readFileBase64(cfg, boxId, path, signal) {
|
|
|
1259
1306
|
if (bytes?.length)
|
|
1260
1307
|
return bytes.toString("base64");
|
|
1261
1308
|
signal?.throwIfAborted();
|
|
1262
|
-
const { ok, body } = await
|
|
1309
|
+
const { ok, body } = await boatJson(cfg, `/boxes/${boxId}/files?path=${encodeURIComponent(path)}&encoding=base64`, { signal });
|
|
1263
1310
|
const content = body?.content;
|
|
1264
1311
|
if (ok && typeof content === "string" && content) {
|
|
1265
1312
|
if (Buffer.byteLength(content, "base64") > MAX_FRAME_BYTES)
|
|
@@ -1268,18 +1315,18 @@ async function readFileBase64(cfg, boxId, path, signal) {
|
|
|
1268
1315
|
}
|
|
1269
1316
|
return null;
|
|
1270
1317
|
}
|
|
1271
|
-
/** `
|
|
1318
|
+
/** `knownBoatId` skips boat resolution entirely — the screen poller holds
|
|
1272
1319
|
* the id for the whole turn and must not re-resolve it every frame. */
|
|
1273
|
-
export async function
|
|
1274
|
-
cfg =
|
|
1275
|
-
let boxId =
|
|
1320
|
+
export async function screenshotBoat(cfg, botId, knownBoatId, options) {
|
|
1321
|
+
cfg = snapshotBoatConfig(cfg);
|
|
1322
|
+
let boxId = knownBoatId;
|
|
1276
1323
|
if (!boxId) {
|
|
1277
|
-
const
|
|
1278
|
-
if (!
|
|
1324
|
+
const boat = await findBoat(cfg, botId);
|
|
1325
|
+
if (!boat)
|
|
1279
1326
|
throw new Error("no computer for this bot yet");
|
|
1280
|
-
if (!READY.has(
|
|
1281
|
-
throw new Error(`box is ${
|
|
1282
|
-
boxId =
|
|
1327
|
+
if (!READY.has(boat.state))
|
|
1328
|
+
throw new Error(`box is ${boat.state}`);
|
|
1329
|
+
boxId = boat.id;
|
|
1283
1330
|
}
|
|
1284
1331
|
const framePath = options?.nativeSize ? PANEL_PATH + ".model.jpg" : PANEL_PATH;
|
|
1285
1332
|
const out = await runCommand(cfg, boxId, options?.nativeSize ? panelShotCommand({ framePath, nativeSize: true }) : SHOT_CMD, { timeoutMs: 60_000, signal: options?.signal });
|