@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.
@@ -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.7",
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
+ }