@botbuddy/cli 1.48.0 → 1.49.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -30,6 +30,7 @@ import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation
30
30
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
31
31
  import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repairFailureRecovery } from "./agent-doctor.mjs";
32
32
  import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } from "./docker-publish-ip.mjs";
33
+ import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
33
34
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
34
35
  import { runUsage, usageStatus } from "./usage/sync.mjs";
35
36
  import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
@@ -83,6 +84,8 @@ export async function run(argv, {
83
84
  case "logout": return cmdLogout();
84
85
  case "status": return cmdStatus();
85
86
  case "doctor": return cmdDoctor(args);
87
+ // BOT-2367: this Mac's Helper host key, whatever the status file's age.
88
+ case "host": return cmdHost(args);
86
89
  case "update": return cmdUpdate();
87
90
  // Agent-only commands (used by MCP agents, not humans)
88
91
  case "heartbeat": return cmdHeartbeat(args);
@@ -188,7 +191,9 @@ ${bold("AUTH")}
188
191
  logout Remove saved credentials
189
192
  status Show current auth status (local metadata + server check)
190
193
  doctor [--fix] [--print-exports] [--json]
191
- Diagnose or repair the cached agent session
194
+ Diagnose or repair the cached agent session; on macOS also
195
+ reports duplicate Helper workers (--fix terminates them)
196
+ host key [--json] This Mac's Helper host key, whatever the status file's age
192
197
 
193
198
  ${bold("MCP CONFIG KEY")}
194
199
  mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
@@ -257,6 +262,8 @@ export async function cmdDoctor(args, {
257
262
  log = (line) => console.log(line), errorLog = (line) => console.error(line),
258
263
  doctor = doctorAgentAuth, usageReport = doctorUsageSync, bootstrapReport = doctorBootstrapState,
259
264
  publishReport = () => probeEnginePublishIp(),
265
+ helperWorkersReport = async () => inspectHelperWorkers(),
266
+ helperWorkersFix = (report) => fixHelperWorkers(report),
260
267
  } = {}) {
261
268
  const parsed = parseDoctorArgs(args);
262
269
  if (parsed.help) { log(doctorHelp()); return 0; }
@@ -271,8 +278,18 @@ export async function cmdDoctor(args, {
271
278
  const usage = await usageReport().catch(() => null);
272
279
  // BOT-1945 (AC-3): informational, like usage sync — never changes the exit code.
273
280
  const bootstrap = await bootstrapReport().catch(() => null);
281
+ // BOT-2367: duplicate Helper workers / stale Helper locks (macOS). Reported on
282
+ // every run; `--fix` SIGTERMs a duplicate launchd is not running, then
283
+ // re-checks. Informational: never changes doctor's exit code.
284
+ let helperWorkers = await helperWorkersReport().catch(() => null);
285
+ let helperFix = null;
286
+ if (parsed.fix && helperWorkers?.status === "duplicate") {
287
+ helperFix = await helperWorkersFix(helperWorkers).catch(() => null);
288
+ const recheck = await helperWorkersReport().catch(() => null);
289
+ helperWorkers = recheck ? { ...recheck, fix: helperFix } : { ...helperWorkers, fix: helperFix };
290
+ }
274
291
  if (parsed.json) {
275
- log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}) }));
292
+ log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}) }));
276
293
  } else {
277
294
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
278
295
  log(`client key: ${result.report.client_key.status}`);
@@ -288,6 +305,7 @@ export async function cmdDoctor(args, {
288
305
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
289
306
  if (bootstrap) log(formatBootstrapLine(bootstrap));
290
307
  if (usage) log(formatUsageSyncLine(usage));
308
+ for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
291
309
  }
292
310
  if (result.exports) log(result.exports);
293
311
  return result.exitCode;
@@ -0,0 +1,223 @@
1
+ // BOT-2367 — this Mac's BotBuddy Helper host key, and the `bb doctor` Helper
2
+ // duplicate-worker / stale-lock check.
3
+ //
4
+ // The Helper writes `managed-container-host-status.json` with this machine's
5
+ // `host_key` and `checked_at` (Apple-epoch seconds; there is no `updated_at`).
6
+ // The host key is derived from the machine's P-256 identity and is static, so
7
+ // it never depends on the file's age: `bb host key` returns it whatever the
8
+ // age and reports freshness separately. On 2026-10-14 a 7-minute Helper job
9
+ // tick aged the file past Supply Guard's 300 s limit on a healthy host and SG
10
+ // refused to name the host (exit 4); Helper 0.2.37 refreshes the file on every
11
+ // beacon, and this command gives wrappers a resolver that cannot fail that way.
12
+ //
13
+ // Read-only except `fixHelperWorkers`, which only SIGTERMs a
14
+ // `botbuddyhelper-worker` process launchd is not running, and only when the
15
+ // launchd PID was read twice and agreed (fail-closed otherwise).
16
+ import { spawnSync } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+ import { homedir, platform } from "node:os";
19
+ import { basename, join } from "node:path";
20
+
21
+ export const HELPER_WORKER_LABEL = "com.botbuddy.helper.worker";
22
+ export const HELPER_WORKER_BINARY = "botbuddyhelper-worker";
23
+ /** Supply Guard's freshness limit; Helper 0.2.37 refreshes every ~20 s. */
24
+ export const HELPER_STATUS_FRESH_SECONDS = 300;
25
+ const APPLE_REFERENCE_EPOCH_SECONDS = 978_307_200;
26
+ const HOST_KEY_PATTERN = /^[A-Za-z0-9._:-]+$/;
27
+ const HELPER_LOCK_FILES = [".worker.lock", ".beacon.lock", ".metrics.lock"];
28
+ const START_REMEDY = "start the BotBuddy Helper (open \"BotBuddy Helper.app\") so it writes this file";
29
+
30
+ export function helperSupportDir(home = homedir()) {
31
+ return join(home, "Library", "Application Support", "BotBuddyHelper");
32
+ }
33
+
34
+ export function helperStatusPath(home = homedir()) {
35
+ return join(helperSupportDir(home), "managed-container-host-status.json");
36
+ }
37
+
38
+ /**
39
+ * `{ ok: true, host_key, checked_at, age_seconds, fresh, path }`, or
40
+ * `{ ok: false, code, path, message }` when the file is missing or names no
41
+ * well-formed host key. Freshness never decides whether the key is returned.
42
+ */
43
+ export function readHelperHostKey({ home = homedir(), nowMs = Date.now(), readFile = (p) => readFileSync(p, "utf8") } = {}) {
44
+ const path = helperStatusPath(home);
45
+ let contents;
46
+ try {
47
+ contents = readFile(path);
48
+ } catch (error) {
49
+ return { ok: false, code: "HELPER_STATUS_MISSING", path, message: `could not read ${path} (${error?.code ?? "unreadable"}); ${START_REMEDY}` };
50
+ }
51
+ let status;
52
+ try {
53
+ status = JSON.parse(contents);
54
+ } catch {
55
+ status = null;
56
+ }
57
+ const hostKey = status?.host_key;
58
+ if (typeof hostKey !== "string" || !HOST_KEY_PATTERN.test(hostKey)) {
59
+ return { ok: false, code: "HELPER_STATUS_MALFORMED", path, message: `${path} names no well-formed host_key; ${START_REMEDY}` };
60
+ }
61
+ const checkedAt = status.checked_at;
62
+ if (!Number.isFinite(checkedAt)) {
63
+ return { ok: true, host_key: hostKey, checked_at: null, age_seconds: null, fresh: false, path };
64
+ }
65
+ const checkedAtMs = (checkedAt + APPLE_REFERENCE_EPOCH_SECONDS) * 1000;
66
+ const ageSeconds = Math.round((nowMs - checkedAtMs) / 1000);
67
+ return {
68
+ ok: true,
69
+ host_key: hostKey,
70
+ checked_at: new Date(checkedAtMs).toISOString(),
71
+ age_seconds: ageSeconds,
72
+ fresh: ageSeconds >= -60 && ageSeconds <= HELPER_STATUS_FRESH_SECONDS,
73
+ path,
74
+ };
75
+ }
76
+
77
+ const HOST_USAGE = "Usage: bb host key [--json]";
78
+
79
+ export function hostHelp() {
80
+ return `${HOST_USAGE}
81
+
82
+ Prints this Mac's managed-container host key from the BotBuddy Helper's status
83
+ file, whatever the file's age: the key is this machine's static identity. A file
84
+ older than ${HELPER_STATUS_FRESH_SECONDS} s still prints the key (exit 0) with a freshness warning on
85
+ stderr. Exit 4 when the file is missing or names no host key.
86
+ --json prints {host_key, checked_at, age_seconds, fresh, path}.`;
87
+ }
88
+
89
+ /** `bb host key [--json]` → exit code. */
90
+ export async function cmdHost(args, {
91
+ log = (line) => console.log(line), errorLog = (line) => console.error(line),
92
+ read = () => readHelperHostKey(),
93
+ } = {}) {
94
+ const [sub, ...rest] = args;
95
+ if (sub === "help" || sub === "--help" || sub === "-h") { log(hostHelp()); return 0; }
96
+ const json = rest.includes("--json");
97
+ const unknown = rest.filter((a) => a !== "--json");
98
+ if (sub !== "key" || unknown.length) { errorLog(HOST_USAGE); return 4; }
99
+ const r = read();
100
+ if (!r.ok) {
101
+ errorLog(`bb host key: ${r.code}: ${r.message}`);
102
+ return 4;
103
+ }
104
+ if (json) {
105
+ log(JSON.stringify({ host_key: r.host_key, checked_at: r.checked_at, age_seconds: r.age_seconds, fresh: r.fresh, path: r.path }));
106
+ } else {
107
+ log(r.host_key);
108
+ }
109
+ if (!r.fresh) {
110
+ const age = r.age_seconds == null ? "has no checked_at" : `is ${r.age_seconds} s old`;
111
+ errorLog(`bb host key: warning: the Helper status file ${age} (fresh is ≤ ${HELPER_STATUS_FRESH_SECONDS} s); the key is still this Mac's. ` +
112
+ `If the Helper is not running, check \`launchctl print gui/$(id -u)/${HELPER_WORKER_LABEL}\` and the worker log.`);
113
+ }
114
+ return 0;
115
+ }
116
+
117
+ // ── bb doctor: duplicate Helper workers and stale Helper locks ───────────────
118
+
119
+ function defaultRun(cmd, args) {
120
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 });
121
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
122
+ }
123
+
124
+ /** `launchctl list <label>` → pid number, null (loaded, not running), or undefined (unreadable). */
125
+ export function parseLaunchctlListPid(result) {
126
+ if (!result || result.status !== 0) return undefined;
127
+ const m = /"PID"\s*=\s*(\d+)\s*;/.exec(result.stdout);
128
+ return m ? Number(m[1]) : null;
129
+ }
130
+
131
+ /** `ps -axo pid=,comm=` → the pids of `botbuddyhelper-worker` processes. */
132
+ export function parseWorkerPids(stdout) {
133
+ const pids = [];
134
+ for (const line of String(stdout).split("\n")) {
135
+ const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
136
+ if (m && basename(m[2]) === HELPER_WORKER_BINARY) pids.push(Number(m[1]));
137
+ }
138
+ return pids.sort((a, b) => a - b);
139
+ }
140
+
141
+ /** `lsof -F pcn` → Helper lock files held open by a process that is not a Helper process. */
142
+ export function parseStaleLocks(stdout) {
143
+ const stale = [];
144
+ let pid = null; let command = null;
145
+ for (const line of String(stdout).split("\n")) {
146
+ const tag = line[0]; const value = line.slice(1);
147
+ if (tag === "p") { pid = Number(value); command = null; }
148
+ else if (tag === "c") command = value;
149
+ else if (tag === "n") {
150
+ const file = basename(value);
151
+ if (HELPER_LOCK_FILES.includes(file) && !/^botbuddyhelper/i.test(command ?? "")) {
152
+ stale.push({ file, pid, command });
153
+ }
154
+ }
155
+ }
156
+ return stale;
157
+ }
158
+
159
+ /**
160
+ * Read-only census of Helper worker processes against launchd's own job PID.
161
+ * A duplicate is a `botbuddyhelper-worker` launchd is not running. The launchd
162
+ * PID is read before and after `ps`; if launchd's answer is unreadable or
163
+ * changed (a tick started mid-check), nothing is called a duplicate.
164
+ */
165
+ export function inspectHelperWorkers({ run = defaultRun, home = homedir(), platformFn = platform } = {}) {
166
+ if (platformFn() !== "darwin") return null;
167
+ const before = parseLaunchctlListPid(run("launchctl", ["list", HELPER_WORKER_LABEL]));
168
+ const workers = parseWorkerPids(run("ps", ["-axo", "pid=,comm="]).stdout);
169
+ const after = parseLaunchctlListPid(run("launchctl", ["list", HELPER_WORKER_LABEL]));
170
+ const lockPaths = HELPER_LOCK_FILES.map((f) => join(helperSupportDir(home), f));
171
+ const lsof = run("lsof", ["+c", "0", "-F", "pcn", ...lockPaths]);
172
+ const staleLocks = parseStaleLocks(lsof.stdout);
173
+ const base = { launchd_pid: after ?? null, workers, duplicates: [], stale_locks: staleLocks };
174
+ if (before === undefined || after === undefined) {
175
+ return { status: "unknown", ...base, reason: `launchd job ${HELPER_WORKER_LABEL} is not readable in this session` };
176
+ }
177
+ if (before !== after) return { status: "unsettled", ...base, reason: "launchd started a worker tick during the check" };
178
+ const duplicates = workers.filter((pid) => pid !== after);
179
+ return { status: duplicates.length ? "duplicate" : "ok", ...base, duplicates };
180
+ }
181
+
182
+ function defaultIsAlive(pid) {
183
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
184
+ }
185
+
186
+ /** SIGTERM each duplicate (never launchd's worker); wait up to ~3 s for exit. */
187
+ export async function fixHelperWorkers(report, {
188
+ kill = (pid, signal) => process.kill(pid, signal), isAlive = defaultIsAlive,
189
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
190
+ } = {}) {
191
+ const result = { terminated: [], failed: [] };
192
+ if (report?.status !== "duplicate") return result;
193
+ for (const pid of report.duplicates) {
194
+ if (pid === report.launchd_pid) continue;
195
+ try { kill(pid, "SIGTERM"); } catch { /* already gone or not ours; checked below */ }
196
+ }
197
+ for (let i = 0; i < 30 && report.duplicates.some((pid) => isAlive(pid)); i++) await sleep(100);
198
+ for (const pid of report.duplicates) (isAlive(pid) ? result.failed : result.terminated).push(pid);
199
+ return result;
200
+ }
201
+
202
+ /** Human lines for `bb doctor`. */
203
+ export function formatHelperWorkersLines(report, fix = null) {
204
+ if (!report) return [];
205
+ const lines = [];
206
+ const launchd = report.launchd_pid ? `launchd ${HELPER_WORKER_LABEL} pid ${report.launchd_pid}` : `launchd ${HELPER_WORKER_LABEL} idle between ticks`;
207
+ if (fix && (fix.terminated.length || fix.failed.length)) {
208
+ // A re-checked report (cmdDoctor attaches `fix`) already lists the survivors.
209
+ const running = report.fix ? report.workers.length : report.workers.length - fix.terminated.length;
210
+ for (const pid of fix.terminated) lines.push(`helper workers: terminated duplicate pid ${pid}; ${running} running (${launchd})`);
211
+ for (const pid of fix.failed) lines.push(`helper workers: could not terminate duplicate pid ${pid}; stop it with \`kill ${pid}\``);
212
+ } else if (report.status === "ok") {
213
+ lines.push(`helper workers: ${report.workers.length} running (${launchd})`);
214
+ } else if (report.status === "duplicate") {
215
+ lines.push(`helper workers: ${report.workers.length} running — duplicate pid ${report.duplicates.join(", ")} (${launchd}); fix: \`bb doctor --fix\` terminates the duplicate`);
216
+ } else {
217
+ lines.push(`helper workers: ${report.workers.length} running — ${report.reason}; not checked for duplicates${report.status === "unsettled" ? " (re-run bb doctor)" : ""}`);
218
+ }
219
+ for (const s of report.stale_locks) {
220
+ lines.push(`helper lock: ${s.file} is held open by pid ${s.pid} (${s.command}), not a Helper process, so the Helper cannot take it; stop pid ${s.pid}`);
221
+ }
222
+ return lines;
223
+ }