@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 +1 -1
- package/src/agent-credential-store.mjs +53 -8
- package/src/auth.mjs +11 -0
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
|
}
|