@botbuddy/cli 1.48.0 → 1.50.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 +32 -3
- package/src/helper-host.mjs +223 -0
- package/src/test-ingest.mjs +224 -0
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -30,8 +30,10 @@ 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";
|
|
36
|
+
import { cmdTestIngest, doctorTestIngest, formatTestIngestLine } from "./test-ingest.mjs";
|
|
35
37
|
import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
|
|
36
38
|
|
|
37
39
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
@@ -40,7 +42,8 @@ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
|
|
|
40
42
|
// runs inside every `git push` under a 5 s budget.
|
|
41
43
|
export function shouldCheckForUpdates(argv) {
|
|
42
44
|
const [command] = argv;
|
|
43
|
-
|
|
45
|
+
// BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
|
|
46
|
+
if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
|
|
44
47
|
if (argv.includes("--json")) return false;
|
|
45
48
|
return true;
|
|
46
49
|
}
|
|
@@ -83,6 +86,8 @@ export async function run(argv, {
|
|
|
83
86
|
case "logout": return cmdLogout();
|
|
84
87
|
case "status": return cmdStatus();
|
|
85
88
|
case "doctor": return cmdDoctor(args);
|
|
89
|
+
// BOT-2367: this Mac's Helper host key, whatever the status file's age.
|
|
90
|
+
case "host": return cmdHost(args);
|
|
86
91
|
case "update": return cmdUpdate();
|
|
87
92
|
// Agent-only commands (used by MCP agents, not humans)
|
|
88
93
|
case "heartbeat": return cmdHeartbeat(args);
|
|
@@ -106,6 +111,7 @@ export async function run(argv, {
|
|
|
106
111
|
return code;
|
|
107
112
|
}
|
|
108
113
|
case "test": return cmdTest(args);
|
|
114
|
+
case "test-ingest": return cmdTestIngest(args);
|
|
109
115
|
case "wait": return runWait(args);
|
|
110
116
|
case "pw": return runPw(args);
|
|
111
117
|
case "mcp": return cmdMcp(args);
|
|
@@ -188,7 +194,9 @@ ${bold("AUTH")}
|
|
|
188
194
|
logout Remove saved credentials
|
|
189
195
|
status Show current auth status (local metadata + server check)
|
|
190
196
|
doctor [--fix] [--print-exports] [--json]
|
|
191
|
-
Diagnose or repair the cached agent session
|
|
197
|
+
Diagnose or repair the cached agent session; on macOS also
|
|
198
|
+
reports duplicate Helper workers (--fix terminates them)
|
|
199
|
+
host key [--json] This Mac's Helper host key, whatever the status file's age
|
|
192
200
|
|
|
193
201
|
${bold("MCP CONFIG KEY")}
|
|
194
202
|
mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
|
|
@@ -226,6 +234,8 @@ ${bold("DURABLE WORKLOADS")}
|
|
|
226
234
|
Run locally with a durable lifecycle outbox
|
|
227
235
|
telemetry <status|replay|doctor>
|
|
228
236
|
Inspect, replay, or gate durable lifecycle delivery
|
|
237
|
+
test-ingest --file <payload.json|-> [--tenant <slug>]
|
|
238
|
+
Upload a test-run report with this worktree's agent session
|
|
229
239
|
|
|
230
240
|
${bold("USAGE INGEST")}
|
|
231
241
|
usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
|
|
@@ -257,6 +267,11 @@ export async function cmdDoctor(args, {
|
|
|
257
267
|
log = (line) => console.log(line), errorLog = (line) => console.error(line),
|
|
258
268
|
doctor = doctorAgentAuth, usageReport = doctorUsageSync, bootstrapReport = doctorBootstrapState,
|
|
259
269
|
publishReport = () => probeEnginePublishIp(),
|
|
270
|
+
helperWorkersReport = async () => inspectHelperWorkers(),
|
|
271
|
+
helperWorkersFix = (report) => fixHelperWorkers(report),
|
|
272
|
+
// BOT-1982: which credential test-run uploads would use — the ONE place a
|
|
273
|
+
// missing upload credential is reported (the harnesses skip silently).
|
|
274
|
+
testIngestReport = () => doctorTestIngest(),
|
|
260
275
|
} = {}) {
|
|
261
276
|
const parsed = parseDoctorArgs(args);
|
|
262
277
|
if (parsed.help) { log(doctorHelp()); return 0; }
|
|
@@ -271,8 +286,20 @@ export async function cmdDoctor(args, {
|
|
|
271
286
|
const usage = await usageReport().catch(() => null);
|
|
272
287
|
// BOT-1945 (AC-3): informational, like usage sync — never changes the exit code.
|
|
273
288
|
const bootstrap = await bootstrapReport().catch(() => null);
|
|
289
|
+
// BOT-2367: duplicate Helper workers / stale Helper locks (macOS). Reported on
|
|
290
|
+
// every run; `--fix` SIGTERMs a duplicate launchd is not running, then
|
|
291
|
+
// re-checks. Informational: never changes doctor's exit code.
|
|
292
|
+
let helperWorkers = await helperWorkersReport().catch(() => null);
|
|
293
|
+
let helperFix = null;
|
|
294
|
+
if (parsed.fix && helperWorkers?.status === "duplicate") {
|
|
295
|
+
helperFix = await helperWorkersFix(helperWorkers).catch(() => null);
|
|
296
|
+
const recheck = await helperWorkersReport().catch(() => null);
|
|
297
|
+
helperWorkers = recheck ? { ...recheck, fix: helperFix } : { ...helperWorkers, fix: helperFix };
|
|
298
|
+
}
|
|
299
|
+
// BOT-1982: informational, like usage sync — never changes the exit code.
|
|
300
|
+
const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
|
|
274
301
|
if (parsed.json) {
|
|
275
|
-
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}) }));
|
|
302
|
+
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
|
|
276
303
|
} else {
|
|
277
304
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
278
305
|
log(`client key: ${result.report.client_key.status}`);
|
|
@@ -288,6 +315,8 @@ export async function cmdDoctor(args, {
|
|
|
288
315
|
if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
|
|
289
316
|
if (bootstrap) log(formatBootstrapLine(bootstrap));
|
|
290
317
|
if (usage) log(formatUsageSyncLine(usage));
|
|
318
|
+
for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
|
|
319
|
+
if (testIngest) log(formatTestIngestLine(testIngest));
|
|
291
320
|
}
|
|
292
321
|
if (result.exports) log(result.exports);
|
|
293
322
|
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
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// BOT-1982 — upload a test-run payload to the `test-ingest` edge function with
|
|
2
|
+
// THIS worktree's credential. One resolver + one transport, shared by
|
|
3
|
+
// `bb test-ingest` (tenant repos' harnesses call the binary) and this repo's
|
|
4
|
+
// pre-push reporter (scripts/report-test-runs.mjs imports the module).
|
|
5
|
+
//
|
|
6
|
+
// Why an ordered candidate list with 401 fall-through: the old reporters sent the
|
|
7
|
+
// FIRST exported key, so a dead legacy `BOTBUDDY_API_KEY` left in a shell profile
|
|
8
|
+
// shadowed a valid key and every upload 401'd. Now the worktree's agent session
|
|
9
|
+
// (`bb setup`) is preferred, the durable keys follow, the legacy exports come
|
|
10
|
+
// last, and a 401 tries the next candidate — a stale credential can never shadow
|
|
11
|
+
// a valid one. A missing credential is NOT reported per run: callers stay silent
|
|
12
|
+
// and `bb doctor` reports it once (doctorTestIngest).
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
|
|
17
|
+
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
18
|
+
import { readKeychainSecret } from "./agent-credential-store.mjs";
|
|
19
|
+
import { resolveAgentBinding } from "./wait-profile.mjs";
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
export const DEFAULT_TEST_INGEST_URL = "https://api.bot-buddy.ai/functions/v1/test-ingest";
|
|
23
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
24
|
+
export const EXIT_DELIVERED = 0;
|
|
25
|
+
export const EXIT_NO_CREDENTIAL = 3;
|
|
26
|
+
export const EXIT_NOT_DELIVERED = 5;
|
|
27
|
+
export const TEST_INGEST_FIX = "run `bb setup` in this worktree (or `bb doctor --fix`) so test-run uploads use its agent session";
|
|
28
|
+
|
|
29
|
+
/** test-ingest URL: explicit → functions base → derived from the MCP server URL. */
|
|
30
|
+
export function testIngestEndpoint(env = process.env) {
|
|
31
|
+
if (env.BOTBUDDY_TEST_INGEST_URL) return env.BOTBUDDY_TEST_INGEST_URL;
|
|
32
|
+
if (env.BOTBUDDY_FUNCTIONS_URL) return `${env.BOTBUDDY_FUNCTIONS_URL.replace(/\/+$/, "")}/test-ingest`;
|
|
33
|
+
if (env.BOTBUDDY_SERVER_URL) {
|
|
34
|
+
const url = new URL(env.BOTBUDDY_SERVER_URL);
|
|
35
|
+
url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/test-ingest");
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
return DEFAULT_TEST_INGEST_URL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A time-bounded Keychain read: a locked/prompting Keychain must never hang a push.
|
|
42
|
+
async function boundedKeychainRead(service, timeoutMs = 3_000) {
|
|
43
|
+
if (process.platform !== "darwin") return null;
|
|
44
|
+
const bounded = (cmd, args) => execFileP(cmd, args, { timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
45
|
+
return readKeychainSecret(service, { execFileImpl: bounded });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function bindingKey({ cwd, env, readCredential }) {
|
|
49
|
+
try {
|
|
50
|
+
const { token, tokenEnv } = await resolveAgentBinding({ cwd, env, readCredential });
|
|
51
|
+
return token ? { token, source: "mcp_key", env: tokenEnv } : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null; // no/malformed binding → no binding key; never throws into a push
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ordered, de-duplicated credential candidates `{ token, source }` (never logged):
|
|
59
|
+
* 1. BOTBUDDY_TEST_INGEST_KEY — an explicit override (CI's dedicated key)
|
|
60
|
+
* 2. the agent session — BOTBUDDY_AGENT_SESSION_TOKEN, else this worktree's
|
|
61
|
+
* FRESH .botbuddy/agent-state.json (an expired cache is never offered or
|
|
62
|
+
* advertised by `bb doctor`; offline, no self-heal registration from a hook)
|
|
63
|
+
* 3. the binding's tier-2 MCP key (env, else a bounded Keychain read)
|
|
64
|
+
* 4. legacy BOTBUDDY_API_KEY / BOTBUDDY_BB_AGENT_KEY — last, so a stale export
|
|
65
|
+
* can never shadow a valid credential
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveTestIngestCredentials({
|
|
68
|
+
env = process.env, cwd = process.cwd(), readState = readAgentState, isFresh = isAgentStateFresh,
|
|
69
|
+
readCredential = async (name, e) => (typeof e[name] === "string" && e[name].trim()) || (await boundedKeychainRead(name)),
|
|
70
|
+
} = {}) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const add = (token, source) => {
|
|
73
|
+
const value = typeof token === "string" ? token.trim() : "";
|
|
74
|
+
if (value && !out.some((c) => c.token === value)) out.push({ token: value, source });
|
|
75
|
+
};
|
|
76
|
+
add(env.BOTBUDDY_TEST_INGEST_KEY, "explicit");
|
|
77
|
+
add(readAgentSessionTokenEnv(env), "session_env");
|
|
78
|
+
try {
|
|
79
|
+
const state = await readState(cwd);
|
|
80
|
+
if (state?.agent_session_token && isFresh(state, new Date())) add(state.agent_session_token, "session");
|
|
81
|
+
} catch { /* unreadable cache = no session candidate */ }
|
|
82
|
+
const key = await bindingKey({ cwd, env, readCredential });
|
|
83
|
+
if (key) add(key.token, key.source);
|
|
84
|
+
add(env.BOTBUDDY_API_KEY, "legacy_api_key");
|
|
85
|
+
add(env.BOTBUDDY_BB_AGENT_KEY, "legacy_agent_key");
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function postOnce({ url, token, payload, fetchImpl, timeoutMs }) {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchImpl(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
96
|
+
body: typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
const text = res.text ? await res.text().catch(() => "") : "";
|
|
100
|
+
let body = null;
|
|
101
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
102
|
+
return { status: res.status, ok: res.ok, text, body };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { status: null, ok: false, error: controller.signal.aborted ? `timed out after ${timeoutMs}ms` : (error?.message ?? String(error)) };
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* POST `payload` with the first candidate test-ingest accepts. Only a 401 moves to
|
|
112
|
+
* the next candidate (any other answer is the server's verdict on the payload).
|
|
113
|
+
* Returns a token-free receipt:
|
|
114
|
+
* { ok:true, status, source, attempts, summary, errors, deep_link }
|
|
115
|
+
* { ok:false, code:"credential_missing", attempts:[] }
|
|
116
|
+
* { ok:false, code:"ingest_errors", status, source, attempts, summary, errors } (200 + row errors)
|
|
117
|
+
* { ok:false, code:"rejected"|"unreachable", status, source, attempts, error }
|
|
118
|
+
*/
|
|
119
|
+
export async function uploadTestRun({
|
|
120
|
+
payload, url = testIngestEndpoint(), credentials, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const candidates = Array.isArray(credentials) ? credentials : [];
|
|
123
|
+
if (candidates.length === 0) return { ok: false, code: "credential_missing", attempts: [] };
|
|
124
|
+
const attempts = [];
|
|
125
|
+
let last = null;
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
const res = await postOnce({ url, token: candidate.token, payload, fetchImpl, timeoutMs });
|
|
128
|
+
attempts.push({ source: candidate.source, status: res.status });
|
|
129
|
+
last = { ...res, source: candidate.source };
|
|
130
|
+
if (res.status === 401) continue;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (last.ok) {
|
|
134
|
+
const summary = last.body?.summary ?? null;
|
|
135
|
+
const errors = Array.isArray(summary?.errors) ? summary.errors : [];
|
|
136
|
+
// test-ingest answers 200 with summary.errors for permanent per-row write
|
|
137
|
+
// failures: the request was accepted but rows are missing, so it is NOT a
|
|
138
|
+
// delivery (the harness must not treat it as one and drop its results).
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
return { ok: false, code: "ingest_errors", status: last.status, source: last.source, attempts, summary, errors, deep_link: last.body?.deep_link ?? null };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, status: last.status, source: last.source, attempts, summary, errors, deep_link: last.body?.deep_link ?? null };
|
|
143
|
+
}
|
|
144
|
+
if (last.status === null) return { ok: false, code: "unreachable", status: null, source: last.source, attempts, error: last.error };
|
|
145
|
+
const message = last.body?.error ?? last.body?.message ?? String(last.text ?? "").slice(0, 200);
|
|
146
|
+
return { ok: false, code: "rejected", status: last.status, source: last.source, attempts, error: message || `http_${last.status}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `bb doctor` line for test-run uploads: which credential would be tried first. */
|
|
150
|
+
export async function doctorTestIngest(opts = {}) {
|
|
151
|
+
const candidates = await resolveTestIngestCredentials(opts);
|
|
152
|
+
if (candidates.length === 0) return { status: "missing", fix: TEST_INGEST_FIX };
|
|
153
|
+
return { status: "available", source: candidates[0].source, candidates: candidates.map((c) => c.source) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatTestIngestLine(report) {
|
|
157
|
+
if (!report) return null;
|
|
158
|
+
return report.status === "available"
|
|
159
|
+
? `test-run upload: available (${report.source})`
|
|
160
|
+
: `test-run upload: MISSING — ${report.fix}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseTestIngestArgs(argv = []) {
|
|
164
|
+
const args = { file: null, tenant: null, timeoutMs: null, help: false, errors: [] };
|
|
165
|
+
for (let i = 0; i < argv.length; i++) {
|
|
166
|
+
const a = argv[i];
|
|
167
|
+
const value = () => {
|
|
168
|
+
const v = argv[i + 1];
|
|
169
|
+
if (v === undefined || (v.startsWith("--") && v !== "-")) { args.errors.push(`${a} requires a value`); return null; }
|
|
170
|
+
i++;
|
|
171
|
+
return v;
|
|
172
|
+
};
|
|
173
|
+
if (a === "--help" || a === "-h" || a === "help") args.help = true;
|
|
174
|
+
else if (a === "--file") args.file = value();
|
|
175
|
+
else if (a === "--tenant") args.tenant = value();
|
|
176
|
+
else if (a === "--timeout-ms") {
|
|
177
|
+
const v = value();
|
|
178
|
+
if (v !== null) { const n = Number(v); if (Number.isInteger(n) && n > 0) args.timeoutMs = n; else args.errors.push("--timeout-ms must be a positive integer"); }
|
|
179
|
+
} else args.errors.push(`unknown argument ${a}`);
|
|
180
|
+
}
|
|
181
|
+
if (!args.help && !args.file) args.errors.push("--file <path|-> is required");
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function testIngestHelp() {
|
|
186
|
+
return [
|
|
187
|
+
"bb test-ingest --file <payload.json|-> [--tenant <slug>] [--timeout-ms <n>]",
|
|
188
|
+
"",
|
|
189
|
+
"POSTs a test-run payload ({ executions: [...] }, or a raw vitest/playwright --reporter=json",
|
|
190
|
+
"report plus context fields) to BotBuddy's test-ingest with this worktree's credential:",
|
|
191
|
+
"BOTBUDDY_TEST_INGEST_KEY → agent session (bb setup) → the binding's MCP key → legacy exports.",
|
|
192
|
+
"A 401 tries the next credential. Prints one JSON receipt (never a secret).",
|
|
193
|
+
"Exit 0 delivered · 3 no credential (see `bb doctor`) · 5 rejected, unreachable, or accepted with row errors.",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function cmdTestIngest(argv, {
|
|
198
|
+
env = process.env, cwd = process.cwd(), log = (l) => console.log(l), errorLog = (l) => console.error(l),
|
|
199
|
+
readStdin = () => readFileSync(0, "utf8"), readFile = (p) => readFileSync(p, "utf8"),
|
|
200
|
+
resolveCredentials = resolveTestIngestCredentials, upload = uploadTestRun,
|
|
201
|
+
} = {}) {
|
|
202
|
+
const args = parseTestIngestArgs(argv);
|
|
203
|
+
if (args.help) { log(testIngestHelp()); return 0; }
|
|
204
|
+
if (args.errors.length) { errorLog(`bb test-ingest: ${args.errors.join("; ")}`); return 2; }
|
|
205
|
+
let payload;
|
|
206
|
+
try {
|
|
207
|
+
payload = JSON.parse(args.file === "-" ? readStdin() : readFile(args.file));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
errorLog(`bb test-ingest: cannot read a JSON payload from ${args.file}: ${error?.message ?? error}`);
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
213
|
+
errorLog("bb test-ingest: the payload must be a JSON object");
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
if (args.tenant && !payload.tenant) payload.tenant = args.tenant;
|
|
217
|
+
const credentials = await resolveCredentials({ env, cwd });
|
|
218
|
+
const receipt = await upload({
|
|
219
|
+
payload, credentials, url: testIngestEndpoint(env), timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
220
|
+
});
|
|
221
|
+
log(JSON.stringify(receipt));
|
|
222
|
+
if (receipt.ok) return EXIT_DELIVERED;
|
|
223
|
+
return receipt.code === "credential_missing" ? EXIT_NO_CREDENTIAL : EXIT_NOT_DELIVERED;
|
|
224
|
+
}
|