@botbuddy/cli 1.68.1 → 1.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.69.1
4
+
5
+ - The bb-status band no longer says "Couldn't read this session's epic" for a ticket that is in no Linear project. Its epic read now asks only for the band's own tickets' epics (`get_epic_health` `ticket_keys`), so a ticket with no project shows no epic row. Before, the read covered the whole team and timed out for Supply Guard.
6
+
3
7
  ## 1.68.1
4
8
 
5
9
  - The bb-status band now binds a Claude Code session whose repo has no BotBuddy SessionStart hook, such as Supply Guard worktrees, instead of showing "BB identity belongs to another session". When the worktree has no session cache, an unbound one or an expired one, `bb status-band read` bootstraps this session once (ticketless, the same bootstrap `bb bootstrap` runs) and reads again. A cache another live session holds is never taken over.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.68.1",
3
- "source_commit": "910247e2ef698463c9c5e1e4949caac7c6bb343c",
4
- "source_sha256": "cd971a2647c6424648a30ec47b69c5eff464f408888cd66b5ea9d23a37cf6b83",
2
+ "release_version": "1.69.1",
3
+ "source_commit": "335523d662691ecab635e495cb9dee3c789f4252",
4
+ "source_sha256": "193f763f6dfac67793088d383cc74e9ba7e347a62a05f1fb7b71ef3bed453834",
5
5
  "target": "darwin-arm64",
6
- "sha256": "9cbc2b6f427cc6070f27492bd1693525bcbbc4dad66aacdd2d82c5fb7f64d834"
6
+ "sha256": "6a868a403b3cae7241b2c313972bad80992d858cabd3cbc1f60fd1d740833499"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.68.1",
3
- "source_commit": "910247e2ef698463c9c5e1e4949caac7c6bb343c",
4
- "source_sha256": "cd971a2647c6424648a30ec47b69c5eff464f408888cd66b5ea9d23a37cf6b83",
2
+ "release_version": "1.69.1",
3
+ "source_commit": "335523d662691ecab635e495cb9dee3c789f4252",
4
+ "source_sha256": "193f763f6dfac67793088d383cc74e9ba7e347a62a05f1fb7b71ef3bed453834",
5
5
  "target": "linux-x64",
6
- "sha256": "2ace98b6231c22ae2cf20940ef61c5d08df4ed6af7735f8109bc91fe829eb1fb"
6
+ "sha256": "e2471a43a4fbb4f193cb1ff2cfcabdee9bcd3e6e4b24c5f1ce17d73cb290807f"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.68.1",
4
- "source_commit": "910247e2ef698463c9c5e1e4949caac7c6bb343c",
5
- "source_sha256": "cd971a2647c6424648a30ec47b69c5eff464f408888cd66b5ea9d23a37cf6b83",
3
+ "release_version": "1.69.1",
4
+ "source_commit": "335523d662691ecab635e495cb9dee3c789f4252",
5
+ "source_sha256": "193f763f6dfac67793088d383cc74e9ba7e347a62a05f1fb7b71ef3bed453834",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "9cbc2b6f427cc6070f27492bd1693525bcbbc4dad66aacdd2d82c5fb7f64d834",
11
+ "sha256": "6a868a403b3cae7241b2c313972bad80992d858cabd3cbc1f60fd1d740833499",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "2ace98b6231c22ae2cf20940ef61c5d08df4ed6af7735f8109bc91fe829eb1fb",
18
+ "sha256": "e2471a43a4fbb4f193cb1ff2cfcabdee9bcd3e6e4b24c5f1ce17d73cb290807f",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.68.1",
3
+ "version": "1.69.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -1543,9 +1543,11 @@ export function mcpRecovery(code) {
1543
1543
  case "reserved_env_var":
1544
1544
  return "that --env name belongs to another BotBuddy credential (it would clobber that Keychain slot) — pick a distinct MCP var like BOTBUDDY_MCP_KEY_WORK.";
1545
1545
  case "env_var_in_use":
1546
- return "that Keychain slot already holds a key — revoke the old server key (`botbuddy mcp revoke <agent_id>`) if it is still live, then clear the local slot with `security delete-generic-password -s <NAME>`; or use a different --env, or pass --force to replace it in place.";
1546
+ return "that Keychain slot already holds a key — revoke the old server key (`botbuddy mcp revoke <agent_id>`) if it is still live, then clear the local slot with `security delete-generic-password -s <NAME>`; or use a different --env, or pass --force to replace it in place (the replaced key is revoked).";
1547
+ case "previous_key_unconfirmed":
1548
+ return "couldn't confirm which key --force would replace (BotBuddy unreachable or the reply was unclear), so nothing was minted — retry when BotBuddy answers; the old key is unchanged.";
1547
1549
  case "keychain_read_failed":
1548
- return "couldn't read the Keychain to check the slot (locked/unavailable) — unlock it (`security unlock-keychain`) and retry, or pass --force to skip the check.";
1550
+ return "couldn't read the Keychain slot (locked/unavailable) — unlock it (`security unlock-keychain`) and retry. --force also reads the slot, to revoke the key it replaces.";
1549
1551
  case "not_an_mcp_key":
1550
1552
  return "that agent_id is not a bb_mcp_ key — pass the agent_id `botbuddy mcp setup` returned.";
1551
1553
  case "not_found":
@@ -1665,7 +1667,10 @@ export async function cmdMcp(args, {
1665
1667
  // Parse inside the try so a bad --env (missing value / reserved name) becomes
1666
1668
  // an error receipt rather than an uncaught throw (Codex P2).
1667
1669
  const opts = parseMcpSetupArgs(args);
1668
- log(JSON.stringify(await setup({ envVar: opts.envVar, tenant: opts.tenant, label: opts.label, expiryDays: opts.expiryDays, force: opts.force })));
1670
+ const receipt = await setup({ envVar: opts.envVar, tenant: opts.tenant, label: opts.label, expiryDays: opts.expiryDays, force: opts.force });
1671
+ log(JSON.stringify(receipt));
1672
+ // BOT-3196 AC-4: a replaced key that could not be revoked is still live.
1673
+ if (receipt?.previous_key_revoked === false) setExitCode(3);
1669
1674
  } catch (error) {
1670
1675
  const code = (error instanceof McpKeyError || error instanceof McpKeyStoreError) ? error.code : "mint_failed";
1671
1676
  const extra = {};
package/src/mcp-key.mjs CHANGED
@@ -214,11 +214,19 @@ export async function setupMcpKey({
214
214
  writeKeychain = writeKeychainSecret,
215
215
  readKeychain = readKeychainSecret,
216
216
  mcpUrl = SERVER_URL,
217
+ env = process.env,
217
218
  } = {}) {
218
219
  const pinned = await resolveAuth();
219
220
  if (pinned?.error) throw new McpKeyError("not_authenticated");
220
221
  const auth = pinned?.auth ?? null;
221
222
 
223
+ // BOT-3196 AC-4: --force REPLACES the key in this slot, so the key it replaces
224
+ // must stop working at once. Overwriting only the local secret left the old
225
+ // bb_mcp_ row live on the server (its 401 never came). Identify the old key
226
+ // BEFORE minting (fail closed: an unreadable slot is refused, never treated
227
+ // as empty), then revoke it once the new key is safely stored.
228
+ const replacing = force ? await findReplacedMcpKey({ envVar, env, call, keychainAvail, readKeychain }) : null;
229
+
222
230
  // Codex P1/P2: refuse to mint into an OCCUPIED Keychain slot unless --force.
223
231
  // Re-running setup would `security -U`-overwrite the local secret AFTER a
224
232
  // second server credential is minted, silently orphaning the previous, still
@@ -298,9 +306,36 @@ export async function setupMcpKey({
298
306
  }
299
307
  }
300
308
 
309
+ // BOT-3196 AC-4: revoke the replaced key now that the new one is stored (or,
310
+ // with no Keychain, about to be printed). A failed revoke never hides the new
311
+ // key: the receipt says so, and the command exits non-zero (cmdMcp).
312
+ let replaced = {};
313
+ if (replacing) {
314
+ let revoked = false;
315
+ try {
316
+ const undo = await call("revoke_mcp_key", { agent_id: replacing.agentId }, { auth, ...(replacing.tenant ? { tenant: replacing.tenant } : {}) });
317
+ // Only BotBuddy's affirmative answer for THIS key counts: a 2xx carrying
318
+ // { ok: true, revoked: true, agent_id }. A gateway 5xx with a JSON body can
319
+ // parse as an empty tool answer and must never read as revoked (Codex P2).
320
+ revoked = undo?.ok === true && !undo.isError
321
+ && typeof undo.status === "number" && undo.status >= 200 && undo.status < 300
322
+ && undo.data?.ok === true && undo.data?.revoked === true && undo.data?.agent_id === replacing.agentId;
323
+ } catch {
324
+ revoked = false;
325
+ }
326
+ replaced = revoked
327
+ ? { replaced_agent_id: replacing.agentId, previous_key_revoked: true }
328
+ : {
329
+ replaced_agent_id: replacing.agentId,
330
+ previous_key_revoked: false,
331
+ previous_key_recovery: `the replaced key is STILL LIVE — revoke it with \`botbuddy mcp revoke ${replacing.agentId}${replacing.tenant ? ` --tenant ${replacing.tenant}` : ""}\`.`,
332
+ };
333
+ }
334
+
301
335
  return {
302
336
  schema_version: 1,
303
337
  outcome: stored ? "installed" : "minted_no_keychain",
338
+ ...replaced,
304
339
  // Codex P1: storing ≠ exporting. When stored in the Keychain, the key is not
305
340
  // yet in the environment the MCP client reads — spell out the loader so the
306
341
  // operator doesn't launch Claude/Codex with $envVar unset.
@@ -320,6 +355,38 @@ export async function setupMcpKey({
320
355
  };
321
356
  }
322
357
 
358
+ /**
359
+ * BOT-3196 AC-4: the live bb_mcp_ key a forced setup replaces, or null. Reads
360
+ * the slot (the Keychain item, or with no Keychain the variable itself) and asks
361
+ * the server who that key is. A key the server rejects with a plain 401, or one
362
+ * that is not a bb_mcp_ key, has nothing to revoke. An unreadable Keychain or
363
+ * an unconfirmed identity fails closed before anything is minted.
364
+ */
365
+ export async function findReplacedMcpKey({ envVar, env = process.env, call = callToolJson, keychainAvail = keychainAvailable, readKeychain = readKeychainSecret }) {
366
+ let previous = null;
367
+ if (keychainAvail()) {
368
+ try {
369
+ previous = await readKeychain(envVar, { strict: true });
370
+ } catch (error) {
371
+ throw new McpKeyError("keychain_read_failed", { detail: error?.message ?? String(error) });
372
+ }
373
+ } else {
374
+ previous = env?.[envVar] ?? null;
375
+ }
376
+ if (typeof previous !== "string" || !previous.startsWith("bb_mcp_")) return null;
377
+ const who = await call("whoami", {}, { auth: { "x-agent-api-key": previous } });
378
+ // Only a plain 401 (no structured refusal code) proves the old key is already
379
+ // dead. Anything else — a transport error, a 5xx, a tenant refusal, a reply
380
+ // without the key's identity — leaves its state unknown, so refuse before
381
+ // minting rather than orphan a key that may still be live (Codex P2, #1625).
382
+ if (who?.ok === false && who.auth === true && who.status === 401 && !who.code) return null;
383
+ const agentId = who?.ok && !who.isError ? who.data?.agent?.id : null;
384
+ if (typeof agentId !== "string" || !agentId) {
385
+ throw new McpKeyError("previous_key_unconfirmed", { detail: who?.error ?? "no identity in the whoami reply" });
386
+ }
387
+ return { agentId, tenant: typeof who.data?.tenant_id === "string" ? who.data.tenant_id : null };
388
+ }
389
+
323
390
  // Map a revoke_mcp_key server refusal to a CLI error code.
324
391
  function mapRevokeCode(code) {
325
392
  if (code === "MCP_TENANT_PIN_REQUIRED" || code === "MCP_TENANT_SELECTION_REQUIRED") return "tenant_pin_required";
@@ -266,7 +266,10 @@ export async function readStatus({
266
266
  : await Promise.all([
267
267
  bb("list_work_items", { entity: "ticket", limit: 200 }),
268
268
  bb("get_pr_state", { include_closed: true }),
269
- skipEpic ? null : bb("get_epic_health", { team_key: prefix }, 15000),
269
+ // BOT-3152 AC-1: only this band's tickets' epics. A whole-team read timed
270
+ // out for Supply Guard, so a ticket in no project read as "couldn't read".
271
+ // team_key stays for a server that predates ticket_keys.
272
+ skipEpic ? null : bb("get_epic_health", { team_key: prefix, ticket_keys: keys }, 15000),
270
273
  ]);
271
274
  let linearTicket = null;
272
275
  const blockers = [];