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