@botbuddy/cli 1.40.4 → 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.4",
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];
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
  }