@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.
- package/package.json +2 -1
- package/src/agent-credential-store.mjs +11 -181
- package/src/api.mjs +8 -8
- package/src/auth.mjs +1 -1
- package/src/cli-credentials.mjs +11 -6
- package/src/codex-bridge.mjs +3 -3
- package/src/commands.mjs +94 -140
- package/src/config.mjs +1 -1
- package/src/mcp-key.mjs +23 -22
- package/src/pw/coordinator.mjs +0 -5
- package/src/pw/run.mjs +32 -32
- package/src/run.mjs +165 -10
- package/src/stack.mjs +3 -3
- package/src/telemetry-config.mjs +109 -0
- package/src/telemetry-delivery.mjs +40 -0
- package/src/telemetry-outbox.mjs +327 -0
- package/src/wait-profile.mjs +128 -79
- package/src/wait.mjs +88 -226
- package/src/profile-bootstrap.mjs +0 -252
|
@@ -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
|
+
}
|
package/src/wait-profile.mjs
CHANGED
|
@@ -1,116 +1,165 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, parse } from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
explicitTenant = null,
|
|
108
|
+
explicitEnv = null,
|
|
59
109
|
explicitToken = null,
|
|
60
|
-
|
|
110
|
+
readBinding = readAgentBinding,
|
|
111
|
+
configuredTenants = defaultConfiguredTenants,
|
|
112
|
+
readCredential = defaultReadCredential,
|
|
61
113
|
} = {}) {
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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 = "
|
|
140
|
+
err.code = "agent_binding_required";
|
|
68
141
|
throw err;
|
|
69
142
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|