@botbuddy/cli 1.40.2 → 1.40.4
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-doctor.mjs +15 -6
- package/src/commands.mjs +80 -15
- package/src/wait-checkpoint.mjs +22 -8
package/package.json
CHANGED
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/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
|
}
|
package/src/wait-checkpoint.mjs
CHANGED
|
@@ -107,6 +107,20 @@ async function processStartedAt(pid) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// The real process-instance probe behind the ownership fence. Tests inject a
|
|
111
|
+
// deterministic one instead of spawning `ps` (BOT-2031).
|
|
112
|
+
const systemProcessProbe = Object.freeze({
|
|
113
|
+
async isAlive(pid) {
|
|
114
|
+
try { process.kill(pid, 0); }
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error?.code === "ESRCH") return false;
|
|
117
|
+
return true; // EPERM is deliberately conservative.
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
},
|
|
121
|
+
startedAt: processStartedAt,
|
|
122
|
+
});
|
|
123
|
+
|
|
110
124
|
function installFenceExitHook() {
|
|
111
125
|
if (installedFenceExitHook) return;
|
|
112
126
|
installedFenceExitHook = true;
|
|
@@ -120,7 +134,11 @@ function installFenceExitHook() {
|
|
|
120
134
|
});
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
export function createWaitCheckpointStore({
|
|
137
|
+
export function createWaitCheckpointStore({
|
|
138
|
+
root = process.cwd(),
|
|
139
|
+
now = () => new Date(),
|
|
140
|
+
processProbe = systemProcessProbe,
|
|
141
|
+
} = {}) {
|
|
124
142
|
const worktree = resolve(root);
|
|
125
143
|
const directory = join(worktree, ".botbuddy", "waits");
|
|
126
144
|
const pathFor = (id) => {
|
|
@@ -184,12 +202,8 @@ export function createWaitCheckpointStore({ root = process.cwd(), now = () => ne
|
|
|
184
202
|
return lock;
|
|
185
203
|
};
|
|
186
204
|
const lockIsLive = async (lock) => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (error?.code === "ESRCH") return false;
|
|
190
|
-
return true; // EPERM is deliberately conservative.
|
|
191
|
-
}
|
|
192
|
-
const startedAt = await processStartedAt(lock.pid);
|
|
205
|
+
if (!await processProbe.isAlive(lock.pid)) return false;
|
|
206
|
+
const startedAt = await processProbe.startedAt(lock.pid);
|
|
193
207
|
// A live PID without a matching process-instance identity is never ownership proof.
|
|
194
208
|
return startedAt != null && startedAt === lock.process_started_at;
|
|
195
209
|
};
|
|
@@ -310,7 +324,7 @@ export function createWaitCheckpointStore({ root = process.cwd(), now = () => ne
|
|
|
310
324
|
const localWaitId = safeId(id);
|
|
311
325
|
await ensureDirectory();
|
|
312
326
|
const path = lockPathFor(localWaitId);
|
|
313
|
-
const processStartedAtValue = await
|
|
327
|
+
const processStartedAtValue = await processProbe.startedAt(process.pid);
|
|
314
328
|
if (!processStartedAtValue) {
|
|
315
329
|
throw new WaitCheckpointError("wait_owner_unknown", "could not establish this process identity for wait recovery");
|
|
316
330
|
}
|