@botbuddy/cli 1.49.0 → 1.50.1

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.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -32,7 +32,9 @@ import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repa
32
32
  import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } from "./docker-publish-ip.mjs";
33
33
  import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
34
34
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
35
+ import { readAgentState } from "./agent-state.mjs";
35
36
  import { runUsage, usageStatus } from "./usage/sync.mjs";
37
+ import { cmdTestIngest, doctorTestIngest, formatTestIngestLine } from "./test-ingest.mjs";
36
38
  import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
37
39
 
38
40
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
@@ -41,7 +43,8 @@ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
41
43
  // runs inside every `git push` under a 5 s budget.
42
44
  export function shouldCheckForUpdates(argv) {
43
45
  const [command] = argv;
44
- if (command === "wait" || command === "telemetry" || command === "usage") return false;
46
+ // BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
47
+ if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
45
48
  if (argv.includes("--json")) return false;
46
49
  return true;
47
50
  }
@@ -109,6 +112,7 @@ export async function run(argv, {
109
112
  return code;
110
113
  }
111
114
  case "test": return cmdTest(args);
115
+ case "test-ingest": return cmdTestIngest(args);
112
116
  case "wait": return runWait(args);
113
117
  case "pw": return runPw(args);
114
118
  case "mcp": return cmdMcp(args);
@@ -231,6 +235,8 @@ ${bold("DURABLE WORKLOADS")}
231
235
  Run locally with a durable lifecycle outbox
232
236
  telemetry <status|replay|doctor>
233
237
  Inspect, replay, or gate durable lifecycle delivery
238
+ test-ingest --file <payload.json|-> [--tenant <slug>]
239
+ Upload a test-run report with this worktree's agent session
234
240
 
235
241
  ${bold("USAGE INGEST")}
236
242
  usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
@@ -264,6 +270,9 @@ export async function cmdDoctor(args, {
264
270
  publishReport = () => probeEnginePublishIp(),
265
271
  helperWorkersReport = async () => inspectHelperWorkers(),
266
272
  helperWorkersFix = (report) => fixHelperWorkers(report),
273
+ // BOT-1982: which credential test-run uploads would use — the ONE place a
274
+ // missing upload credential is reported (the harnesses skip silently).
275
+ testIngestReport = () => doctorTestIngest(),
267
276
  } = {}) {
268
277
  const parsed = parseDoctorArgs(args);
269
278
  if (parsed.help) { log(doctorHelp()); return 0; }
@@ -288,8 +297,10 @@ export async function cmdDoctor(args, {
288
297
  const recheck = await helperWorkersReport().catch(() => null);
289
298
  helperWorkers = recheck ? { ...recheck, fix: helperFix } : { ...helperWorkers, fix: helperFix };
290
299
  }
300
+ // BOT-1982: informational, like usage sync — never changes the exit code.
301
+ const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
291
302
  if (parsed.json) {
292
- log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}) }));
303
+ log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
293
304
  } else {
294
305
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
295
306
  log(`client key: ${result.report.client_key.status}`);
@@ -306,6 +317,7 @@ export async function cmdDoctor(args, {
306
317
  if (bootstrap) log(formatBootstrapLine(bootstrap));
307
318
  if (usage) log(formatUsageSyncLine(usage));
308
319
  for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
320
+ if (testIngest) log(formatTestIngestLine(testIngest));
309
321
  }
310
322
  if (result.exports) log(result.exports);
311
323
  return result.exitCode;
@@ -569,6 +581,7 @@ export async function cmdSetup(args, {
569
581
  now = () => Date.now(),
570
582
  binding = readAgentBinding,
571
583
  clearState = clearAgentState,
584
+ readState = readAgentState,
572
585
  selfHeal = selfHealAgentSession,
573
586
  callerResolver = resolveCaller,
574
587
  usageAgent = installUsageAgent,
@@ -643,12 +656,22 @@ export async function cmdSetup(args, {
643
656
  signedIn = true;
644
657
  }
645
658
  }
646
- // Setup is an explicit session bootstrap/repair command. Rotate before
647
- // resolving so it cannot merely report an old cache as ready.
648
- await clearState(cwd);
659
+ // Setup is an explicit session bootstrap/repair command: it always mints a
660
+ // fresh session so it cannot merely report an old cache as ready.
661
+ // BOT-2171: but it keeps the worktree's AGENT. Deleting the cache first made
662
+ // the mint carry no agent_id, so the server fell back to a hostname-bearing
663
+ // name lookup and, after a hostname drift, inserted a new agent — every stack
664
+ // lease this worktree held then answered NOT_LEASE_OWNER. A cache bound to this
665
+ // tenant is re-minted in place (`force` re-registers with its agent_id); one
666
+ // bound elsewhere, or a legacy cache with no tenant to verify, is still
667
+ // discarded, since its agent may not serve this tenant (Codex #1180 R1).
668
+ const cached = await readState(cwd).catch(() => null);
669
+ const keepAgent = Boolean(cached?.agent_id) && cached.tenant === tenant;
670
+ if (!keepAgent) await clearState(cwd);
671
+ const mint = () => selfHeal({ cwd, env, ...(keepAgent ? { force: true } : {}) });
649
672
  let session;
650
673
  try {
651
- session = await selfHeal({ cwd, env });
674
+ session = await mint();
652
675
  } catch (error) {
653
676
  // A locally-valid key the server rejects (revoked, rotated) needs exactly
654
677
  // one fresh sign-in and one retried mint. Never loop, and never re-sign a
@@ -656,7 +679,7 @@ export async function cmdSetup(args, {
656
679
  if (signedIn || !SIGN_IN_REJECTED.has(error?.code) || error?.mintedWith === "mcp_key") throw error;
657
680
  errorLog(`bb setup: this machine's saved sign-in was rejected (${error.code}); signing in again.`);
658
681
  await signIn();
659
- session = await selfHeal({ cwd, env });
682
+ session = await mint();
660
683
  }
661
684
  // BOT-2058: register_agent reports any OTHER live session already holding
662
685
  // this ticket (heartbeat < 15m) in `ticket_signals`. This is advisory: a
@@ -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
+ }