@botbuddy/cli 1.59.7 → 1.59.9
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +7 -0
- package/daemon-release/linux-x64.json +7 -0
- package/daemon-release/manifest.json +22 -0
- package/package.json +3 -1
- package/src/api.mjs +62 -0
- package/src/commands.mjs +6 -0
- package/src/daemon/approval-wait.mjs +126 -0
- package/src/daemon/bootstrap-resume.mjs +151 -0
- package/src/daemon/bootstrap.mjs +386 -0
- package/src/daemon/commands.mjs +478 -0
- package/src/daemon/enrollment.mjs +483 -0
- package/src/daemon/entrypoint.mjs +14 -0
- package/src/daemon/installer.mjs +438 -0
- package/src/daemon/profile-delivery.mjs +332 -0
- package/src/daemon/release.mjs +81 -0
- package/src/daemon/runtime-config.mjs +85 -0
- package/src/daemon/service.mjs +38 -0
- package/src/oauth-device.mjs +11 -0
- package/src/publish-native-guards.mjs +44 -0
- package/src/wait-core.mjs +63 -18
- package/src/wait.mjs +4 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"release_version": "1.59.9",
|
|
3
|
+
"source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
|
|
4
|
+
"source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
|
|
5
|
+
"target": "darwin-arm64",
|
|
6
|
+
"sha256": "ad8d7b6e3255d85f1c1237ae1b6bbd0a6b359b7909fba60b4819566bd7362c31"
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"release_version": "1.59.9",
|
|
3
|
+
"source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
|
|
4
|
+
"source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
|
|
5
|
+
"target": "linux-x64",
|
|
6
|
+
"sha256": "bf2f3a46ef151ace3e5b21633c03dc1824bee2e42fc6edd8f6898ca35d48f10c"
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"release_version": "1.59.9",
|
|
4
|
+
"source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
|
|
5
|
+
"source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
|
|
6
|
+
"targets": {
|
|
7
|
+
"darwin-arm64": {
|
|
8
|
+
"goos": "darwin",
|
|
9
|
+
"goarch": "arm64",
|
|
10
|
+
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
+
"sha256": "ad8d7b6e3255d85f1c1237ae1b6bbd0a6b359b7909fba60b4819566bd7362c31",
|
|
12
|
+
"mode": "0755"
|
|
13
|
+
},
|
|
14
|
+
"linux-x64": {
|
|
15
|
+
"goos": "linux",
|
|
16
|
+
"goarch": "amd64",
|
|
17
|
+
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
+
"sha256": "bf2f3a46ef151ace3e5b21633c03dc1824bee2e42fc6edd8f6898ca35d48f10c",
|
|
19
|
+
"mode": "0755"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.59.
|
|
3
|
+
"version": "1.59.9",
|
|
4
4
|
"description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"bin/",
|
|
19
|
+
"daemon/",
|
|
20
|
+
"daemon-release/",
|
|
19
21
|
"src/",
|
|
20
22
|
"!src/**/*.test.mjs"
|
|
21
23
|
],
|
package/src/api.mjs
CHANGED
|
@@ -392,6 +392,68 @@ function toolEndpoint(tenant) {
|
|
|
392
392
|
return url.toString();
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
// The Helper control plane is deliberately owner-wide. It is a REST sibling of
|
|
396
|
+
// MCP's JSON-RPC endpoint, not an Edge admin shortcut: it authenticates through
|
|
397
|
+
// the same owner/agent resolver, rejects every tenant selector server-side, and
|
|
398
|
+
// applies its own host-owner fence. Keep its URL construction exact so a caller
|
|
399
|
+
// cannot silently redirect registration to a generic functions route.
|
|
400
|
+
export function containerHostRegisterEndpoint(serverUrl = SERVER_URL) {
|
|
401
|
+
let url;
|
|
402
|
+
try { url = new URL(serverUrl); } catch { throw new Error("container_host_register_endpoint_invalid"); }
|
|
403
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
404
|
+
!/^(https:|http:)$/.test(url.protocol) ||
|
|
405
|
+
url.pathname.replace(/\/$/, "") !== "/functions/v1/mcp-server") {
|
|
406
|
+
throw new Error("container_host_register_endpoint_invalid");
|
|
407
|
+
}
|
|
408
|
+
url.pathname = "/functions/v1/mcp-server/container-hosts/register";
|
|
409
|
+
return url.toString();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Programmatic client for the source-backed `POST mcp-server/container-hosts/register`
|
|
414
|
+
* contract. It never adds a tenant pin and never manufactures an Edge user JWT.
|
|
415
|
+
*/
|
|
416
|
+
export async function registerContainerHost(body, {
|
|
417
|
+
fetchImpl = fetch,
|
|
418
|
+
auth: pinnedAuth = null,
|
|
419
|
+
resolveAuth = resolveCallAuth,
|
|
420
|
+
serverUrl = SERVER_URL,
|
|
421
|
+
signal,
|
|
422
|
+
} = {}) {
|
|
423
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || typeof fetchImpl !== "function") {
|
|
424
|
+
return { ok: false, error: "invalid_request" };
|
|
425
|
+
}
|
|
426
|
+
let endpoint;
|
|
427
|
+
try { endpoint = containerHostRegisterEndpoint(serverUrl); }
|
|
428
|
+
catch { return { ok: false, error: "endpoint_invalid" }; }
|
|
429
|
+
let auth = pinnedAuth;
|
|
430
|
+
if (!auth) {
|
|
431
|
+
const resolved = await resolveAuth({ toolName: "container_host_register", preferSession: false });
|
|
432
|
+
if (resolved?.error || !resolved?.auth) return { ok: false, auth: true, error: resolved?.error?.error ?? "not_authenticated" };
|
|
433
|
+
auth = resolved.auth;
|
|
434
|
+
}
|
|
435
|
+
let response;
|
|
436
|
+
try {
|
|
437
|
+
response = await fetchImpl(endpoint, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { "Content-Type": "application/json", ...auth },
|
|
440
|
+
body: JSON.stringify(body),
|
|
441
|
+
signal,
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
return { ok: false, error: "transport" };
|
|
445
|
+
}
|
|
446
|
+
let data;
|
|
447
|
+
try { data = await response.json(); } catch { return { ok: false, status: response.status, error: "invalid_json" }; }
|
|
448
|
+
if (response.status === 401 || response.status === 403) {
|
|
449
|
+
return { ok: false, auth: true, status: response.status, error: typeof data?.code === "string" ? data.code : "unauthorized" };
|
|
450
|
+
}
|
|
451
|
+
if (response.status < 200 || response.status > 299) {
|
|
452
|
+
return { ok: false, status: response.status, error: typeof data?.code === "string" ? data.code : `http_${response.status}` };
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, data };
|
|
455
|
+
}
|
|
456
|
+
|
|
395
457
|
export async function callToolJson(toolName, args = {}, {
|
|
396
458
|
fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin,
|
|
397
459
|
requireSessionToken = false, resolveAuth = resolveCallAuth,
|
package/src/commands.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { readAgentState } from "./agent-state.mjs";
|
|
|
37
37
|
import { runUsage, usageStatus } from "./usage/sync.mjs";
|
|
38
38
|
import { cmdTestIngest, doctorTestIngest, formatTestIngestLine } from "./test-ingest.mjs";
|
|
39
39
|
import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
|
|
40
|
+
import { cmdDaemon } from "./daemon/commands.mjs";
|
|
40
41
|
|
|
41
42
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
42
43
|
// invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
|
|
@@ -100,6 +101,7 @@ export async function run(argv, {
|
|
|
100
101
|
case "stack": return cmdStack(args);
|
|
101
102
|
case "allocation": return managedAllocation(args);
|
|
102
103
|
case "docker": return cmdDocker(args);
|
|
104
|
+
case "daemon": return cmdDaemon(args);
|
|
103
105
|
case "run": return cmdRun(args);
|
|
104
106
|
case "telemetry": return cmdTelemetry(args);
|
|
105
107
|
// BOT-803: local Claude/Codex token usage → usage-ingest (fail-open).
|
|
@@ -232,6 +234,10 @@ ${bold("DOCKER HYGIENE")}
|
|
|
232
234
|
docker preflight [options] Refuse a managed-stack start before network pressure exhausts the host
|
|
233
235
|
docker help Full explicit-context safety contract
|
|
234
236
|
|
|
237
|
+
${bold("GO DAEMON")}
|
|
238
|
+
daemon <render|install|status|enroll|bootstrap> …
|
|
239
|
+
Verify and manage the native Go daemon service
|
|
240
|
+
|
|
235
241
|
${bold("DURABLE WORKLOADS")}
|
|
236
242
|
run --session-id <id> --environment <env> -- <command>
|
|
237
243
|
Launch a receipt-bearing command under a detached owner
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// BOT-2324/BOT-2057: bridge a fresh-host approval to the existing pushed
|
|
2
|
+
// `bb wait` protocol. This module never polls and treats the SSE wake as a
|
|
3
|
+
// prompt to re-register, not as authority to issue a device profile.
|
|
4
|
+
import { spawn as spawnChild } from "node:child_process";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { EXIT } from "../wait-core.mjs";
|
|
9
|
+
|
|
10
|
+
const HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
11
|
+
const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
12
|
+
const MAX_WAIT_RECEIPT_BYTES = 128 * 1024;
|
|
13
|
+
const BOTBUDDY_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "botbuddy.mjs");
|
|
14
|
+
|
|
15
|
+
const fail = (code) => { throw new Error(code); };
|
|
16
|
+
|
|
17
|
+
function checkedHostKey(hostKey) {
|
|
18
|
+
if (typeof hostKey !== "string" || !HOST_KEY_RE.test(hostKey)) fail("daemon_bootstrap_approval_wait_invalid");
|
|
19
|
+
return hostKey;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkedTimeout(timeoutSeconds) {
|
|
23
|
+
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3_600) {
|
|
24
|
+
fail("daemon_bootstrap_approval_wait_invalid");
|
|
25
|
+
}
|
|
26
|
+
return timeoutSeconds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The exact raw wait selector published by BOT-2324. */
|
|
30
|
+
export function hostApprovalWaitCondition(hostKey) {
|
|
31
|
+
return `event:type=host_approval,subject=${checkedHostKey(hostKey)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fixed-argv call into this CLI's existing pushed wait client; never a sleep/poll loop. */
|
|
35
|
+
export function defaultRunHostApprovalWait(condition, timeoutSeconds, { spawnImpl = spawnChild } = {}) {
|
|
36
|
+
if (typeof condition !== "string" || !/^event:type=host_approval,subject=bbh-[0-9a-f]{24}$/.test(condition)) {
|
|
37
|
+
return Promise.resolve({ exitCode: EXIT.INVALID, receipt: null, error: "invalid_condition" });
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3_600) {
|
|
40
|
+
return Promise.resolve({ exitCode: EXIT.INVALID, receipt: null, error: "invalid_timeout" });
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed Node executable and checked local CLI argv.
|
|
44
|
+
const child = spawnImpl(process.execPath, [BOTBUDDY_BIN, "wait", condition, "--timeout", String(timeoutSeconds), "--allow-short-timeout", "host approval is bounded by the enrollment deadline"], {
|
|
45
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
46
|
+
});
|
|
47
|
+
let settled = false;
|
|
48
|
+
let output = "";
|
|
49
|
+
let overflow = false;
|
|
50
|
+
const finish = (result) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
resolve(result);
|
|
54
|
+
};
|
|
55
|
+
child.stdout.on("data", (chunk) => {
|
|
56
|
+
if (overflow) return;
|
|
57
|
+
output += String(chunk);
|
|
58
|
+
if (Buffer.byteLength(output, "utf8") > MAX_WAIT_RECEIPT_BYTES) {
|
|
59
|
+
overflow = true;
|
|
60
|
+
child.kill();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
child.once("error", () => finish({ exitCode: EXIT.INTERNAL, receipt: null, error: "wait_spawn_failed" }));
|
|
64
|
+
child.once("close", (code) => {
|
|
65
|
+
if (overflow) return finish({ exitCode: EXIT.INTERNAL, receipt: null, error: "wait_receipt_too_large" });
|
|
66
|
+
const line = output.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
67
|
+
let receipt = null;
|
|
68
|
+
try { receipt = line ? JSON.parse(line) : null; } catch { /* typed below as invalid evidence */ }
|
|
69
|
+
finish({ exitCode: Number.isInteger(code) ? code : EXIT.INTERNAL, receipt });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function receiptMatchesExactHostApproval(receipt, hostKey) {
|
|
75
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt) ||
|
|
76
|
+
receipt.schema_version !== 1 || receipt.outcome !== "matched" || !Array.isArray(receipt.matched) ||
|
|
77
|
+
receipt.matched.length !== 1) return false;
|
|
78
|
+
const [match] = receipt.matched;
|
|
79
|
+
return match && typeof match === "object" && !Array.isArray(match) &&
|
|
80
|
+
match.signal_type === "host_approval" && match.subject_key === hostKey &&
|
|
81
|
+
exactApprovalPayload(match.payload);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exactApprovalPayload(payload) {
|
|
85
|
+
const keys = ["settings_version", "policy_version", "plan_id", "source"];
|
|
86
|
+
return payload && typeof payload === "object" && !Array.isArray(payload) &&
|
|
87
|
+
Object.keys(payload).length === keys.length && keys.every((key) => Object.hasOwn(payload, key)) &&
|
|
88
|
+
Number.isSafeInteger(payload.settings_version) && payload.settings_version >= 1 && payload.settings_version <= 2_147_483_647 &&
|
|
89
|
+
Number.isSafeInteger(payload.policy_version) && payload.policy_version >= 1 && payload.policy_version <= 2_147_483_647 &&
|
|
90
|
+
typeof payload.plan_id === "string" && CANONICAL_UUID_RE.test(payload.plan_id) &&
|
|
91
|
+
(payload.source === "arm" || payload.source === "state");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function receiptIsCancelled(receipt) {
|
|
95
|
+
return receipt && typeof receipt === "object" && !Array.isArray(receipt) &&
|
|
96
|
+
receipt.schema_version === 1 && receipt.outcome === "cancelled";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert a real `bb wait` terminal receipt into bootstrap's narrow wake seam.
|
|
101
|
+
* A match merely permits the required post-wake registration re-check in
|
|
102
|
+
* bootstrap.mjs; it never itself conveys profile-issuance authority.
|
|
103
|
+
*/
|
|
104
|
+
export async function awaitHostApproval({ hostKey, timeoutSeconds } = {}, { runWait = defaultRunHostApprovalWait } = {}) {
|
|
105
|
+
const exactHostKey = checkedHostKey(hostKey);
|
|
106
|
+
const boundedTimeout = checkedTimeout(timeoutSeconds);
|
|
107
|
+
if (typeof runWait !== "function") fail("daemon_bootstrap_approval_wait_invalid");
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await runWait(hostApprovalWaitCondition(exactHostKey), boundedTimeout);
|
|
111
|
+
} catch {
|
|
112
|
+
fail("daemon_bootstrap_approval_wait_failed");
|
|
113
|
+
}
|
|
114
|
+
if (!result || typeof result !== "object" || !Number.isInteger(result.exitCode)) {
|
|
115
|
+
fail("daemon_bootstrap_approval_wait_failed");
|
|
116
|
+
}
|
|
117
|
+
if (result.exitCode === EXIT.MATCHED) {
|
|
118
|
+
// `bb wait cancel` deliberately exits 0 for its own successful local
|
|
119
|
+
// operation. Inspect the receipt before treating a zero as a policy wake.
|
|
120
|
+
if (receiptIsCancelled(result.receipt)) return { state: "cancelled" };
|
|
121
|
+
if (!receiptMatchesExactHostApproval(result.receipt, exactHostKey)) fail("daemon_bootstrap_approval_wait_invalid");
|
|
122
|
+
return { state: "approved" };
|
|
123
|
+
}
|
|
124
|
+
if (result.exitCode === EXIT.TIMEOUT) return { state: "expired" };
|
|
125
|
+
fail("daemon_bootstrap_approval_wait_failed");
|
|
126
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// BOT-2057: requester-local, non-secret state that lets an operator resume an
|
|
2
|
+
// approved fresh target without creating another target P-256 identity. This is
|
|
3
|
+
// deliberately metadata only; target private material and device credentials
|
|
4
|
+
// never pass through or persist here.
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { chmod, link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
10
|
+
const PATH_RE = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
|
|
11
|
+
const RECORD_KEYS = Object.freeze([
|
|
12
|
+
"schema_version", "host_key", "public_key_spki_sha256", "server_url", "ssh_host", "service_user",
|
|
13
|
+
"daemon_binary", "profile_path", "requesting_host", "runtime_engine_id", "platform", "arch",
|
|
14
|
+
"release_root", "home", "config", "approval_timeout_seconds", "profile_phase",
|
|
15
|
+
]);
|
|
16
|
+
const fail = (code) => { throw new Error(code); };
|
|
17
|
+
|
|
18
|
+
function exactObject(value) {
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value) &&
|
|
20
|
+
Object.keys(value).length === RECORD_KEYS.length && RECORD_KEYS.every((key) => Object.hasOwn(value, key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validRecord(value) {
|
|
24
|
+
return exactObject(value) &&
|
|
25
|
+
value.schema_version === 2 && typeof value.host_key === "string" && HOST_KEY_RE.test(value.host_key) &&
|
|
26
|
+
typeof value.public_key_spki_sha256 === "string" && /^sha256:[0-9a-f]{64}$/.test(value.public_key_spki_sha256) &&
|
|
27
|
+
value.server_url === "https://xymjddoobrfnsmawmtbc.supabase.co/functions/v1/mcp-server" &&
|
|
28
|
+
typeof value.ssh_host === "string" && /^[A-Za-z0-9._-]+(?:@[A-Za-z0-9._-]+)?$/.test(value.ssh_host) &&
|
|
29
|
+
typeof value.service_user === "string" && /^[a-z_][a-z0-9_-]*$/i.test(value.service_user) && value.service_user !== "root" &&
|
|
30
|
+
[value.daemon_binary, value.profile_path, value.release_root, value.home, value.config].every((path) => typeof path === "string" && PATH_RE.test(path)) &&
|
|
31
|
+
value.profile_path.endsWith("/profile.json") &&
|
|
32
|
+
typeof value.requesting_host === "string" && value.requesting_host.length > 0 && Buffer.byteLength(value.requesting_host, "utf8") <= 80 &&
|
|
33
|
+
typeof value.runtime_engine_id === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(value.runtime_engine_id) &&
|
|
34
|
+
["darwin", "linux"].includes(value.platform) && typeof value.arch === "string" && value.arch.length > 0 &&
|
|
35
|
+
Number.isSafeInteger(value.approval_timeout_seconds) && value.approval_timeout_seconds >= 1 && value.approval_timeout_seconds <= 3_600 &&
|
|
36
|
+
["pending", "profile_delivered"].includes(value.profile_phase);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sameCustodyRecord(left, right) {
|
|
40
|
+
return RECORD_KEYS.filter((key) => key !== "profile_phase").every((key) => left[key] === right[key]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPermittedPhaseTransition(existing, next) {
|
|
44
|
+
return existing.profile_phase === next.profile_phase ||
|
|
45
|
+
(existing.profile_phase === "pending" && next.profile_phase === "profile_delivered");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function checkedDirectory(path, code = "daemon_bootstrap_resume_unsafe_root") {
|
|
49
|
+
const stat = await lstat(path).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
50
|
+
if (!stat) return false;
|
|
51
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail(code);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function checkedPrivateDirectory(path) {
|
|
56
|
+
const stat = await lstat(path).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
57
|
+
if (!stat) return false;
|
|
58
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) fail("daemon_bootstrap_resume_unsafe_root");
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readRecord(path, missingCode) {
|
|
63
|
+
let stat;
|
|
64
|
+
try { stat = await lstat(path); } catch (error) {
|
|
65
|
+
if (error?.code === "ENOENT") fail(missingCode);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) fail("daemon_bootstrap_resume_invalid");
|
|
69
|
+
let parsed;
|
|
70
|
+
try { parsed = JSON.parse(await readFile(path, "utf8")); } catch { fail("daemon_bootstrap_resume_invalid"); }
|
|
71
|
+
if (!validRecord(parsed)) fail("daemon_bootstrap_resume_invalid");
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createBootstrapResumeStore({ cwd = process.cwd() } = {}) {
|
|
76
|
+
const root = resolve(cwd, ".botbuddy", "daemon-bootstrap");
|
|
77
|
+
const base = resolve(cwd);
|
|
78
|
+
if (!root.startsWith(`${base}/`)) fail("daemon_bootstrap_resume_unsafe_root");
|
|
79
|
+
const recordPath = (hostKey) => {
|
|
80
|
+
if (typeof hostKey !== "string" || !HOST_KEY_RE.test(hostKey)) fail("daemon_bootstrap_resume_invalid");
|
|
81
|
+
return join(root, `${hostKey}.json`);
|
|
82
|
+
};
|
|
83
|
+
const ensureRoot = async () => {
|
|
84
|
+
const parent = join(base, ".botbuddy");
|
|
85
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
86
|
+
// `chmod` follows a symlink. Verify the existing requester root before
|
|
87
|
+
// correcting its mode so a hostile cwd cannot redirect that operation.
|
|
88
|
+
await checkedDirectory(parent);
|
|
89
|
+
await chmod(parent, 0o700);
|
|
90
|
+
await checkedPrivateDirectory(parent);
|
|
91
|
+
if (!await checkedPrivateDirectory(root)) {
|
|
92
|
+
await mkdir(root, { mode: 0o700 });
|
|
93
|
+
await chmod(root, 0o700);
|
|
94
|
+
}
|
|
95
|
+
await checkedPrivateDirectory(root);
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
async save(record) {
|
|
99
|
+
if (!validRecord(record)) fail("daemon_bootstrap_resume_invalid");
|
|
100
|
+
await ensureRoot();
|
|
101
|
+
const finalPath = recordPath(record.host_key);
|
|
102
|
+
let replacePending = false;
|
|
103
|
+
try {
|
|
104
|
+
const existing = await readRecord(finalPath, "daemon_bootstrap_resume_missing");
|
|
105
|
+
if (!sameCustodyRecord(existing, record) || !isPermittedPhaseTransition(existing, record)) {
|
|
106
|
+
fail("daemon_bootstrap_resume_conflict");
|
|
107
|
+
}
|
|
108
|
+
if (existing.profile_phase === record.profile_phase) return existing;
|
|
109
|
+
replacePending = true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.message !== "daemon_bootstrap_resume_missing") throw error;
|
|
112
|
+
}
|
|
113
|
+
const temporary = join(root, `.${record.host_key}.${randomUUID()}.tmp`);
|
|
114
|
+
try {
|
|
115
|
+
await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
116
|
+
if (replacePending) {
|
|
117
|
+
// A phase may advance only after the existing private, regular record
|
|
118
|
+
// was bound to the same target/spec above. Rename makes the completed
|
|
119
|
+
// profile marker visible atomically; no reverse transition exists.
|
|
120
|
+
await rename(temporary, finalPath);
|
|
121
|
+
return record;
|
|
122
|
+
}
|
|
123
|
+
await link(temporary, finalPath);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === "EEXIST") {
|
|
126
|
+
const existing = await readRecord(finalPath, "daemon_bootstrap_resume_missing");
|
|
127
|
+
if (!sameCustodyRecord(existing, record) || !isPermittedPhaseTransition(existing, record)) {
|
|
128
|
+
fail("daemon_bootstrap_resume_conflict");
|
|
129
|
+
}
|
|
130
|
+
if (existing.profile_phase !== record.profile_phase) fail("daemon_bootstrap_resume_conflict");
|
|
131
|
+
return existing;
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
return record;
|
|
138
|
+
},
|
|
139
|
+
async load(hostKey) {
|
|
140
|
+
await ensureRoot();
|
|
141
|
+
return readRecord(recordPath(hostKey), "daemon_bootstrap_resume_missing");
|
|
142
|
+
},
|
|
143
|
+
async remove(hostKey) {
|
|
144
|
+
await ensureRoot();
|
|
145
|
+
const target = recordPath(hostKey);
|
|
146
|
+
const record = await readRecord(target, "daemon_bootstrap_resume_missing");
|
|
147
|
+
await rm(target, { force: false });
|
|
148
|
+
return record;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|