@botbuddy/cli 1.40.3 → 1.40.4

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.40.3",
3
+ "version": "1.40.4",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,7 +67,7 @@ function prerequisiteRecovery(report) {
67
67
  * credential-store failure must point at unlocking the store — not `bb login` or a
68
68
  * binding repair — matching prerequisiteRecovery's wording.
69
69
  */
70
- function repairFailureRecovery(code, error = null) {
70
+ export function repairFailureRecovery(code, error = null) {
71
71
  // BOT-1891: a tier-2 MCP key the server rejected (revoked/rotated) is replaced by
72
72
  // minting a new one and re-delivering it — not by `bb login`, which a Background
73
73
  // job cannot run anyway. Name the variable, never the value.
@@ -102,6 +102,19 @@ function applyStackLeaseCapability(report, capability) {
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * Read the client key STRICTLY: a locked/failing credential store throws instead
107
+ * of reading as absent, so callers can tell "unlock it" from "sign in". Shared by
108
+ * `bb doctor` and `bb setup` (BOT-2067) so both classify the key identically.
109
+ */
110
+ export function readClientKeyStrict() {
111
+ return resolveOwnerToken({
112
+ getConfig,
113
+ keychainRead: () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
114
+ keychainReadLegacy: () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
115
+ });
116
+ }
117
+
105
118
  /** Inspect a worktree credential. Only `exports` ever contains the secret. */
106
119
  export async function doctorAgentAuth({
107
120
  cwd = process.cwd(), env = process.env, fix = false, printExports = false,
@@ -111,11 +124,7 @@ export async function doctorAgentAuth({
111
124
  // probe distinguishes a locked/failing credential store (throws → `unavailable`)
112
125
  // from a genuinely absent key (`missing`) — the strict read must not live only
113
126
  // inside selfHeal, which the `--fix` gate skips when it reports the key missing.
114
- ownerToken = () => resolveOwnerToken({
115
- getConfig,
116
- keychainRead: () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
117
- keychainReadLegacy: () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
118
- }),
127
+ ownerToken = readClientKeyStrict,
119
128
  selfHeal = selfHealAgentSession,
120
129
  hostFn = hostname, machineUuidFn = machineUuid,
121
130
  } = {}) {
package/src/commands.mjs CHANGED
@@ -27,7 +27,7 @@ import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
27
27
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
28
28
  import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
29
29
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
30
- import { doctorAgentAuth, doctorHelp, parseDoctorArgs } from "./agent-doctor.mjs";
30
+ import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repairFailureRecovery } from "./agent-doctor.mjs";
31
31
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
32
32
  import { runUsage, usageStatus } from "./usage/sync.mjs";
33
33
  import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
@@ -446,7 +446,10 @@ export function deriveCallerHint({ env = process.env, cwd = process.cwd(), git =
446
446
  return {
447
447
  caller: env.BOTBUDDY_AGENT_NAME || (cachedAgentId ? `agent ${cachedAgentId}` : repo),
448
448
  callerHarness: harness,
449
- callerTicket: ticket ? `https://linear.app/botbuddy/issue/${ticket}` : null,
449
+ // BOT-2067: only a BOT ticket lives in the botbuddy Linear workspace. Any
450
+ // other prefix (ENT on a Supply Guard branch) is sent as plain text — the
451
+ // consent screen shows it unlinked rather than linking the wrong workspace.
452
+ callerTicket: ticket ? (ticket.startsWith("BOT-") ? `https://linear.app/botbuddy/issue/${ticket}` : ticket) : null,
450
453
  };
451
454
  }
452
455
 
@@ -495,9 +498,33 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
495
498
  }
496
499
  }
497
500
 
501
+ // BOT-2067: setup's failure line names the recovery for THIS code. A server-side
502
+ // mint failure is repaired by `bb doctor --fix` without a new sign-in (BOT-1789);
503
+ // a rejected key needs a sign-in; store/binding failures keep the classifier
504
+ // `bb doctor` uses. The error message itself is never printed (it may echo a
505
+ // credential); only the code is.
506
+ function setupFailureRecovery(error) {
507
+ const code = error?.code ?? "setup_failed";
508
+ if (code === "session_mint_failed" || code === "session_mint_invalid") {
509
+ return "the BotBuddy service could not mint a session (transient/server error); run `bb doctor --fix`, which repairs this worktree's session without signing in again";
510
+ }
511
+ if ((code === "session_mint_unauthorized" && error?.mintedWith !== "mcp_key") || code === "client_key_required") {
512
+ return "the server rejected this machine's sign-in; run `bb login`, then `bb setup`";
513
+ }
514
+ if (code === "tenant_binding_required" || code === "invalid_binding") {
515
+ return "fix the committed .botbuddy-agent.json binding, then retry `bb setup`";
516
+ }
517
+ if (code === "client_key_unavailable" || code === "session_mint_unauthorized") return repairFailureRecovery(code, error);
518
+ return "retry `bb setup`";
519
+ }
520
+
521
+ const SIGN_IN_REJECTED = new Set(["session_mint_unauthorized", "client_key_required"]);
522
+
498
523
  /** Bootstrap a bound worktree without exposing credential or registration steps. */
499
524
  export async function cmdSetup(args, {
500
525
  login = doLogin,
526
+ ownerToken = readClientKeyStrict,
527
+ now = () => Date.now(),
501
528
  binding = readAgentBinding,
502
529
  clearState = clearAgentState,
503
530
  selfHeal = selfHealAgentSession,
@@ -509,16 +536,19 @@ export async function cmdSetup(args, {
509
536
  log = (line) => console.log(line),
510
537
  errorLog = (line) => console.error(line),
511
538
  } = {}) {
539
+ // BOT-2067: `--relogin` is setup-only (forces a fresh sign-in); `bb login`
540
+ // always signs in, so its parser does not accept it.
541
+ const relogin = args.includes("--relogin");
512
542
  let options;
513
543
  try {
514
- options = parseLoginArgs(args);
544
+ options = parseLoginArgs(args.filter((arg) => arg !== "--relogin"));
515
545
  } catch (error) {
516
546
  if (!(error instanceof LoginUsageError)) throw error;
517
547
  errorLog(error.message);
518
548
  return error.exitCode === 2 ? 2 : 4;
519
549
  }
520
550
  if (options.help) {
521
- log("bb setup [--no-browser] [--tenant <slug>] [--device | --loopback]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. Over SSH the sign-in uses a device code you approve from any browser (--device / --loopback force a mode). --loopback or --no-browser over SSH keeps the localhost callback on the remote host, so first forward its port with ssh -L <port>:127.0.0.1:<port> <host> (the port is in the printed URL), or setup times out. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
551
+ log("bb setup [--relogin] [--no-browser] [--tenant <slug>] [--device | --loopback]\n\nVerifies the committed .botbuddy-agent.json binding and caches this worktree's session. It reuses this machine's existing sign-in (one per machine, valid 30 days) and signs in only when there is none, it expired, or the server rejects it; --relogin forces a fresh sign-in. Over SSH the sign-in uses a device code you approve from any browser (--device / --loopback force a mode). --loopback or --no-browser over SSH keeps the localhost callback on the remote host, so first forward its port with ssh -L <port>:127.0.0.1:<port> <host> (the port is in the printed URL), or setup times out. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
522
552
  return 0;
523
553
  }
524
554
  let bound;
@@ -537,20 +567,55 @@ export async function cmdSetup(args, {
537
567
  return 4;
538
568
  }
539
569
  const tenant = bound.tenant;
570
+ // Login owns a machine/user credential, not a worktree tenant credential.
571
+ // The committed binding below is the only place setup scopes the minted
572
+ // session. Forwarding --tenant here would overwrite a multi-tenant user's
573
+ // reusable client key with a tenant-sealed one.
574
+ const signIn = () => login({
575
+ noBrowser: options.noBrowser,
576
+ device: resolveLoginDevice(options, { env, platform }),
577
+ ...callerResolver(options, env),
578
+ });
540
579
  try {
541
- // Login owns a machine/user credential, not a worktree tenant credential.
542
- // The committed binding below is the only place setup scopes the minted
543
- // session. Forwarding --tenant here would overwrite a multi-tenant user's
544
- // reusable client key with a tenant-sealed one.
545
- await login({
546
- noBrowser: options.noBrowser,
547
- device: resolveLoginDevice(options, { env, platform }),
548
- ...callerResolver(options, env),
549
- });
580
+ // BOT-2067: the client key is per machine and lasts 30 days. Reuse it rather
581
+ // than putting an OAuth approval in front of the operator for every worktree.
582
+ let signedIn = false;
583
+ if (relogin) {
584
+ await signIn();
585
+ signedIn = true;
586
+ } else {
587
+ let owner;
588
+ try {
589
+ owner = await ownerToken();
590
+ } catch {
591
+ // A locked/failing store is not "signed out": signing in again would not
592
+ // help and would open a browser for nothing.
593
+ throw Object.assign(new Error("client key unreadable"), { code: "client_key_unavailable" });
594
+ }
595
+ const valid = owner?.token && !(typeof owner.expiresAt === "number" && owner.expiresAt <= now());
596
+ if (valid) {
597
+ const expires = typeof owner.expiresAt === "number" ? ` (expires ${new Date(owner.expiresAt).toISOString().slice(0, 10)})` : "";
598
+ log(`bb setup: using this machine's existing sign-in${expires}.`);
599
+ } else {
600
+ await signIn();
601
+ signedIn = true;
602
+ }
603
+ }
550
604
  // Setup is an explicit session bootstrap/repair command. Rotate before
551
605
  // resolving so it cannot merely report an old cache as ready.
552
606
  await clearState(cwd);
553
- const session = await selfHeal({ cwd, env });
607
+ let session;
608
+ try {
609
+ session = await selfHeal({ cwd, env });
610
+ } catch (error) {
611
+ // A locally-valid key the server rejects (revoked, rotated) needs exactly
612
+ // one fresh sign-in and one retried mint. Never loop, and never re-sign a
613
+ // key this run just minted.
614
+ if (signedIn || !SIGN_IN_REJECTED.has(error?.code) || error?.mintedWith === "mcp_key") throw error;
615
+ errorLog(`bb setup: this machine's saved sign-in was rejected (${error.code}); signing in again.`);
616
+ await signIn();
617
+ session = await selfHeal({ cwd, env });
618
+ }
554
619
  // BOT-2058: register_agent reports any OTHER live session already holding
555
620
  // this ticket (heartbeat < 15m) in `ticket_signals`. This is advisory: a
556
621
  // previous harness can remain visible while a human intentionally resumes
@@ -569,7 +634,7 @@ export async function cmdSetup(args, {
569
634
  await reportUsageAgent(usageAgent, { cwd, log });
570
635
  return 0;
571
636
  } catch (error) {
572
- errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}). Retry bb setup after completing sign-in.`);
637
+ errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}); ${setupFailureRecovery(error)}.`);
573
638
  return 3;
574
639
  }
575
640
  }