@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.48.0",
3
+ "version": "1.50.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,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
- if (command === "wait" || command === "telemetry" || command === "usage") return false;
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
+ }