@botbuddy/cli 1.49.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.49.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
@@ -33,6 +33,7 @@ import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } fro
33
33
  import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
34
34
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
35
35
  import { runUsage, usageStatus } from "./usage/sync.mjs";
36
+ import { cmdTestIngest, doctorTestIngest, formatTestIngestLine } from "./test-ingest.mjs";
36
37
  import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
37
38
 
38
39
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
@@ -41,7 +42,8 @@ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
41
42
  // runs inside every `git push` under a 5 s budget.
42
43
  export function shouldCheckForUpdates(argv) {
43
44
  const [command] = argv;
44
- 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;
45
47
  if (argv.includes("--json")) return false;
46
48
  return true;
47
49
  }
@@ -109,6 +111,7 @@ export async function run(argv, {
109
111
  return code;
110
112
  }
111
113
  case "test": return cmdTest(args);
114
+ case "test-ingest": return cmdTestIngest(args);
112
115
  case "wait": return runWait(args);
113
116
  case "pw": return runPw(args);
114
117
  case "mcp": return cmdMcp(args);
@@ -231,6 +234,8 @@ ${bold("DURABLE WORKLOADS")}
231
234
  Run locally with a durable lifecycle outbox
232
235
  telemetry <status|replay|doctor>
233
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
234
239
 
235
240
  ${bold("USAGE INGEST")}
236
241
  usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
@@ -264,6 +269,9 @@ export async function cmdDoctor(args, {
264
269
  publishReport = () => probeEnginePublishIp(),
265
270
  helperWorkersReport = async () => inspectHelperWorkers(),
266
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(),
267
275
  } = {}) {
268
276
  const parsed = parseDoctorArgs(args);
269
277
  if (parsed.help) { log(doctorHelp()); return 0; }
@@ -288,8 +296,10 @@ export async function cmdDoctor(args, {
288
296
  const recheck = await helperWorkersReport().catch(() => null);
289
297
  helperWorkers = recheck ? { ...recheck, fix: helperFix } : { ...helperWorkers, fix: helperFix };
290
298
  }
299
+ // BOT-1982: informational, like usage sync — never changes the exit code.
300
+ const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
291
301
  if (parsed.json) {
292
- log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}) }));
302
+ log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
293
303
  } else {
294
304
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
295
305
  log(`client key: ${result.report.client_key.status}`);
@@ -306,6 +316,7 @@ export async function cmdDoctor(args, {
306
316
  if (bootstrap) log(formatBootstrapLine(bootstrap));
307
317
  if (usage) log(formatUsageSyncLine(usage));
308
318
  for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
319
+ if (testIngest) log(formatTestIngestLine(testIngest));
309
320
  }
310
321
  if (result.exports) log(result.exports);
311
322
  return result.exitCode;
@@ -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
+ }