@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.25.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": [
@@ -1,15 +1,8 @@
1
- import { chmod, link, mkdir, readFile, rename, stat, unlink, utimes, writeFile } from "node:fs/promises";
2
- import { homedir, userInfo } from "node:os";
1
+ import { userInfo } from "node:os";
3
2
  import { execFile, spawn } from "node:child_process";
4
3
  import { promisify } from "node:util";
5
- import { dirname, join } from "node:path";
6
- import { randomUUID } from "node:crypto";
7
4
  import { existsSync } from "node:fs";
8
5
 
9
- const STORE_SCHEMA_VERSION = 1;
10
- const LOCK_RETRY_MS = 10;
11
- const LOCK_MAX_ATTEMPTS = 100;
12
- const STALE_LOCK_MS = 30_000;
13
6
  const execFileAsync = promisify(execFile);
14
7
 
15
8
  // BOT-1520: is the macOS Keychain backend usable on this host? The CLI stores
@@ -24,20 +17,17 @@ export function keychainAvailable(platform = process.platform, exists = existsSy
24
17
  return platform === "darwin" && exists("/usr/bin/security");
25
18
  }
26
19
 
27
- export function ensureProfileCredentialBackend({ platform = process.platform, exists = existsSync } = {}) {
28
- if (!keychainAvailable(platform, exists)) {
29
- throw new Error("profile setup requires the macOS Keychain credential backend");
30
- }
31
- }
32
-
33
- export function profileCredentialEnvironment(profile) {
34
- return profile === "botbuddy-dev" ? "BOTBUDDY_BB_AGENT_KEY"
35
- : profile === "supplyguard-dev" ? "BOTBUDDY_SG_AGENT_KEY" : null;
36
- }
20
+ // BOT-1607 / BOT-1608: the canonical env/Keychain var a `.mcp.json` references.
21
+ // The `.botbuddy-agent.json` binding's `mcp_env` defaults to this; `botbuddy mcp`
22
+ // mints into it (or an `--env <NAME>` override). Defined here — the one module
23
+ // both mcp-key.mjs and wait-profile.mjs already import — so neither has to import
24
+ // the other (an api.mjs ↔ wait-profile.mjs ↔ mcp-key.mjs cycle).
25
+ export const DEFAULT_MCP_ENV_VAR = "BOTBUDDY_MCP_KEY";
37
26
 
38
- function keychainService(profile) {
39
- return profileCredentialEnvironment(profile);
40
- }
27
+ // The pre-1607 var `profile setup` planted the reused bb_agent_ session token in.
28
+ // A `.mcp.json` still referencing it keeps authenticating for one release; the
29
+ // CLI recognises it as a DEPRECATED alias and tells the operator to migrate.
30
+ export const LEGACY_MCP_ENV_VAR = "BOTBUDDY_BB_AGENT_KEY";
41
31
 
42
32
  // A keychain/credential-store write failed AFTER OAuth + registration already
43
33
  // succeeded. It carries its own code so the CLI never mislabels a storage
@@ -212,163 +202,3 @@ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsy
212
202
  return false;
213
203
  }
214
204
  }
215
-
216
- export async function writeKeychain(profile, token, options = {}) {
217
- const service = keychainService(profile);
218
- if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
219
- return writeKeychainSecret(service, token, options);
220
- }
221
-
222
- async function readKeychain(profile) {
223
- // Honor the keychain escape hatch (BOTBUDDY_NO_KEYCHAIN / non-darwin) so the
224
- // per-profile environment key is the sole credential source there.
225
- if (!keychainAvailable()) return null;
226
- return readKeychainSecret(keychainService(profile));
227
- }
228
-
229
- function profileCredentialStorePath(home = homedir()) {
230
- return join(home, ".botbuddy", "agent-profiles.json");
231
- }
232
-
233
- async function readStore({ home = homedir() } = {}) {
234
- try {
235
- const raw = await readFile(profileCredentialStorePath(home), "utf8");
236
- const parsed = JSON.parse(raw);
237
- if (parsed?.schema_version !== STORE_SCHEMA_VERSION || !parsed.profiles || typeof parsed.profiles !== "object") {
238
- return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
239
- }
240
- return parsed;
241
- } catch (error) {
242
- if (error?.code === "ENOENT") return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
243
- throw error;
244
- }
245
- }
246
-
247
- function validIdentityEntry(entry) {
248
- return entry && typeof entry === "object" && typeof entry.name === "string";
249
- }
250
-
251
- async function withStoreLock(path, operation) {
252
- const lockPath = `${path}.lock`;
253
- for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
254
- const ownerPath = `${lockPath}.${process.pid}.${randomUUID()}`;
255
- try {
256
- await writeFile(ownerPath, JSON.stringify({ pid: process.pid, owner_path: ownerPath }), { flag: "wx", mode: 0o600 });
257
- await link(ownerPath, lockPath);
258
- const ownerStat = await stat(ownerPath);
259
- const ownsLock = async () => {
260
- try {
261
- const current = await stat(lockPath);
262
- return current.dev === ownerStat.dev && current.ino === ownerStat.ino;
263
- } catch (error) {
264
- if (error?.code === "ENOENT") return false;
265
- throw error;
266
- }
267
- };
268
- const refresh = setInterval(() => {
269
- const now = new Date();
270
- void ownsLock().then((owned) => owned && utimes(lockPath, now, now)).catch(() => {});
271
- }, Math.floor(STALE_LOCK_MS / 3));
272
- try {
273
- return await operation();
274
- } finally {
275
- clearInterval(refresh);
276
- if (await ownsLock()) await unlink(lockPath).catch(() => {});
277
- await unlink(ownerPath).catch(() => {});
278
- }
279
- } catch (error) {
280
- await unlink(ownerPath).catch(() => {});
281
- if (error?.code !== "EEXIST") throw error;
282
- try {
283
- const lockStat = await stat(lockPath);
284
- if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
285
- const lock = JSON.parse(await readFile(lockPath, "utf8"));
286
- let ownerAlive = false;
287
- try { process.kill(lock.pid, 0); ownerAlive = true; } catch (ownerError) { ownerAlive = ownerError?.code === "EPERM"; }
288
- if (!ownerAlive) {
289
- await unlink(lockPath);
290
- if (typeof lock.owner_path === "string") await unlink(lock.owner_path).catch(() => {});
291
- }
292
- }
293
- } catch (lockError) {
294
- if (lockError?.code !== "ENOENT") throw lockError;
295
- }
296
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
297
- }
298
- }
299
- throw new Error("profile credential store is busy; retry profile setup");
300
- }
301
-
302
- export async function readProfileIdentity(profile, options = {}) {
303
- const store = await readStore(options);
304
- const entry = store.profiles[profile];
305
- if (!validIdentityEntry(entry) || typeof entry.tenant !== "string" || typeof entry.agent_id !== "string") return null;
306
- return { agentId: entry.agent_id, tenant: entry.tenant, name: entry.name };
307
- }
308
-
309
- export async function readProfileRetryIdentity(profile, options = {}) {
310
- const store = await readStore(options);
311
- const entry = store.profiles[profile];
312
- if (!validIdentityEntry(entry)) return null;
313
- const agentId = typeof entry.agent_id === "string" ? entry.agent_id : entry.pending_agent_id;
314
- return typeof agentId === "string" ? { agentId, name: entry.name } : null;
315
- }
316
-
317
- export async function readProfileCredential(profile, options = {}) {
318
- return (options.keychainRead ?? readKeychain)(profile);
319
- }
320
-
321
- async function writeProfileMetadata(profile, entry, {
322
- home = homedir(),
323
- onlyIfNoAttestedIdentity = false,
324
- afterWrite = null,
325
- } = {}) {
326
- const path = profileCredentialStorePath(home);
327
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
328
- await withStoreLock(path, async () => {
329
- const store = await readStore({ home });
330
- const current = store.profiles[profile];
331
- if (onlyIfNoAttestedIdentity && validIdentityEntry(current) && typeof current.tenant === "string" && typeof current.agent_id === "string") {
332
- return false;
333
- }
334
- const next = {
335
- schema_version: STORE_SCHEMA_VERSION,
336
- profiles: { ...store.profiles, [profile]: entry },
337
- };
338
- const temporaryPath = `${path}.${randomUUID()}.tmp`;
339
- await writeFile(temporaryPath, JSON.stringify(next), { mode: 0o600 });
340
- await chmod(temporaryPath, 0o600);
341
- await rename(temporaryPath, path);
342
- await chmod(path, 0o600);
343
- await afterWrite?.();
344
- return true;
345
- });
346
- }
347
-
348
- export async function recordProfileRetryIdentity({ profile, agentId, name }, options = {}) {
349
- if (![profile, agentId, name].every((value) => typeof value === "string" && value.length > 0)) {
350
- throw new Error("profile retry identity requires profile, agentId, and name");
351
- }
352
- // This entry is explicitly not tenant-attested and contains no credential. It
353
- // may only be used to reconnect with the profile's fixed tenant on retry.
354
- await writeProfileMetadata(profile, { pending_agent_id: agentId, name }, {
355
- ...options,
356
- onlyIfNoAttestedIdentity: true,
357
- });
358
- }
359
-
360
- export async function installProfileCredential({ profile, tenant, agentId, name, token }, { home = homedir(), keychainWrite = writeKeychain } = {}) {
361
- if (![profile, tenant, agentId, name, token].every((value) => typeof value === "string" && value.length > 0)) {
362
- throw new Error("profile credential store requires profile, tenant, agentId, name, and token");
363
- }
364
- // Keep non-secret identity metadata even if Keychain is momentarily locked.
365
- // The next setup call reconnects this same server agent instead of minting a
366
- // random orphan; credentials are never written to this file.
367
- await writeProfileMetadata(profile, { tenant, agent_id: agentId, name }, {
368
- home,
369
- // Keep this Keychain operation in the same lock as the metadata write. A
370
- // later setup cannot leave metadata from one agent beside another agent's
371
- // credential, while a failed write still leaves resumable metadata.
372
- afterWrite: () => keychainWrite(profile, token),
373
- });
374
- }
package/src/api.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getConfig, SERVER_URL } from "./config.mjs";
2
2
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
3
- import { findProfileName, getAgentProfile } from "./wait-profile.mjs";
3
+ import { readAgentBinding } from "./wait-profile.mjs";
4
4
  import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
5
5
 
6
6
  // Same slug shape the server accepts on `?tenant=`.
@@ -10,7 +10,7 @@ const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
10
10
  // server refuses unpinned (MCP_TENANT_PIN_REQUIRED). Generic commands
11
11
  // (`botbuddy call`, resources) therefore derive a default pin:
12
12
  // 1. BOTBUDDY_TENANT (explicit; malformed → usage error, never sent),
13
- // 2. the repo's .botbuddy-agent.json profile tenant (walks up from cwd),
13
+ // 2. the repo's .botbuddy-agent.json tenant (walks up from cwd),
14
14
  // 3. the token's sole reachable tenant,
15
15
  // 4. none — the server's error names the fix.
16
16
  // A sealed (tenant-mode) or pre-1571 token gets NO derived pin: the server
@@ -21,8 +21,7 @@ export async function resolveDefaultTenantPin({
21
21
  getConfig: getCfg = getConfig,
22
22
  env = process.env,
23
23
  cwd = process.cwd(),
24
- findProfile = findProfileName,
25
- profileFor = getAgentProfile,
24
+ readBinding = readAgentBinding,
26
25
  } = {}) {
27
26
  const explicit = typeof env.BOTBUDDY_TENANT === "string" ? env.BOTBUDDY_TENANT.trim() : "";
28
27
  if (explicit) {
@@ -31,10 +30,11 @@ export async function resolveDefaultTenantPin({
31
30
  }
32
31
  const cfg = getCfg() ?? {};
33
32
  if (cfg.token_tenant_mode !== "user") return null;
34
- let profileName = null;
35
- try { profileName = await findProfile(cwd); } catch { profileName = null; }
36
- const profileTenant = profileName ? profileFor(profileName)?.tenant ?? null : null;
37
- if (profileTenant) return profileTenant;
33
+ // A malformed / retired-shape .botbuddy-agent.json never breaks a generic call —
34
+ // fall through to the sole-tenant rung rather than throwing here.
35
+ let bindingTenant = null;
36
+ try { bindingTenant = (await readBinding(cwd))?.tenant ?? null; } catch { bindingTenant = null; }
37
+ if (bindingTenant) return bindingTenant;
38
38
  const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
39
39
  return tenants.length === 1 ? tenants[0] : null;
40
40
  }
package/src/auth.mjs CHANGED
@@ -187,7 +187,7 @@ export async function doLogin(options = {}, deps = {}) {
187
187
  }.`,
188
188
  );
189
189
  if (tenantMode === "user") {
190
- log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy profile setup")} pins its own.`);
190
+ log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy mcp setup --tenant <slug>")} mints a key pinned to its own.`);
191
191
  } else if (tenantId) {
192
192
  log(` Token scope: ${cyan("tenant")} — sealed to ${cyan(tenantId)}. Run ${cyan("botbuddy login")} without ${dim("--tenant")} for a token that reaches all your tenants.`);
193
193
  }
@@ -23,7 +23,7 @@ import {
23
23
  readKeychainSecret,
24
24
  deleteKeychainSecret,
25
25
  } from "./agent-credential-store.mjs";
26
- import { resolveAgentProfile } from "./wait-profile.mjs";
26
+ import { resolveAgentBinding } from "./wait-profile.mjs";
27
27
 
28
28
  // BOT-1574: the per-machine CLIENT KEY. `botbuddy login` mints a user-mode OAuth
29
29
  // token (BOT-1571) — tenant-agnostic, pins one tenant per request against the
@@ -267,15 +267,20 @@ export async function clearOwnerToken({
267
267
  }
268
268
  }
269
269
 
270
- // Resolve the tenant-bound agent key from the profile Keychain store (or the
271
- // per-profile environment key), without throwing when no profile is configured.
272
- // This is the sole source of the agent credential now that `register` is gone.
270
+ // Resolve the agent-context credential from the worktree binding the
271
+ // `.botbuddy-agent.json` mcp_env var (env or Keychain) without throwing when no
272
+ // binding is configured. This is the fallback the owner OAuth token defers to for
273
+ // tool-call auth now that `register` and `profile setup` are both gone.
273
274
  export async function resolveAgentKey(options = {}) {
274
275
  try {
275
- const { token } = await resolveAgentProfile(options);
276
+ const { token } = await resolveAgentBinding(options);
276
277
  return token || null;
277
278
  } catch (error) {
278
- if (error?.code === "profile_required" || error?.code === "unknown_profile") return null;
279
+ // A missing / malformed / retired-shape binding is not authenticated — let the
280
+ // caller fall through to its "not authenticated" path rather than throwing.
281
+ if (["agent_binding_required", "invalid_binding", "binding_migration_required"].includes(error?.code)) {
282
+ return null;
283
+ }
279
284
  throw error;
280
285
  }
281
286
  }
@@ -66,14 +66,14 @@ function isAddressInUseError(text) {
66
66
 
67
67
  export async function runBridge(args) {
68
68
  const cfg = getConfig(); // non-secret metadata only (agent_name)
69
- // BOT-1520: authenticate from the Keychain — the profile agent key
70
- // (`botbuddy profile setup`) or the owner OAuth token (`botbuddy login`).
69
+ // BOT-1520: authenticate from the Keychain — the MCP key
70
+ // (`botbuddy mcp setup`, BOT-1608) or the owner OAuth token (`botbuddy login`).
71
71
  // Resolve ONCE here for the life of the bridge and cache it, so the relay
72
72
  // helpers don't shell out to `security` on every request.
73
73
  const agentKey = await resolveAgentKey();
74
74
  const owner = agentKey ? null : await resolveOwnerToken({ getConfig });
75
75
  if (!agentKey && !owner) {
76
- die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy profile setup <profile>")}).`);
76
+ die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy mcp setup")}).`);
77
77
  }
78
78
  bridgeAuth = { agentKey, owner };
79
79