@botbuddy/cli 1.26.0 → 1.27.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.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "exports": {
13
13
  "./playwright-reporter": "./src/test/playwright-reporter.mjs",
14
+ "./telemetry": "./src/telemetry-outbox.mjs",
14
15
  "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
package/src/commands.mjs CHANGED
@@ -18,13 +18,16 @@ import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFA
18
18
  import { readAgentBinding } from "./wait-profile.mjs";
19
19
  import { runPw } from "./pw/run.mjs";
20
20
  import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
21
+ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
22
+ import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
23
+ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
21
24
 
22
25
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
23
26
  // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
24
27
  // must not pay for a registry round-trip.
25
28
  export function shouldCheckForUpdates(argv) {
26
29
  const [command] = argv;
27
- if (command === "wait") return false;
30
+ if (command === "wait" || command === "telemetry") return false;
28
31
  if (argv.includes("--json")) return false;
29
32
  return true;
30
33
  }
@@ -53,6 +56,7 @@ export async function run(argv, {
53
56
  case "stack": return cmdStack(args);
54
57
  case "docker": return cmdDocker(args);
55
58
  case "run": return cmdRun(args);
59
+ case "telemetry": return cmdTelemetry(args);
56
60
  case "test": return cmdTest(args);
57
61
  case "wait": return runWait(args);
58
62
  case "pw": return runPw(args);
@@ -80,6 +84,36 @@ export async function run(argv, {
80
84
  }
81
85
  }
82
86
 
87
+ async function cmdTelemetry(args) {
88
+ const subcommand = args[0] ?? "help";
89
+ if (!new Set(["status", "replay", "doctor", "help", "--help"]).has(subcommand) || args.length > 1) {
90
+ die("Usage: botbuddy telemetry <status|replay|doctor>");
91
+ }
92
+ if (subcommand === "help" || subcommand === "--help") {
93
+ console.log("botbuddy telemetry status\nbotbuddy telemetry replay\nbotbuddy telemetry doctor");
94
+ return;
95
+ }
96
+ let location;
97
+ try { location = await loadTelemetryLocation(process.cwd()); }
98
+ catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
99
+ const outbox = await createTelemetryOutbox({ tenant: location.tenant, repository: location.repository, producerVersion: VERSION });
100
+ if (subcommand === "replay") {
101
+ let identity;
102
+ try {
103
+ identity = await loadTelemetryIdentity(process.cwd());
104
+ await attestTelemetryCredential(identity, outbox);
105
+ } catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
106
+ await outbox.importLegacyTestRunReceipts(".botbuddy/test-runs");
107
+ const receipt = await outbox.replay((event) => deliverExecutionEvent(event, { credential: identity.credential }));
108
+ console.log(JSON.stringify(receipt));
109
+ return receipt.remaining === 0 ? 0 : 5;
110
+ }
111
+ const status = await outbox.status();
112
+ console.log(JSON.stringify(status));
113
+ if (subcommand === "doctor") return status.queued_count === 0 && !status.last_error_class ? 0 : 5;
114
+ return 0;
115
+ }
116
+
83
117
  function cmdHelp() {
84
118
  console.log(`${bold("bb")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
85
119
 
@@ -132,6 +166,10 @@ ${bold("DOCKER HYGIENE")}
132
166
  ${bold("DURABLE WORKLOADS")}
133
167
  run --session-id <id> --environment <env> -- <command>
134
168
  Launch a receipt-bearing command under a detached owner
169
+ run --foreground --kind <kind> --environment <env> -- <command>
170
+ Run locally with a durable lifecycle outbox
171
+ telemetry <status|replay|doctor>
172
+ Inspect, replay, or gate durable lifecycle delivery
135
173
 
136
174
  ${bold("AGENT WAITS")}
137
175
  wait [--any] <condition>... [options]
package/src/run.mjs CHANGED
@@ -6,15 +6,19 @@
6
6
  // allowed to call update_command_run. A bb-wait is therefore a notification
7
7
  // mechanism, never the owner of a child process.
8
8
 
9
- import { createHash, randomUUID } from "crypto";
10
- import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "fs/promises";
11
- import { dirname, join } from "path";
12
- import { homedir } from "os";
13
- import { spawn } from "child_process";
14
- import { fileURLToPath } from "url";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { spawn } from "node:child_process";
14
+ import { fileURLToPath } from "node:url";
15
15
  import { callToolJson } from "./api.mjs";
16
16
  import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
17
17
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
18
+ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
19
+ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
20
+ import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
21
+ import { VERSION } from "./version.mjs";
18
22
 
19
23
  export const RUN_SCHEMA_VERSION = 1;
20
24
  export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
@@ -24,6 +28,7 @@ const MAX_CAPTURE_BYTES = 8_192;
24
28
  // BOT-1510: how often the worker flushes lane case verdicts to the backend.
25
29
  // Well under AC-3's 5 s so a begin/verdict is visible within the window.
26
30
  const LANE_FLUSH_INTERVAL_MS = 2_500;
31
+ const FOREGROUND_KINDS = new Set(["test", "wait", "browser", "ci", "hook", "stack"]);
27
32
 
28
33
  // BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
29
34
  // BOT-1572 bb_sess_ legacy alias, both accepted for one release.
@@ -34,7 +39,7 @@ export function parseRunArgs(argv, env = process.env) {
34
39
  // session, so --session-id becomes optional (the relay derives it). The old
35
40
  // $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
36
41
  // is the default when a plain id is used (parity with `botbuddy test`).
37
- const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
42
+ const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, foreground: false, json: false };
38
43
  const errors = [];
39
44
  const separator = argv.indexOf("--");
40
45
  const flags = separator === -1 ? argv : argv.slice(0, separator);
@@ -62,6 +67,7 @@ export function parseRunArgs(argv, env = process.env) {
62
67
  } catch (error) { errors.push(`--rerun-reason must be a JSON object (${error instanceof Error ? error.message : String(error)})`); }
63
68
  break;
64
69
  }
70
+ case "--foreground": opts.foreground = true; break;
65
71
  case "--json": opts.json = true; break;
66
72
  default: errors.push(`unknown option: ${flag}`);
67
73
  }
@@ -70,7 +76,8 @@ export function parseRunArgs(argv, env = process.env) {
70
76
  // session from the token). A malformed token, or a plain id AND a differing
71
77
  // token, is a hard error.
72
78
  if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>");
73
- if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
79
+ if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
80
+ if (opts.foreground && !FOREGROUND_KINDS.has(opts.kind)) errors.push("--kind must be test, wait, browser, ci, hook, or stack with --foreground");
74
81
  if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
75
82
  if (!command.length) errors.push("a workload is required after --");
76
83
  if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
@@ -78,6 +85,118 @@ export function parseRunArgs(argv, env = process.env) {
78
85
  return { opts, command, errors };
79
86
  }
80
87
 
88
+ function foregroundOutcome({ exitCode, signal, timedOut }) {
89
+ if (timedOut) return "timed_out";
90
+ if (signal) return "canceled";
91
+ return exitCode === 0 ? "passed" : "failed";
92
+ }
93
+
94
+ function signalExitCode(signal) {
95
+ const codes = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129, SIGKILL: 137 };
96
+ return codes[signal] ?? EXIT.INTERNAL;
97
+ }
98
+
99
+ // Foreground deliberately shares no ownership code with BOT-1350's detached
100
+ // runner. It has exactly one durable pre-spawn boundary (the outbox start
101
+ // event), then returns the child verdict even when terminal delivery degrades.
102
+ export async function launchForegroundRun(argv, {
103
+ cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
104
+ } = {}) {
105
+ const { opts, command, errors } = parseRunArgs(argv);
106
+ if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
107
+ if (!opts.foreground) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "foreground_flag_required", exit_code: EXIT.INVALID } };
108
+ if (!identity?.repository || !identity?.tenant || !identity?.appSlug) {
109
+ return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "telemetry_identity_required", exit_code: EXIT.INVALID } };
110
+ }
111
+ const telemetry = outbox ?? await createTelemetryOutbox({
112
+ tenant: identity.tenant,
113
+ repository: identity.repository,
114
+ producerVersion: identity.producerVersion ?? VERSION,
115
+ });
116
+ // An authenticated invocation is also the bounded recovery opportunity for
117
+ // prior offline records. Failure remains durable and never alters the child
118
+ // verdict below.
119
+ if (!outbox) {
120
+ // A cached proof keeps offline hooks fast; a new or rotated credential must
121
+ // be authenticated once before it may scope a BotBuddy lifecycle event.
122
+ try {
123
+ await attestTelemetryCredential(identity, telemetry);
124
+ } catch (error) {
125
+ return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_credential_unattested", exit_code: EXIT.INVALID } };
126
+ }
127
+ await telemetry.importLegacyTestRunReceipts(join(cwd, ".botbuddy", "test-runs"), { maxReceipts: 8, deadline: Date.now() + 2_250 }).catch(() => {});
128
+ await telemetry.replay(
129
+ (event) => deliverExecutionEvent(event, { credential: identity.credential }),
130
+ // Recovery is opportunistic: a large offline backlog must never turn the
131
+ // next hook invocation into an unbounded pre-spawn upload job.
132
+ { maxEvents: 8, deadline: Date.now() + 2_250 },
133
+ ).catch(() => {});
134
+ }
135
+ const runId = randomUUID();
136
+ const context = {
137
+ repo: identity.repository,
138
+ environment: opts.environment,
139
+ runner: identity.appSlug,
140
+ phase: opts.kind === "wait" ? "waiting" : "testing",
141
+ health: "uninstrumented",
142
+ };
143
+ const base = {
144
+ schema_version: 1,
145
+ producer_kind: opts.kind,
146
+ source_run_id: runId,
147
+ attempt: 1,
148
+ occurred_at: new Date().toISOString(),
149
+ context,
150
+ };
151
+ try {
152
+ await telemetry.append({ ...base, action: "start", producer_event_id: `${runId}:start`, producer_sequence: 0 });
153
+ } catch (error) {
154
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.code ?? "telemetry_start_not_durable", exit_code: EXIT.INTERNAL } };
155
+ }
156
+ let child;
157
+ try {
158
+ // A foreground command still needs its own process group: hooks commonly
159
+ // launch package managers or test runners, whose descendants otherwise
160
+ // survive a timeout after their immediate parent exits.
161
+ child = spawnImpl(command[0], command.slice(1), { cwd, detached: process.platform !== "win32", stdio: "inherit", windowsHide: true });
162
+ } catch (error) {
163
+ await telemetry.append({ ...base, action: "terminal", producer_event_id: `${runId}:terminal`, producer_sequence: 1, outcome: "crashed", diagnostic: { code: "spawn_failed", category: "spawn" } }).catch(() => {});
164
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL } };
165
+ }
166
+ let timedOut = false;
167
+ let killTimer;
168
+ const timeout = setTimeout(() => {
169
+ timedOut = true;
170
+ terminateProcessGroup(child, "SIGTERM");
171
+ killTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
172
+ }, opts.timeout * 1000);
173
+ const forwarded = forwardTerminationSignals(child, signalEmitter);
174
+ const result = await waitForChild(child);
175
+ forwarded.cleanup();
176
+ clearTimeout(timeout);
177
+ clearTimeout(killTimer);
178
+ const terminalSignal = forwarded.signal ?? result.signal;
179
+ const outcome = result.error ? "crashed" : foregroundOutcome({ ...result, signal: terminalSignal, timedOut });
180
+ let telemetryState = "durable";
181
+ try {
182
+ await telemetry.append({
183
+ ...base,
184
+ action: "terminal",
185
+ producer_event_id: `${runId}:terminal`,
186
+ producer_sequence: 1,
187
+ // BOT-1317 derives duration from the two lifecycle timestamps. `base`
188
+ // intentionally represents pre-spawn time, so terminal needs a fresh
189
+ // timestamp after the child has actually completed.
190
+ occurred_at: new Date().toISOString(),
191
+ context: { ...context, phase: outcome === "passed" ? "complete" : "failed" },
192
+ outcome,
193
+ ...(terminalSignal ? { diagnostic: { code: "child_signaled", category: "signal", signal: terminalSignal } } : {}),
194
+ });
195
+ } catch { telemetryState = "degraded"; }
196
+ const exitCode = result.error ? EXIT.INTERNAL : timedOut ? signalExitCode("SIGTERM") : terminalSignal ? signalExitCode(terminalSignal) : result.exitCode ?? EXIT.INTERNAL;
197
+ return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode } };
198
+ }
199
+
81
200
  export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
82
201
  if (ownerLost) return "owner_lost";
83
202
  if (timedOut) return "timed_out";
@@ -280,7 +399,30 @@ function boundedCapture() {
280
399
  }
281
400
 
282
401
  function terminateProcessGroup(child, signal) {
283
- try { process.kill(-child.pid, signal); } catch { try { child.kill?.(signal); } catch { /* already exited */ } }
402
+ if (process.platform !== "win32" && Number.isInteger(child?.pid) && child.pid > 0) {
403
+ try { process.kill(-child.pid, signal); return; } catch { /* fall back to the immediate child */ }
404
+ }
405
+ try { child.kill?.(signal); } catch { /* already exited */ }
406
+ }
407
+
408
+ function forwardTerminationSignals(child, signalEmitter) {
409
+ let signal = null;
410
+ let escalation = null;
411
+ const handlers = new Map();
412
+ for (const candidate of ["SIGINT", "SIGTERM", "SIGHUP"]) {
413
+ const handler = () => {
414
+ if (signal) { terminateProcessGroup(child, "SIGKILL"); return; }
415
+ signal = candidate;
416
+ terminateProcessGroup(child, candidate);
417
+ escalation = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
418
+ };
419
+ handlers.set(candidate, handler);
420
+ signalEmitter.on(candidate, handler);
421
+ }
422
+ return {
423
+ get signal() { return signal; },
424
+ cleanup() { clearTimeout(escalation); for (const [candidate, handler] of handlers) signalEmitter.removeListener(candidate, handler); },
425
+ };
284
426
  }
285
427
 
286
428
  function waitForChild(child) {
@@ -343,7 +485,20 @@ export async function cmdRun(args) {
343
485
  return;
344
486
  }
345
487
  if (args[0] === "help" || args[0] === "--help") {
346
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run cancel <run_id>");
488
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
489
+ return;
490
+ }
491
+ if (args.includes("--foreground")) {
492
+ let identity;
493
+ try { identity = await loadTelemetryIdentity(); }
494
+ catch (error) {
495
+ oneLine({ schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_identity_invalid", exit_code: EXIT.INVALID }, false);
496
+ process.exitCode = EXIT.INVALID;
497
+ return;
498
+ }
499
+ const result = await launchForegroundRun(args, { identity: { ...identity, producerVersion: VERSION } });
500
+ oneLine(result.receipt, Boolean(parseRunArgs(args).opts.json));
501
+ process.exitCode = result.exitCode;
347
502
  return;
348
503
  }
349
504
  const result = await launchRun(args);
@@ -0,0 +1,109 @@
1
+ // BOT-1316 — checked-in repo identity, separate from local credentials.
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { resolveAgentBinding } from "./wait-profile.mjs";
8
+ import { callToolJson } from "./api.mjs";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const REQUIRED_KEYS = ["schema_version", "repository", "app_slug", "profile", "expected_tenant"];
12
+ const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
13
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
14
+
15
+ export function parseTelemetryConfig(raw) {
16
+ let value;
17
+ try { value = JSON.parse(raw); } catch { throw new Error(".botbuddy/config.json is not valid JSON"); }
18
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(".botbuddy/config.json must be an object");
19
+ const keys = Object.keys(value).sort();
20
+ if (keys.length !== REQUIRED_KEYS.length || keys.some((key, index) => key !== [...REQUIRED_KEYS].sort()[index])) {
21
+ throw new Error(".botbuddy/config.json must contain exactly schema_version, repository, app_slug, profile, expected_tenant");
22
+ }
23
+ if (value.schema_version !== 1 || !REPOSITORY.test(value.repository) || !SLUG.test(value.app_slug) || !SLUG.test(value.profile) || !SLUG.test(value.expected_tenant)) {
24
+ throw new Error(".botbuddy/config.json has an invalid schema_version or identity value");
25
+ }
26
+ return { schemaVersion: 1, repository: value.repository, appSlug: value.app_slug, profile: value.profile, expectedTenant: value.expected_tenant };
27
+ }
28
+
29
+ export async function repositoryFromRemote(cwd = process.cwd()) {
30
+ const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { cwd });
31
+ const raw = stdout.trim().replace(/\.git$/, "");
32
+ const match = raw.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i);
33
+ if (!match) throw new Error("origin remote is not a canonical GitHub repository");
34
+ return match[1];
35
+ }
36
+
37
+ export async function verifyTelemetryIdentity(config, {
38
+ cwd = process.cwd(), repositoryFromRemote: remote = repositoryFromRemote, resolveBinding = resolveAgentBinding, call = callToolJson, verifyCredential = false,
39
+ } = {}) {
40
+ const repository = await remote(cwd);
41
+ if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
42
+ const binding = await resolveBinding({ cwd });
43
+ if (!binding?.token) throw new Error("telemetry requires a locally stored tenant-bound credential");
44
+ if (binding.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
45
+ // A binding file names the intended tenant; it is not proof that a rotated
46
+ // environment credential still belongs there. Ask the server's read-only
47
+ // identity endpoint before allowing a foreground child to run.
48
+ if (verifyCredential) {
49
+ const verified = await call("whoami", {}, {
50
+ auth: { "x-agent-api-key": binding.token },
51
+ tenant: config.expectedTenant,
52
+ signal: AbortSignal.timeout(2_000),
53
+ });
54
+ if (!verified?.ok || verified.isError || verified.data?.tenant_id !== config.expectedTenant) {
55
+ throw new Error("telemetry credential is not attested to the expected tenant");
56
+ }
57
+ }
58
+ return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential: binding.token };
59
+ }
60
+
61
+ export async function loadTelemetryLocation(cwd = process.cwd(), {
62
+ repositoryFromRemote: remote = repositoryFromRemote,
63
+ } = {}) {
64
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
65
+ const root = stdout.trim();
66
+ const raw = await readFile(join(root, ".botbuddy", "config.json"), "utf8");
67
+ const config = parseTelemetryConfig(raw);
68
+ const repository = await remote(root);
69
+ if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
70
+ return { root, repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile };
71
+ }
72
+
73
+ export async function loadTelemetryIdentity(cwd = process.cwd(), options = {}) {
74
+ const location = await loadTelemetryLocation(cwd, options);
75
+ const config = {
76
+ schemaVersion: 1,
77
+ repository: location.repository,
78
+ appSlug: location.appSlug,
79
+ profile: location.profile,
80
+ expectedTenant: location.tenant,
81
+ };
82
+ return verifyTelemetryIdentity(config, { cwd: location.root, ...options });
83
+ }
84
+
85
+ function credentialFingerprint(credential) {
86
+ return createHash("sha256").update(credential).digest("hex");
87
+ }
88
+
89
+ // A tenant-bound key must be proven once before foreground execution can trust
90
+ // it. The proof is keyed to a non-reversible fingerprint in the 0600 outbox
91
+ // status file, so a later rotation or cross-tenant environment value cannot
92
+ // reuse a prior attestation. Once cached, the check remains fully offline.
93
+ export async function attestTelemetryCredential(identity, outbox, { call = callToolJson } = {}) {
94
+ const fingerprint = credentialFingerprint(identity?.credential ?? "");
95
+ if (!identity?.credential || !identity?.tenant || !outbox?.hasCredentialAttestation || !outbox?.recordCredentialAttestation) {
96
+ throw new Error("telemetry credential attestation is unavailable");
97
+ }
98
+ if (await outbox.hasCredentialAttestation({ tenant: identity.tenant, fingerprint })) return { cached: true };
99
+ const verified = await call("whoami", {}, {
100
+ auth: { "x-agent-api-key": identity.credential },
101
+ tenant: identity.tenant,
102
+ signal: AbortSignal.timeout(2_000),
103
+ });
104
+ if (!verified?.ok || verified.isError || verified.data?.tenant_id !== identity.tenant) {
105
+ throw new Error("telemetry credential is not attested to the expected tenant");
106
+ }
107
+ await outbox.recordCredentialAttestation({ tenant: identity.tenant, fingerprint });
108
+ return { cached: false };
109
+ }
@@ -0,0 +1,40 @@
1
+ // BOT-1316 — direct HTTP delivery to BOT-1317. This intentionally bypasses the
2
+ // MCP tool surface: agents never need to call telemetry MCP tools.
3
+ export function executionIngestEndpoint(serverUrl = process.env.BOTBUDDY_SERVER_URL || "https://api.bot-buddy.ai/functions/v1/mcp-server") {
4
+ const url = new URL(serverUrl);
5
+ url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/execution-ingest");
6
+ return url.toString();
7
+ }
8
+
9
+ export async function deliverExecutionEvent(event, {
10
+ credential, endpoint = executionIngestEndpoint(), fetchImpl = fetch, timeoutMs = 2_000,
11
+ } = {}) {
12
+ if (typeof credential !== "string" || !credential) return { accepted: false, error: "credential_missing" };
13
+ const controller = new AbortController();
14
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
15
+ try {
16
+ const response = await fetchImpl(endpoint, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json", "x-agent-api-key": credential },
19
+ body: JSON.stringify(event),
20
+ signal: controller.signal,
21
+ });
22
+ let body = {};
23
+ try { body = await response.json(); } catch { /* typed HTTP status still controls retry */ }
24
+ if (response.status === 200 || response.status === 202) return { accepted: true, receipt: body.receipt ?? "accepted" };
25
+ // A reaper may have terminalized a run after a successful upload whose
26
+ // response was lost. BOT-1317 correctly refuses a contradictory terminal;
27
+ // retaining that record would otherwise block every later local event.
28
+ if (response.status === 409 && body.error === "terminal_conflict") return { accepted: true, receipt: "terminal_conflict_retired" };
29
+ // The queue cannot repair an event rejected by the server's contract.
30
+ // Quarantine these client errors so one malformed historical line does not
31
+ // starve later valid lifecycle events. Authentication is deliberately left
32
+ // retryable: a key can be restored or rotated without discarding evidence.
33
+ if ([400, 404, 409, 413, 422].includes(response.status)) {
34
+ return { accepted: false, permanent: true, error: body.error ?? `http_${response.status}` };
35
+ }
36
+ return { accepted: false, error: body.error ?? `http_${response.status}` };
37
+ } catch (error) {
38
+ return { accepted: false, error: controller.signal.aborted ? "timeout" : error?.code ?? "transport" };
39
+ } finally { clearTimeout(timeout); }
40
+ }
@@ -0,0 +1,327 @@
1
+ // BOT-1316 — durable, local-first lifecycle delivery for foreground wrappers.
2
+ // This module deliberately knows nothing about MCP or child output. Producers
3
+ // append a small, allowlisted lifecycle event before spawning a child; delivery
4
+ // is a separate, idempotent step against execution-ingest.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { chmod, link, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+
11
+ export const OUTBOX_SCHEMA_VERSION = 1;
12
+ export const MAX_OUTBOX_LINE_BYTES = 16 * 1024;
13
+ const LOCK_WAIT_MS = 25;
14
+ const LOCK_ATTEMPTS = 80;
15
+ const SENSITIVE = /(?:[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@[^\s/]+(?:\/[^\s]*)?|bb_[a-z0-9_-]{10,}|gh[pousr]_[a-z0-9]{16,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{16,}|xox[baprs]-[a-z0-9-]{10,}|sk-[a-z0-9]{20,}|(?:akia|asia)[a-z0-9]{16}|-----begin[a-z0-9 ]*private key-----[\s\S]*?-----end[a-z0-9 ]*private key-----)|\bbearer\s+[^\s"']+|(?:^|[^a-z0-9])(?:[a-z0-9]+_)*(?:token|api[_-]?key|private[_-]?key|password|secret)(?:_[a-z0-9]+)*\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s"']+)/gi;
16
+ const DROP_KEYS = new Set(["stdout", "stderr", "output", "raw_output", "bounded_log", "env", "environment_values", "environment_value", "secrets"]);
17
+ const CONTEXT_KEYS = new Set([
18
+ "agent", "repo", "ticket", "pr", "branch", "sha", "environment", "runner", "phase",
19
+ "worker", "shard", "retry", "current_spec", "passed", "failed", "skipped", "health", "correlations",
20
+ ]);
21
+ const CORRELATION_KEYS = new Set([
22
+ "tool_event_id", "command_receipt_id", "test_run_id", "test_execution_id", "wait_session_id",
23
+ "lane_session_id", "agent_signal_seq", "stack_lease_id",
24
+ ]);
25
+ const PROGRESS_KEYS = new Set(["completed", "total", "phase", "current_item", "worker", "shard"]);
26
+ const DIAGNOSTIC_KEYS = new Set(["code", "message", "category", "signal", "retryable"]);
27
+
28
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
30
+
31
+ function redactString(value) {
32
+ SENSITIVE.lastIndex = 0;
33
+ return value.replace(SENSITIVE, "[REDACTED]");
34
+ }
35
+
36
+ // Exported for wrappers and reporter adapters. Removing forbidden fields is more
37
+ // robust than merely redacting values: raw output is never useful lifecycle data.
38
+ export function redactTelemetry(value) {
39
+ if (Array.isArray(value)) return value.map(redactTelemetry);
40
+ if (!value || typeof value !== "object") return typeof value === "string" ? redactString(value) : value;
41
+ return Object.fromEntries(Object.entries(value)
42
+ .filter(([key]) => !DROP_KEYS.has(key.toLowerCase()))
43
+ .map(([key, item]) => [key, redactTelemetry(item)]));
44
+ }
45
+
46
+ function repositoryHash(repository) {
47
+ return hash(repository.toLowerCase()).slice(0, 32);
48
+ }
49
+
50
+ function validTenant(value) { return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value); }
51
+ function validRepository(value) { return typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); }
52
+
53
+ function eventPayload(event) {
54
+ const clean = redactTelemetry(event);
55
+ if (!clean || typeof clean !== "object" || Array.isArray(clean)) throw new Error("telemetry event must be an object");
56
+ // execution-ingest is intentionally strict. Keep this client-side allowlist so
57
+ // a reporter cannot accidentally turn arbitrary process state into telemetry.
58
+ const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs"]);
59
+ for (const key of Object.keys(clean)) if (!allowed.has(key)) delete clean[key];
60
+ // Mirror the server contract at the producer boundary. A malformed reporter
61
+ // must not create a permanently retrying record merely because it included
62
+ // a harmless but unrecognised field.
63
+ if (clean.context && typeof clean.context === "object" && !Array.isArray(clean.context)) {
64
+ clean.context = Object.fromEntries(Object.entries(clean.context).filter(([key]) => CONTEXT_KEYS.has(key)));
65
+ if (clean.context.correlations && typeof clean.context.correlations === "object" && !Array.isArray(clean.context.correlations)) {
66
+ clean.context.correlations = Object.fromEntries(Object.entries(clean.context.correlations).filter(([key]) => CORRELATION_KEYS.has(key)));
67
+ }
68
+ }
69
+ if (clean.progress && typeof clean.progress === "object" && !Array.isArray(clean.progress)) {
70
+ clean.progress = Object.fromEntries(Object.entries(clean.progress).filter(([key]) => PROGRESS_KEYS.has(key)));
71
+ }
72
+ if (clean.diagnostic && typeof clean.diagnostic === "object" && !Array.isArray(clean.diagnostic)) {
73
+ clean.diagnostic = Object.fromEntries(Object.entries(clean.diagnostic).filter(([key]) => DIAGNOSTIC_KEYS.has(key)));
74
+ }
75
+ return clean;
76
+ }
77
+
78
+ async function durableWrite(path, value, { append = false } = {}) {
79
+ const text = typeof value === "string" ? value : JSON.stringify(value);
80
+ const handle = await open(path, append ? "a" : "w", 0o600);
81
+ try {
82
+ await handle.writeFile(text);
83
+ await handle.sync();
84
+ } finally {
85
+ await handle.close();
86
+ }
87
+ await chmod(path, 0o600);
88
+ }
89
+
90
+ async function atomicJson(path, value) {
91
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
92
+ await durableWrite(temp, `${JSON.stringify(value)}\n`);
93
+ await rename(temp, path);
94
+ // Persist the directory entry, not just the file data, before reporting the
95
+ // acknowledgement checkpoint as durable.
96
+ let dir;
97
+ try { dir = await open(dirname(path), "r"); await dir.sync(); } finally { await dir?.close(); }
98
+ }
99
+
100
+ function parseRecord(line) {
101
+ let parsed;
102
+ try { parsed = JSON.parse(line); } catch {
103
+ const error = new Error("invalid_json");
104
+ error.code = "invalid_json";
105
+ throw error;
106
+ }
107
+ if (!parsed || parsed.schema_version !== OUTBOX_SCHEMA_VERSION || typeof parsed.checksum !== "string" || !parsed.event) throw new Error("invalid_record_shape");
108
+ const expected = hash(JSON.stringify(parsed.event));
109
+ if (parsed.checksum !== expected) throw new Error("checksum_mismatch");
110
+ return parsed;
111
+ }
112
+
113
+ function safeErrorClass(error) {
114
+ const raw = String(error?.code ?? error?.name ?? "delivery_failed");
115
+ return raw.replace(/[^a-z0-9_.-]/gi, "_").slice(0, 96) || "delivery_failed";
116
+ }
117
+
118
+ export async function createTelemetryOutbox({
119
+ root = join(homedir(), ".botbuddy", "telemetry"), tenant, repository, producerVersion, now = () => new Date(),
120
+ } = {}) {
121
+ if (!validTenant(tenant)) throw new Error("telemetry tenant must be a lowercase slug");
122
+ if (!validRepository(repository)) throw new Error("telemetry repository must be canonical owner/name");
123
+ if (typeof producerVersion !== "string" || !producerVersion) throw new Error("telemetry producer version is required");
124
+ const directory = join(root, tenant, repositoryHash(repository));
125
+ const path = join(directory, "outbox.ndjson");
126
+ const lockPath = join(directory, "outbox.lock");
127
+ const quarantinePath = join(directory, "outbox.quarantine.ndjson");
128
+ const statusPath = join(directory, "status.json");
129
+ const legacyImportPath = join(directory, "legacy-test-runs.imported.json");
130
+
131
+ await mkdir(directory, { recursive: true, mode: 0o700 });
132
+ await chmod(directory, 0o700);
133
+
134
+ async function acquireLock() {
135
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
136
+ try {
137
+ // Write a complete, fsynced owner record first, then atomically publish
138
+ // it with link(2). A crash can leave an unreferenced temp file but can
139
+ // never strand the shared lock name with missing PID metadata.
140
+ const candidate = `${lockPath}.${process.pid}.${Date.now()}.tmp`;
141
+ await durableWrite(candidate, JSON.stringify({ pid: process.pid, created_at: now().toISOString() }));
142
+ try { await link(candidate, lockPath); } finally { await unlink(candidate).catch(() => {}); }
143
+ return async () => { try { await unlink(lockPath); } catch { /* a process may have cleaned its own stale lock */ } };
144
+ } catch (error) {
145
+ if (error?.code !== "EEXIST") throw error;
146
+ let owner = null;
147
+ try { owner = JSON.parse(await readFile(lockPath, "utf8")); } catch { /* incomplete lock is never age-reclaimed */ }
148
+ // A lock may be reclaimed only after proving the same-host PID is gone.
149
+ if (Number.isInteger(owner?.pid) && owner.pid > 0) {
150
+ let alive = true;
151
+ try { process.kill(owner.pid, 0); } catch (probe) { alive = probe?.code !== "ESRCH"; }
152
+ if (!alive) { try { await unlink(lockPath); } catch { /* winner will retry */ } continue; }
153
+ }
154
+ await sleep(LOCK_WAIT_MS);
155
+ }
156
+ }
157
+ const err = new Error("telemetry_outbox_lock_busy");
158
+ err.code = "telemetry_outbox_lock_busy";
159
+ throw err;
160
+ }
161
+
162
+ async function readStatus() {
163
+ try { return JSON.parse(await readFile(statusPath, "utf8")); } catch { return {}; }
164
+ }
165
+
166
+ async function writeStatus(patch) {
167
+ const current = await readStatus();
168
+ await atomicJson(statusPath, { ...current, ...patch, producer_version: producerVersion, updated_at: now().toISOString() });
169
+ }
170
+
171
+ async function hasCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
172
+ const persisted = await readStatus();
173
+ const attestation = persisted.credential_attestation;
174
+ return attestation?.schema_version === 1
175
+ && attestation.tenant === attestedTenant
176
+ && attestation.fingerprint === fingerprint;
177
+ }
178
+
179
+ async function recordCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
180
+ if (!validTenant(attestedTenant) || !/^[a-f0-9]{64}$/.test(fingerprint ?? "")) throw new Error("invalid telemetry credential attestation");
181
+ await writeStatus({ credential_attestation: { schema_version: 1, tenant: attestedTenant, fingerprint, attested_at: now().toISOString() } });
182
+ }
183
+
184
+ async function readLines() {
185
+ try { return (await readFile(path, "utf8")).split("\n").filter(Boolean); } catch (error) { if (error?.code === "ENOENT") return []; throw error; }
186
+ }
187
+
188
+ async function append(event) {
189
+ const release = await acquireLock();
190
+ try {
191
+ const payload = eventPayload(event);
192
+ const record = {
193
+ schema_version: OUTBOX_SCHEMA_VERSION,
194
+ created_at: now().toISOString(),
195
+ producer_version: producerVersion,
196
+ repository,
197
+ event: payload,
198
+ delivery_attempts: 0,
199
+ checksum: hash(JSON.stringify(payload)),
200
+ };
201
+ const line = `${JSON.stringify(record)}\n`;
202
+ if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) {
203
+ const error = new Error("telemetry_event_oversized"); error.code = "telemetry_event_oversized"; throw error;
204
+ }
205
+ // append + fsync completes before the caller gets a durable receipt, which
206
+ // is the required pre-spawn barrier for foreground commands.
207
+ await durableWrite(path, line, { append: true });
208
+ await writeStatus({ last_error_class: null });
209
+ return { durable: true, path, event: payload };
210
+ } finally { await release(); }
211
+ }
212
+
213
+ async function quarantine(line, offset, reason) {
214
+ await durableWrite(quarantinePath, `${JSON.stringify({ offset, reason, quarantined_at: now().toISOString(), line: line.slice(0, MAX_OUTBOX_LINE_BYTES) })}\n`, { append: true });
215
+ }
216
+
217
+ async function replay(deliver, { maxEvents = Infinity, deadline = Infinity } = {}) {
218
+ if (typeof deliver !== "function") throw new Error("telemetry replay requires a delivery function");
219
+ const release = await acquireLock();
220
+ try {
221
+ const lines = await readLines();
222
+ const remaining = [];
223
+ let delivered = 0;
224
+ let quarantined = 0;
225
+ let halted = false;
226
+ let attempted = 0;
227
+ let offset = 0;
228
+ for (const line of lines) {
229
+ let record;
230
+ try {
231
+ if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) throw new Error("line_oversized");
232
+ record = parseRecord(line);
233
+ } catch (error) {
234
+ quarantined += 1;
235
+ await quarantine(line, offset, error.message || "invalid_record");
236
+ offset += Buffer.byteLength(line) + 1;
237
+ continue;
238
+ }
239
+ if (halted || attempted >= maxEvents || Date.now() >= deadline) { halted = true; remaining.push(record); offset += Buffer.byteLength(line) + 1; continue; }
240
+ try {
241
+ attempted += 1;
242
+ const result = await deliver(record.event, record);
243
+ if (!result?.accepted) {
244
+ if (result?.permanent) {
245
+ quarantined += 1;
246
+ await quarantine(line, offset, result.error ?? "delivery_rejected");
247
+ continue;
248
+ }
249
+ throw Object.assign(new Error("delivery_rejected"), { code: result?.error ?? "delivery_rejected" });
250
+ }
251
+ delivered += 1;
252
+ await writeStatus({ last_successful_delivery_at: now().toISOString(), last_error_class: null });
253
+ } catch (error) {
254
+ halted = true;
255
+ remaining.push({ ...record, delivery_attempts: Number(record.delivery_attempts ?? 0) + 1, last_error_class: safeErrorClass(error), last_attempt_at: now().toISOString() });
256
+ await writeStatus({ last_error_class: safeErrorClass(error) });
257
+ }
258
+ offset += Buffer.byteLength(line) + 1;
259
+ }
260
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
261
+ await durableWrite(tmp, remaining.map((record) => `${JSON.stringify(record)}\n`).join(""));
262
+ await rename(tmp, path);
263
+ let dir;
264
+ try { dir = await open(directory, "r"); await dir.sync(); } finally { await dir?.close(); }
265
+ return { delivered, remaining: remaining.length, quarantined };
266
+ } finally { await release(); }
267
+ }
268
+
269
+ async function status() {
270
+ const lines = await readLines();
271
+ let oldest = null;
272
+ let queued = 0;
273
+ for (const line of lines) {
274
+ try {
275
+ const record = parseRecord(line);
276
+ queued += 1;
277
+ if (!oldest || record.created_at < oldest) oldest = record.created_at;
278
+ } catch { queued += 1; }
279
+ }
280
+ const persisted = await readStatus();
281
+ return {
282
+ queued_count: queued,
283
+ oldest_queued_at: oldest,
284
+ last_successful_delivery_at: persisted.last_successful_delivery_at ?? null,
285
+ last_error_class: persisted.last_error_class ?? null,
286
+ producer_version: producerVersion,
287
+ path,
288
+ };
289
+ }
290
+
291
+ // The historical hook reporter writes summaries below `.botbuddy/test-runs`.
292
+ // Import them only through deterministic source/event keys; the persisted
293
+ // fingerprint ledger prevents repeated scans from growing the outbox, while
294
+ // server idempotency remains a second safety net if a machine dies mid-write.
295
+ async function importLegacyTestRunReceipts(legacyDirectory, { maxReceipts = Infinity, deadline = Infinity } = {}) {
296
+ let imported = {};
297
+ try { imported = JSON.parse(await readFile(legacyImportPath, "utf8")); } catch { /* first migration */ }
298
+ let names = [];
299
+ try { names = (await readdir(legacyDirectory)).filter((name) => name.endsWith(".json")); } catch (error) { if (error?.code === "ENOENT") return { imported: 0, skipped: 0 }; throw error; }
300
+ let added = 0;
301
+ let skipped = 0;
302
+ for (const name of names.sort()) {
303
+ if (added >= maxReceipts || Date.now() >= deadline) break;
304
+ const file = join(legacyDirectory, name);
305
+ let raw;
306
+ try { raw = await readFile(file, "utf8"); } catch { skipped += 1; continue; }
307
+ const fingerprint = hash(`${name}\0${raw}`);
308
+ if (imported[fingerprint]) { skipped += 1; continue; }
309
+ let summary;
310
+ try { summary = JSON.parse(raw); } catch { skipped += 1; continue; }
311
+ if (!summary || typeof summary !== "object" || typeof summary.runner !== "string") { skipped += 1; continue; }
312
+ const sourceRunId = `legacy-${fingerprint.slice(0, 32)}`;
313
+ const started = typeof summary.started_at === "string" ? summary.started_at : now().toISOString();
314
+ const finished = typeof summary.finished_at === "string" ? summary.finished_at : now().toISOString();
315
+ const passed = summary.verdict === "pass" || summary.exit_code === 0;
316
+ const context = { repo: repository, environment: "local", runner: summary.runner, phase: passed ? "complete" : "failed", health: "uninstrumented" };
317
+ await append({ schema_version: 1, action: "start", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:start`, producer_sequence: 0, occurred_at: started, context });
318
+ await append({ schema_version: 1, action: "terminal", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:terminal`, producer_sequence: 1, occurred_at: finished, context, outcome: passed ? "passed" : "failed" });
319
+ imported[fingerprint] = { name, imported_at: now().toISOString() };
320
+ added += 1;
321
+ }
322
+ await atomicJson(legacyImportPath, imported);
323
+ return { imported: added, skipped };
324
+ }
325
+
326
+ return { directory, path, quarantinePath, statusPath, append, replay, status, hasCredentialAttestation, recordCredentialAttestation, importLegacyTestRunReceipts };
327
+ }