@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 +1 -1
- package/src/agent-credential-store.mjs +53 -8
- package/src/agent-doctor.mjs +15 -6
- package/src/auth.mjs +11 -0
- package/src/commands.mjs +80 -15
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/agent-doctor.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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\
|
|
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
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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"})
|
|
637
|
+
errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}); ${setupFailureRecovery(error)}.`);
|
|
573
638
|
return 3;
|
|
574
639
|
}
|
|
575
640
|
}
|