@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 +1 -1
- package/src/agent-credential-store.mjs +53 -8
- package/src/auth.mjs +11 -0
- package/src/stack.mjs +24 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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) {
|