@botbuddy/cli 1.40.3 → 1.40.5

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.5",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import { userInfo } from "node:os";
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import { existsSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
@@ -41,6 +42,28 @@ export class ProfileCredentialStoreError extends Error {
41
42
  }
42
43
  }
43
44
 
45
+ export const KEYCHAIN_UNLOCK_GUIDANCE = "the macOS login Keychain is locked in this SSH session — run `security unlock-keychain ~/Library/Keychains/login.keychain-db` on this host, then re-run. Alternatively set `BOTBUDDY_NO_KEYCHAIN=1` to use the explicit 0600-file store.";
46
+
47
+ // `security` reports this locked-SSH-session condition inconsistently: some
48
+ // paths say authorization was canceled, while the failed write in BOT-2078 was
49
+ // exit 36 / "User interaction is not allowed". Keep one classifier so every
50
+ // Keychain reader and writer gives the same recovery rather than suggesting an
51
+ // unnecessary new login.
52
+ export function isLockedKeychainError(error) {
53
+ const detail = String(error?.stderr ?? error?.message ?? error ?? "");
54
+ return Number(error?.code) === 36
55
+ || /(?:security\s+)?exited\s+36\b/i.test(detail)
56
+ || /user interaction is not allowed/i.test(detail)
57
+ || /authorization was cancell?ed/i.test(detail);
58
+ }
59
+
60
+ function unavailableKeychainError(cause) {
61
+ return new ProfileCredentialStoreError(KEYCHAIN_UNLOCK_GUIDANCE, {
62
+ cause,
63
+ code: "client_key_unavailable",
64
+ });
65
+ }
66
+
44
67
  function writePasswordPrompt(command, args, password, spawnProcess) {
45
68
  return new Promise((resolve, reject) => {
46
69
  // BOT-1566: `security -w` (prompt mode) reads the secret with
@@ -134,14 +157,7 @@ export async function writeKeychainSecret(service, token, {
134
157
  { cause: error, code: "keychain_item_exists" },
135
158
  );
136
159
  }
137
- // `security` cancels its authorization when the login keychain is locked in
138
- // a headless session; guide the user to unlock it rather than re-login.
139
- if (/authorization was cancell?ed/i.test(detail)) {
140
- throw new ProfileCredentialStoreError(
141
- "macOS Keychain authorization was canceled — unlock your login keychain (`security unlock-keychain`) and retry; a headless session needs it already unlocked",
142
- { cause: error },
143
- );
144
- }
160
+ if (isLockedKeychainError(error)) throw unavailableKeychainError(error);
145
161
  throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
146
162
  }
147
163
  const stored = await readSecret(service);
@@ -183,6 +199,12 @@ export async function readKeychainSecret(service, { execFileImpl = execFileAsync
183
199
  const { stdout } = await execFileImpl("security", args);
184
200
  return stdout.trim() || null;
185
201
  } catch (error) {
202
+ // Non-strict readers participate in durable-credential resolution, where a
203
+ // valid MCP key may still be available from the worktree environment. Keep
204
+ // their historical null result for a locked Keychain so that fallback stays
205
+ // reachable. Strict readers (login/migration paths) must surface unlock
206
+ // guidance instead of treating the inaccessible slot as empty.
207
+ if (strict && isLockedKeychainError(error)) throw unavailableKeychainError(error);
186
208
  // Non-strict (default, back-compat): any failure reads as "no value". Strict:
187
209
  // only a genuine not-found is null; a real read failure is re-thrown so the
188
210
  // caller can fail closed instead of mistaking it for an empty slot.
@@ -191,6 +213,29 @@ export async function readKeychainSecret(service, { execFileImpl = execFileAsync
191
213
  }
192
214
  }
193
215
 
216
+ // Test the actual store before an OAuth flow creates a one-time authorization
217
+ // grant. A fresh random service avoids touching any durable credential. The
218
+ // cleanup attempt lives in finally so a successful probe never leaves a scratch
219
+ // item behind, even if removal itself is denied.
220
+ export async function preflightKeychainWrite({
221
+ platform = process.platform,
222
+ exists = existsSync,
223
+ env = process.env,
224
+ random = randomUUID,
225
+ writeSecret = writeKeychainSecret,
226
+ deleteSecret = deleteKeychainSecret,
227
+ } = {}) {
228
+ if (!keychainAvailable(platform, exists, env)) return false;
229
+ const service = `BOTBUDDY_LOGIN_PREFLIGHT_${random()}`;
230
+ const probe = random();
231
+ try {
232
+ await writeSecret(service, probe);
233
+ return true;
234
+ } finally {
235
+ await deleteSecret(service).catch(() => false);
236
+ }
237
+ }
238
+
194
239
  export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync, keychain = null } = {}) {
195
240
  if (!service) return false;
196
241
  const args = ["delete-generic-password", "-s", service, "-a", userInfo().username];
@@ -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/auth.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  pollDeviceToken,
18
18
  } from "./oauth-device.mjs";
19
19
  import { hostname as osHostname } from "node:os";
20
+ import { preflightKeychainWrite } from "./agent-credential-store.mjs";
20
21
 
21
22
  // A login that could not complete. commands.mjs turns this into one terminal
22
23
  // error line; the core never calls process.exit so it stays unit-testable.
@@ -134,7 +135,17 @@ export async function doLogin(options = {}, deps = {}) {
134
135
  now = () => Date.now(),
135
136
  persist = persistOwnerToken,
136
137
  createReceiver = createLoopbackReceiver,
138
+ preflightKeychain = preflightKeychainWrite,
137
139
  } = deps;
140
+ // BOT-2078: minting a device grant before discovering that macOS cannot
141
+ // retain it wastes a human's one-time approval. Probe the active Keychain
142
+ // first; on Linux and explicit BOTBUDDY_NO_KEYCHAIN fallback this is a no-op.
143
+ try {
144
+ await preflightKeychain();
145
+ } catch (error) {
146
+ if (error?.code === "client_key_unavailable") throw new LoginError(error.message);
147
+ throw error;
148
+ }
138
149
  if (options.device === true) {
139
150
  return doDeviceLogin(options, { ...deps, serverUrl, fetchImpl, saveConfig, getConfig, log, errorLog, now, persist });
140
151
  }
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
  }