@botbuddy/cli 1.25.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.
@@ -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
+ }
@@ -1,116 +1,165 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { dirname, join, parse } from "node:path";
3
- import { readProfileCredential } from "./agent-credential-store.mjs";
3
+ import {
4
+ readKeychainSecret,
5
+ keychainAvailable,
6
+ DEFAULT_MCP_ENV_VAR,
7
+ } from "./agent-credential-store.mjs";
8
+ import { getConfig } from "./config.mjs";
4
9
 
5
- // Public CLI profiles remain tenant-bound machine-principal contracts.
6
- export const PROFILE_FILE = ".botbuddy-agent.json";
10
+ // BOT-1608: the committed repo file that binds a worktree to a tenant and names
11
+ // the env/Keychain var carrying its MCP credential — DATA, not an indirection
12
+ // through a hard-coded profile name (the retired PROFILES map). It co-locates
13
+ // with `.mcp.json`, which already declares the tenant via `?tenant=`.
14
+ export const AGENT_BINDING_FILE = ".botbuddy-agent.json";
7
15
 
8
- const PROFILES = Object.freeze({
9
- "botbuddy-dev": Object.freeze({
10
- tenant: "botbuddy",
11
- tokenEnv: "BOTBUDDY_BB_AGENT_KEY",
12
- }),
13
- "supplyguard-dev": Object.freeze({
14
- tenant: "supply-guard",
15
- tokenEnv: "BOTBUDDY_SG_AGENT_KEY",
16
- }),
17
- });
16
+ // Same slug shape the server accepts on `?tenant=`, and a shell env-var name.
17
+ const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
18
19
 
19
- export function getAgentProfile(name) {
20
- return PROFILES[name] ?? null;
21
- }
22
-
23
- // BOT-1593: when no profile is named (no --profile, no .botbuddy-agent.json) but
24
- // EXACTLY ONE supported profile's credential env var is populated, that slot is the
25
- // unambiguous profile. Used only by best-effort setup-error telemetry to attribute a
26
- // `profile_required` failure that still has a usable env bearer — never for the wait
27
- // itself. Returns the profile name, or null when zero or more than one slot is set
28
- // (ambiguous no attribution). Reads env only; never touches the Keychain.
29
- export function findEnvProfile(env = process.env) {
30
- const populated = Object.entries(PROFILES).filter(
31
- ([, { tokenEnv }]) => typeof env[tokenEnv] === "string" && env[tokenEnv].length > 0,
32
- );
33
- return populated.length === 1 ? populated[0][0] : null;
20
+ // Parse a `.botbuddy-agent.json` body into { tenant, mcpEnv }. Fails CLOSED on a
21
+ // malformed file and on the retired `{ profile }` shape — the profile→tenant map
22
+ // is exactly what BOT-1608 deletes, so a `{ profile }` file cannot be migrated
23
+ // without re-introducing it. The error names the new shape (a one-time edit).
24
+ export function parseAgentBinding(raw) {
25
+ let config;
26
+ try {
27
+ config = JSON.parse(raw);
28
+ } catch {
29
+ const err = new Error(`${AGENT_BINDING_FILE} is not valid JSON`);
30
+ err.code = "invalid_binding";
31
+ throw err;
32
+ }
33
+ if (typeof config?.profile === "string" && config?.tenant == null) {
34
+ const err = new Error(
35
+ `${AGENT_BINDING_FILE} uses the retired { "profile" } shape — replace it with `
36
+ + `{ "schema_version": 1, "tenant": "<slug>", "mcp_env": "${DEFAULT_MCP_ENV_VAR}" } (BOT-1608)`,
37
+ );
38
+ err.code = "binding_migration_required";
39
+ throw err;
40
+ }
41
+ if (config?.schema_version !== 1 || typeof config?.tenant !== "string" || !TENANT_SLUG_RE.test(config.tenant)) {
42
+ const err = new Error(`${AGENT_BINDING_FILE} must declare schema_version 1 and a lowercase tenant slug`);
43
+ err.code = "invalid_binding";
44
+ throw err;
45
+ }
46
+ let mcpEnv = DEFAULT_MCP_ENV_VAR;
47
+ if (config.mcp_env != null) {
48
+ if (typeof config.mcp_env !== "string" || !ENV_VAR_RE.test(config.mcp_env)) {
49
+ const err = new Error(`${AGENT_BINDING_FILE} mcp_env must be an UPPER_SNAKE_CASE variable name`);
50
+ err.code = "invalid_binding";
51
+ throw err;
52
+ }
53
+ mcpEnv = config.mcp_env;
54
+ }
55
+ return { tenant: config.tenant, mcpEnv };
34
56
  }
35
57
 
36
- export async function findProfileName(cwd) {
58
+ // Read the committed `.botbuddy-agent.json`, walking up from cwd to the fs root.
59
+ // Returns { tenant, mcpEnv } or null when no file exists anywhere above cwd; a
60
+ // malformed / retired-shape file throws (see parseAgentBinding).
61
+ export async function readAgentBinding(cwd = process.cwd()) {
37
62
  let dir = cwd;
38
63
  const root = parse(dir).root;
39
64
  while (true) {
65
+ let raw = null;
40
66
  try {
41
- const raw = await readFile(join(dir, PROFILE_FILE), "utf8");
42
- const config = JSON.parse(raw);
43
- if (config?.schema_version !== 1 || typeof config?.profile !== "string") {
44
- throw new Error(`${PROFILE_FILE} must contain schema_version 1 and a profile`);
45
- }
46
- return config.profile;
67
+ raw = await readFile(join(dir, AGENT_BINDING_FILE), "utf8");
47
68
  } catch (err) {
48
69
  if (err?.code !== "ENOENT") throw err;
49
70
  }
71
+ if (raw != null) return parseAgentBinding(raw);
50
72
  if (dir === root) return null;
51
73
  dir = dirname(dir);
52
74
  }
53
75
  }
54
76
 
55
- export async function resolveAgentProfile({
77
+ // The tenants the current login credential can reach (from `botbuddy login`),
78
+ // used for the "sole configured tenant" resolution rung. Best-effort: an empty
79
+ // list (config not loaded, or a tenant-sealed token) simply skips that rung.
80
+ function defaultConfiguredTenants() {
81
+ const cfg = getConfig() ?? {};
82
+ return Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
83
+ }
84
+
85
+ // The credential for a resolved binding: the mcp_env var (loaded from the shell,
86
+ // or the Keychain item whose service name IS that var). Never reads the Keychain
87
+ // when it is unavailable (BOTBUDDY_NO_KEYCHAIN / non-darwin) — the env var is the
88
+ // sole source there.
89
+ async function defaultReadCredential(mcpEnv, env) {
90
+ const fromEnv = typeof env[mcpEnv] === "string" && env[mcpEnv] ? env[mcpEnv] : null;
91
+ if (fromEnv) return fromEnv;
92
+ if (!keychainAvailable()) return null;
93
+ return readKeychainSecret(mcpEnv);
94
+ }
95
+
96
+ // BOT-1604 AC9 / BOT-1608 resolution order for in-repo agent-context commands
97
+ // (`bb-wait`, `botbuddy pw`, …): explicit `--tenant`/`--env` → the committed
98
+ // `.botbuddy-agent.json` → the sole configured login tenant if exactly one →
99
+ // else FAIL CLOSED with an actionable error. Never a silent default.
100
+ //
101
+ // Returns { tenant, tokenEnv, token } — tokenEnv is the resolved mcp_env var and
102
+ // token the credential read from it (or an explicit override). `token` may be
103
+ // null (no credential exported yet); the caller decides whether that is fatal.
104
+ export async function resolveAgentBinding({
56
105
  cwd = process.cwd(),
57
106
  env = process.env,
58
- explicitProfile = null,
107
+ explicitTenant = null,
108
+ explicitEnv = null,
59
109
  explicitToken = null,
60
- readCredential = readProfileCredential,
110
+ readBinding = readAgentBinding,
111
+ configuredTenants = defaultConfiguredTenants,
112
+ readCredential = defaultReadCredential,
61
113
  } = {}) {
62
- const name = explicitProfile || await findProfileName(cwd);
63
- if (!name) {
114
+ let binding = null;
115
+ let bindingError = null;
116
+ try {
117
+ binding = await readBinding(cwd);
118
+ } catch (err) {
119
+ bindingError = err;
120
+ }
121
+
122
+ let tenant = explicitTenant || binding?.tenant || null;
123
+ // A malformed/retired committed file must NOT be silently bypassed by the
124
+ // sole-configured-tenant fallback (Codex P2): when readBinding threw and no
125
+ // explicit --tenant overrode it, skip the fallback so bindingError surfaces
126
+ // below. An explicit --tenant is still an intentional override.
127
+ if (!tenant && !bindingError) {
128
+ const tenants = configuredTenants();
129
+ if (tenants.length === 1) tenant = tenants[0];
130
+ }
131
+ if (!tenant) {
132
+ // A malformed committed file is the more specific failure — surface it so the
133
+ // operator fixes the file rather than chasing a generic "no binding" message.
134
+ if (bindingError) throw bindingError;
64
135
  const err = new Error(
65
- `no BotBuddy agent profile found (add ${PROFILE_FILE} or pass --profile)`,
136
+ `no BotBuddy tenant resolved commit a ${AGENT_BINDING_FILE} `
137
+ + `({"schema_version":1,"tenant":"<slug>","mcp_env":"${DEFAULT_MCP_ENV_VAR}"}) `
138
+ + "or pass --tenant <slug>",
66
139
  );
67
- err.code = "profile_required";
140
+ err.code = "agent_binding_required";
68
141
  throw err;
69
142
  }
70
- const profile = getAgentProfile(name);
71
- if (!profile) {
72
- const err = new Error(`unknown BotBuddy agent profile '${name}'`);
73
- err.code = "unknown_profile";
74
- throw err;
75
- }
76
- return {
77
- name,
78
- tenant: profile.tenant,
79
- tokenEnv: profile.tokenEnv,
80
- // A freshly bootstrapped credential must take effect even if a calling shell
81
- // still has a stale profile variable. `--token` remains the explicit escape
82
- // hatch for a one-off credential.
83
- token: explicitToken || await readCredential(name) || env[profile.tokenEnv] || null,
84
- };
143
+
144
+ const tokenEnv = explicitEnv || binding?.mcpEnv || DEFAULT_MCP_ENV_VAR;
145
+ const token = explicitToken || (await readCredential(tokenEnv, env)) || null;
146
+ return { tenant, tokenEnv, token };
85
147
  }
86
148
 
87
- export function withPrincipalReceipt(receipt, profile, registration = {}) {
88
- if (!profile) {
89
- // BOT-1572: a session-token wait has no profile, but the receipt must still
90
- // carry principal.session_id / agent_id / tenant_id derived by the relay from
91
- // the token. Only attach when the relay actually echoed a session identity.
92
- if (registration.sessionId || registration.agentId || registration.sessionTenant != null) {
93
- return {
94
- ...receipt,
95
- principal: {
96
- profile: null,
97
- tenant_id: registration.sessionTenant ?? null,
98
- agent_id: registration.agentId ?? null,
99
- session_id: registration.sessionId ?? null,
100
- session_agent_id: registration.sessionAgentId ?? null,
101
- },
102
- };
103
- }
104
- return receipt;
105
- }
149
+ // Attach the resolved principal to a wait receipt. With the profile bridge gone
150
+ // there is no profile name — the tenant comes from the binding (or the relay's
151
+ // session identity). Only attached when a principal identity is known.
152
+ export function withPrincipalReceipt(receipt, binding, registration = {}) {
153
+ const hasSession =
154
+ registration.sessionId || registration.agentId || registration.sessionTenant != null;
155
+ if (!binding && !hasSession) return receipt;
106
156
  return {
107
157
  ...receipt,
108
158
  principal: {
109
- profile: profile.name,
110
- tenant_id: registration.sessionTenant ?? profile.tenant,
159
+ tenant_id: registration.sessionTenant ?? binding?.tenant ?? null,
111
160
  agent_id: registration.agentId ?? null,
112
161
  // BOT-1467: the arming session's agent, when the relay attributed the wait
113
- // to it instead of the profile agent (null for an ordinary profile wait).
162
+ // to it (null for an ordinary binding wait).
114
163
  session_id: registration.sessionId ?? null,
115
164
  session_agent_id: registration.sessionAgentId ?? null,
116
165
  },