@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 +1 -1
- package/src/commands.mjs +20 -2
- package/src/helper-host.mjs +223 -0
package/package.json
CHANGED
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
|
+
}
|