@botbuddy/cli 1.68.0 → 1.69.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/CHANGELOG.md +4 -0
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/commands.mjs +8 -3
- package/src/mcp-key.mjs +67 -0
- package/src/status-band-read.mjs +59 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.68.1
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
3
7
|
## 1.68.0
|
|
4
8
|
|
|
5
9
|
- The bb-status band for Claude Code now ships in this package. `bb setup` and `bb update` install it once, at user scope, through `bb status-band install`. They point the `botbuddy-labs` marketplace at the plugin inside the installed CLI, so every worktree shows the band with no plugin command, and no repo checkout is needed. The band's reads and live wake run from the installed CLI (`bb status-band read` and `bb status-band relay`).
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.0",
|
|
3
|
+
"source_commit": "843a7cf3ec82011015ab46bc9f40454083375df2",
|
|
4
|
+
"source_sha256": "058e319761db47ddd1b9967bbb6016e01689eef53d18c8639f7fc8cb9d1a6ded",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "8938908530358c262670cb5c3b1114ce993c187f0dfe27943cc9a6498e9d4fb9"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.0",
|
|
3
|
+
"source_commit": "843a7cf3ec82011015ab46bc9f40454083375df2",
|
|
4
|
+
"source_sha256": "058e319761db47ddd1b9967bbb6016e01689eef53d18c8639f7fc8cb9d1a6ded",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "dc8d2b619f15baa928a500a2420160c9a67924f71defc3d16f32617d0a6543a0"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.69.0",
|
|
4
|
+
"source_commit": "843a7cf3ec82011015ab46bc9f40454083375df2",
|
|
5
|
+
"source_sha256": "058e319761db47ddd1b9967bbb6016e01689eef53d18c8639f7fc8cb9d1a6ded",
|
|
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": "
|
|
11
|
+
"sha256": "8938908530358c262670cb5c3b1114ce993c187f0dfe27943cc9a6498e9d4fb9",
|
|
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": "
|
|
18
|
+
"sha256": "dc8d2b619f15baa928a500a2420160c9a67924f71defc3d16f32617d0a6543a0",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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";
|
package/src/status-band-read.mjs
CHANGED
|
@@ -473,7 +473,48 @@ export async function readStatus({
|
|
|
473
473
|
: null,
|
|
474
474
|
};
|
|
475
475
|
}
|
|
476
|
-
|
|
476
|
+
// BOT-3152 AC-5: a repo with no SessionStart bootstrap hook (Supply Guard) leaves
|
|
477
|
+
// the worktree cache missing, unbound (no harness id) or expired, so the band
|
|
478
|
+
// binds this session itself, once per read, through the same bootstrap
|
|
479
|
+
// `bb bootstrap` runs. A cache another harness session holds is never
|
|
480
|
+
// bootstrapped over: that session owns the worktree's identity.
|
|
481
|
+
const BINDABLE = new Set(["session_missing", "session_mismatch", "session_expired"]);
|
|
482
|
+
async function bindable(cwd, harnessSessionId, now) {
|
|
483
|
+
let state;
|
|
484
|
+
try {
|
|
485
|
+
state = JSON.parse(await readFile(join(await realpath(cwd), ".botbuddy/agent-state.json"), "utf8"));
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return error?.code === "ENOENT";
|
|
488
|
+
}
|
|
489
|
+
const holder = typeof state?.harness_session_id === "string" ? state.harness_session_id.toLowerCase() : "";
|
|
490
|
+
if (!holder || holder === harnessSessionId.toLowerCase()) return true;
|
|
491
|
+
return !(Date.parse(state.expires_at) > now());
|
|
492
|
+
}
|
|
493
|
+
async function bootstrapBand({ cwd, harnessSessionId, branch }) {
|
|
494
|
+
const [{ bootstrapHarnessSession }, { getConfig }, { hostname }] = await Promise.all([
|
|
495
|
+
import("./session-bootstrap.mjs"),
|
|
496
|
+
import("./config.mjs"),
|
|
497
|
+
import("node:os"),
|
|
498
|
+
]);
|
|
499
|
+
await bootstrapHarnessSession({
|
|
500
|
+
name: `${getConfig()?.agent_name || `botbuddy-${hostname()}`}-${harnessSessionId.slice(0, 8)}`,
|
|
501
|
+
type: "claude",
|
|
502
|
+
harnessSessionId,
|
|
503
|
+
cwd,
|
|
504
|
+
branch,
|
|
505
|
+
noTicketReason: "bb-status band bound a Claude Code session its repo did not bootstrap",
|
|
506
|
+
harness: "botbuddy-claude-band",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function main(args, {
|
|
511
|
+
out = process.stdout,
|
|
512
|
+
cwd = process.cwd(),
|
|
513
|
+
env = process.env,
|
|
514
|
+
read = readStatus,
|
|
515
|
+
bootstrap = bootstrapBand,
|
|
516
|
+
now = () => Date.now(),
|
|
517
|
+
} = {}) {
|
|
477
518
|
try {
|
|
478
519
|
args = [...args];
|
|
479
520
|
const skipEpic = args.at(-1) === "--skip-epic";
|
|
@@ -484,13 +525,23 @@ export async function main(args, { out = process.stdout, cwd = process.cwd(), en
|
|
|
484
525
|
(args.length === 4 && args[2] !== "--ticket-id")
|
|
485
526
|
)
|
|
486
527
|
refuse("invalid_arguments");
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
528
|
+
const request = { cwd, harnessSessionId: args[1], ticketId: args[3], linearKey: env.LINEAR_BB_API_KEY, skipEpic };
|
|
529
|
+
let snapshot;
|
|
530
|
+
try {
|
|
531
|
+
snapshot = await read(request);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (!BINDABLE.has(error?.message) || !UUID.test(args[1]) || !(await bindable(cwd, args[1], now))) throw error;
|
|
534
|
+
let branch = null;
|
|
535
|
+
try {
|
|
536
|
+
branch = (await execFileAsync("git", ["branch", "--show-current"], { cwd })).stdout.trim() || null;
|
|
537
|
+
} catch { /* detached or not a repo: bootstrap without a branch */ }
|
|
538
|
+
try {
|
|
539
|
+
await bootstrap({ cwd, harnessSessionId: args[1].toLowerCase(), branch });
|
|
540
|
+
} catch {
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
snapshot = await read(request);
|
|
544
|
+
}
|
|
494
545
|
out.write(JSON.stringify(snapshot) + "\n");
|
|
495
546
|
return 0;
|
|
496
547
|
} catch (error) {
|