@botbuddy/cli 1.40.4 → 1.40.6

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.6",
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
  }
package/src/stack.mjs CHANGED
@@ -88,6 +88,8 @@ ${bold("up OPTIONS")}
88
88
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
89
89
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
90
90
  --no-wait If the host is full, print the queue position and exit (don't park).
91
+ --claim-only LOCAL-EXEC only: wait for and hold a provisioning lease without starting
92
+ Supabase. Resume with the identical \`stack up --local-exec\` command.
91
93
  --wait-capacity <dur> Opt-in (BOT-2024): on a TRANSIENT HOST_CAPACITY_SOURCE_STALE refusal, park
92
94
  on the server's \`bb wait 'capacity:…'\` up to <dur> (90s, 10m, 1h) and
93
95
  re-attempt admission once when it matches. Sustained refusals, the
@@ -189,7 +191,7 @@ export function parseStackArgs(argv) {
189
191
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
190
192
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
191
193
  timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
192
- connectionFile: null, noWait: false, localExec: false, waitCapacity: null,
194
+ connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null,
193
195
  dockerContext: null, dockerEndpoint: null,
194
196
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
195
197
  };
@@ -271,6 +273,7 @@ export function parseStackArgs(argv) {
271
273
  break;
272
274
  }
273
275
  case "--no-wait": opts.noWait = true; break;
276
+ case "--claim-only": opts.claimOnly = true; break;
274
277
  case "--local-exec": opts.localExec = true; break;
275
278
  case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
276
279
  case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
@@ -305,6 +308,9 @@ export function parseStackArgs(argv) {
305
308
  if (opts.waitCapacity != null && !["up", "run"].includes(command)) {
306
309
  errors.push("--wait-capacity is valid only with `stack up` or `stack run`");
307
310
  }
311
+ if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
312
+ errors.push("--claim-only is valid only with `stack up --local-exec`");
313
+ }
308
314
  if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
309
315
  errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
310
316
  }
@@ -1696,6 +1702,23 @@ export async function cmdUp(opts, {
1696
1702
  }
1697
1703
  reportLaneStatus({ phase: "testing", reason: "provisioning" });
1698
1704
 
1705
+ // BOT-2089: an expensive hermetic preparation (template restore) must not lose
1706
+ // an admission slot after the queue grants it. Keep this deliberately narrow:
1707
+ // only a local-exec caller may hold an unreserved provisioning lease, and it
1708
+ // must resume through the normal `stack up` path that reserves before start.
1709
+ if (opts.claimOnly) {
1710
+ if (state !== "provisioning") {
1711
+ return emitResult(buildReceipt({
1712
+ command: "up", outcome: "error", lease_id: leaseId, state, slot,
1713
+ error: `claim-only lease is ${state}, not provisioning`,
1714
+ }), opts, EXIT.LEASE_FAILED);
1715
+ }
1716
+ return emitResult(buildReceipt({
1717
+ command: "up", outcome: "claimed", lease_id: leaseId, state, host_key: d.host_key, slot,
1718
+ idle_ttl_secs: d.idle_ttl_secs, resource_name: d.resource_name,
1719
+ }), opts, EXIT.OK);
1720
+ }
1721
+
1699
1722
  // Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
1700
1723
  if (state !== "active") {
1701
1724
  if (opts.localExec) {