@botbuddy/cli 1.52.0 → 1.53.1
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 +5 -0
- package/src/agent-session.mjs +32 -5
- package/src/agent-state.mjs +27 -0
- package/src/commands.mjs +1 -1
- package/src/session-bootstrap.mjs +5 -2
- package/src/stack.mjs +2 -1
- package/src/wait.mjs +5 -1
package/package.json
CHANGED
package/src/agent-doctor.mjs
CHANGED
|
@@ -83,6 +83,11 @@ export function repairFailureRecovery(code, error = null) {
|
|
|
83
83
|
if (code === "agent_tenant_immutable") {
|
|
84
84
|
return "this worktree's agent identity belongs to another tenant; run `bb setup` to register this tenant, then `bb doctor --fix`";
|
|
85
85
|
}
|
|
86
|
+
// BOT-2415: the cached session names a different agent than the one holding this
|
|
87
|
+
// worktree's technical name. Deterministic: a retry repeats the same conflict.
|
|
88
|
+
if (code === "agent_name_conflict") {
|
|
89
|
+
return "this worktree's cached session (.botbuddy/agent-state.json) names a different agent than the one holding the worktree's technical name; move that file aside, then run `bb doctor --fix`";
|
|
90
|
+
}
|
|
86
91
|
// BOT-1750 (Codex R9 P2): `session_mint_unauthorized` means a locally-unexpired
|
|
87
92
|
// client key was revoked/rejected server-side — only a fresh login replaces it, so
|
|
88
93
|
// point at `bb login`, not a binding repair (the binding is valid).
|
package/src/agent-session.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { readKeychainSecret } from "./agent-credential-store.mjs";
|
|
|
14
14
|
import { getConfig } from "./config.mjs";
|
|
15
15
|
import { acquireStackLock } from "./stack-file-lock.mjs";
|
|
16
16
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
17
|
-
import { agentStatePath, clearAgentState, isAgentStateFresh, readAgentState, resolveMachineId, worktreeRoot, writeAgentState } from "./agent-state.mjs";
|
|
17
|
+
import { agentStatePath, cacheBelongsToHarness, clearAgentState, harnessSessionIdFromEnv, isAgentStateFresh, readAgentState, resolveMachineId, worktreeRoot, writeAgentState } from "./agent-state.mjs";
|
|
18
18
|
import { machineUuid } from "./machine-id.mjs";
|
|
19
19
|
import { machineHost } from "./machine-host.mjs";
|
|
20
20
|
|
|
@@ -184,7 +184,9 @@ export async function resolveAgentSessionCredential({
|
|
|
184
184
|
const envToken = readAgentSessionTokenEnv(env);
|
|
185
185
|
if (envToken) return { token: envToken, sessionId: explicitSessionId, agentId: null, source: "env" };
|
|
186
186
|
const cached = await readState(cwd);
|
|
187
|
-
|
|
187
|
+
// BOT-2425: a cache written for another harness session (or before the harness
|
|
188
|
+
// id was recorded) is not this session's identity; re-resolve it instead.
|
|
189
|
+
if (cached && isFresh(cached, new Date()) && cacheBelongsToHarness(cached, harnessSessionIdFromEnv(env))) {
|
|
188
190
|
warnLegacyResolvedToken(cached.agent_session_token, warn);
|
|
189
191
|
return withIdentity({ token: cached.agent_session_token, sessionId: explicitSessionId ?? cached.session_id, agentId: cached.agent_id, source: "cache" }, cached);
|
|
190
192
|
}
|
|
@@ -246,6 +248,10 @@ export async function selfHealAgentSession({
|
|
|
246
248
|
// (`force` deliberately bypasses ordinary reuse for an explicit repair.)
|
|
247
249
|
const current = await readState(cwd);
|
|
248
250
|
const renewal = rejectedCredential !== null;
|
|
251
|
+
// BOT-2425: the harness session this process runs in (exported by the
|
|
252
|
+
// SessionStart hook). A cache belongs to it only when it recorded the same id.
|
|
253
|
+
const envHarness = harnessSessionIdFromEnv(env);
|
|
254
|
+
const ownCache = !!current && cacheBelongsToHarness(current, envHarness);
|
|
249
255
|
let bound;
|
|
250
256
|
let registrationWorktree = cwd;
|
|
251
257
|
if (renewal) {
|
|
@@ -267,7 +273,7 @@ export async function selfHealAgentSession({
|
|
|
267
273
|
return withIdentity({ token: current.agent_session_token, sessionId: sessionId ?? current.session_id, agentId: current.agent_id, source: "cache", ticketSignals: null, stackLeaseCapability: null }, current);
|
|
268
274
|
}
|
|
269
275
|
}
|
|
270
|
-
if (!force && !renewal && current && isFresh(current, now())) {
|
|
276
|
+
if (!force && !renewal && current && ownCache && isFresh(current, now())) {
|
|
271
277
|
// A fresh cache is reused without a register call, so no capability verdict is
|
|
272
278
|
// available here (bb doctor --fix forces a mint to (re)establish + report it).
|
|
273
279
|
return withIdentity({ token: current.agent_session_token, sessionId: sessionId ?? current.session_id, agentId: current.agent_id, source: "cache", ticketSignals: null, stackLeaseCapability: null }, current);
|
|
@@ -318,6 +324,15 @@ export async function selfHealAgentSession({
|
|
|
318
324
|
// can reconcile a replacement agent by thread identity if the cache is lost.
|
|
319
325
|
const codexThreadId = env.CODEX_THREAD_ID || undefined;
|
|
320
326
|
const prNumber = Number.parseInt(env.BOTBUDDY_PR_NUMBER ?? env.CODEX_PR_NUMBER ?? "", 10);
|
|
327
|
+
// BOT-2425: present the session-lifetime identity. The cached agent_id is only
|
|
328
|
+
// this session's when the cache belongs to its harness session; otherwise the
|
|
329
|
+
// harness id alone lets the server re-bind to the session's agent (the cloud
|
|
330
|
+
// match for a deleted file or another worktree). A renewal keeps the cache's
|
|
331
|
+
// own identity — it replaces a rejected token, it never changes the agent.
|
|
332
|
+
const presentedAgentId = renewal || ownCache ? current?.agent_id : undefined;
|
|
333
|
+
const presentedHarness = renewal
|
|
334
|
+
? (ownCache ? envHarness ?? current?.harness_session_id : current?.harness_session_id) ?? null
|
|
335
|
+
: envHarness ?? (ownCache ? current?.harness_session_id : null) ?? null;
|
|
321
336
|
const response = await call("register_agent", {
|
|
322
337
|
name: workAgentName,
|
|
323
338
|
type: "custom",
|
|
@@ -329,7 +344,11 @@ export async function selfHealAgentSession({
|
|
|
329
344
|
machine_uuid: resolvedHardwareId || undefined,
|
|
330
345
|
branch: currentBranch || undefined,
|
|
331
346
|
ticket_id: ticketFromBranch(currentBranch),
|
|
332
|
-
agent_id:
|
|
347
|
+
agent_id: presentedAgentId ?? undefined,
|
|
348
|
+
// A rejected-token renewal may still own locks or leases under this agent.
|
|
349
|
+
// Ask the server to keep it; ordinary setup/doctor repair may adopt a holder.
|
|
350
|
+
...(renewal ? { preserve_agent_identity: true } : {}),
|
|
351
|
+
harness_session_id: presentedHarness ?? undefined,
|
|
333
352
|
harness: env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : "cli",
|
|
334
353
|
model: env.BOTBUDDY_MODEL || undefined,
|
|
335
354
|
effort: env.BOTBUDDY_EFFORT || undefined,
|
|
@@ -356,8 +375,14 @@ export async function selfHealAgentSession({
|
|
|
356
375
|
const typedCode = typeof response?.code === "string"
|
|
357
376
|
? response.code
|
|
358
377
|
: typeof data.code === "string" ? data.code : null;
|
|
359
|
-
|
|
378
|
+
// BOT-2415: a technical-name conflict is deterministic (the cached agent_id
|
|
379
|
+
// names a different agent than the one holding this worktree's name), so no
|
|
380
|
+
// retry fixes it. Recognise the typed refusal and an older server's raw 23505.
|
|
381
|
+
const nameConflict = typedCode === "agent_name_taken"
|
|
382
|
+
|| /agents_name_key/.test(String(response?.error ?? data.error ?? ""));
|
|
383
|
+
const code = ["agent_tenant_immutable", "session_identity_mismatch"].includes(typedCode)
|
|
360
384
|
? typedCode
|
|
385
|
+
: nameConflict ? "agent_name_conflict"
|
|
361
386
|
: response?.auth ? "session_mint_unauthorized" : "session_mint_failed";
|
|
362
387
|
// BOT-1891: name the credential (never its value) so `bb doctor` can point a
|
|
363
388
|
// rejected tier-2 key at rotation instead of `bb login`.
|
|
@@ -399,6 +424,8 @@ export async function selfHealAgentSession({
|
|
|
399
424
|
expires_at: expiry.toISOString(),
|
|
400
425
|
// BOT-1891: which durable credential minted this session (never the value).
|
|
401
426
|
minted_with: mintedWith,
|
|
427
|
+
// BOT-2425: the harness session this identity belongs to.
|
|
428
|
+
...(presentedHarness ? { harness_session_id: presentedHarness } : {}),
|
|
402
429
|
// BOT-1945: the non-secret incarnation fence. The durable close_session
|
|
403
430
|
// refuses a generated row without it, so a cache that drops it can never be
|
|
404
431
|
// retired by `bb bootstrap` / `bb bootstrap close` once its bearer lapses.
|
package/src/agent-state.mjs
CHANGED
|
@@ -21,6 +21,29 @@ export async function withStateLock(cwd, { timeoutMs } = {}, fn) {
|
|
|
21
21
|
try { return await fn(); } finally { lease.release(); }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** BOT-2425: the harness session this shell belongs to, lower-cased; null when
|
|
25
|
+
* unknown. The SessionStart hook exports BOTBUDDY_HARNESS_SESSION_ID; Claude Code
|
|
26
|
+
* also sets CLAUDE_CODE_SESSION_ID in every shell it spawns. A subagent
|
|
27
|
+
* (CLAUDE_CODE_CHILD_SESSION=1) inherits its PARENT's session id, so it never
|
|
28
|
+
* claims that identity: it keeps its own worktree identity, exactly as before. */
|
|
29
|
+
export function harnessSessionIdFromEnv(env = process.env) {
|
|
30
|
+
if (env?.CLAUDE_CODE_CHILD_SESSION === "1") return null;
|
|
31
|
+
for (const name of ["BOTBUDDY_HARNESS_SESSION_ID", "CLAUDE_CODE_SESSION_ID"]) {
|
|
32
|
+
const raw = typeof env?.[name] === "string" ? env[name].trim() : "";
|
|
33
|
+
if (raw) return UUID_RE.test(raw) ? raw.toLowerCase() : null;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** BOT-2425: may this cache act as THIS harness session's identity? A cache is
|
|
39
|
+
* reusable when no harness session is known, or when it was written for exactly
|
|
40
|
+
* this one. A cache with no harness id (written before BOT-2425 or by another
|
|
41
|
+
* shell) or another session's id is not this session's identity. */
|
|
42
|
+
export function cacheBelongsToHarness(state, harnessSessionId) {
|
|
43
|
+
if (!harnessSessionId) return true;
|
|
44
|
+
return typeof state?.harness_session_id === "string" && state.harness_session_id.toLowerCase() === harnessSessionId;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export const AGENT_STATE_DIR = ".botbuddy";
|
|
25
48
|
export const AGENT_STATE_FILE = "agent-state.json";
|
|
26
49
|
export const AGENT_STATE_PATH = join(AGENT_STATE_DIR, AGENT_STATE_FILE);
|
|
@@ -90,6 +113,10 @@ function validState(value) {
|
|
|
90
113
|
if (value.host !== undefined && !(typeof value.host === "string" && value.host.length > 0)) return false;
|
|
91
114
|
if (value.worktree !== undefined && !(typeof value.worktree === "string" && value.worktree.length > 0)) return false;
|
|
92
115
|
if (value.minted_at !== undefined && !(typeof value.minted_at === "string" && !Number.isNaN(Date.parse(value.minted_at)))) return false;
|
|
116
|
+
// BOT-2425: the harness session (Claude Code session / Codex thread) this
|
|
117
|
+
// cached identity belongs to — the session-lifetime identity key.
|
|
118
|
+
if (value.harness_session_id !== undefined
|
|
119
|
+
&& !(typeof value.harness_session_id === "string" && UUID_RE.test(value.harness_session_id))) return false;
|
|
93
120
|
// BOT-1650: the nonsecret incarnation fence for close/heartbeat idempotency.
|
|
94
121
|
if (value.session_token_generation !== undefined
|
|
95
122
|
&& !(typeof value.session_token_generation === "string" && UUID_RE.test(value.session_token_generation))) return false;
|
package/src/commands.mjs
CHANGED
|
@@ -568,7 +568,7 @@ function setupFailureRecovery(error) {
|
|
|
568
568
|
if (code === "tenant_binding_required" || code === "invalid_binding") {
|
|
569
569
|
return "fix the committed .botbuddy-agent.json binding, then retry `bb setup`";
|
|
570
570
|
}
|
|
571
|
-
if (code === "client_key_unavailable" || code === "session_mint_unauthorized") return repairFailureRecovery(code, error);
|
|
571
|
+
if (code === "client_key_unavailable" || code === "session_mint_unauthorized" || code === "agent_name_conflict") return repairFailureRecovery(code, error);
|
|
572
572
|
return "retry `bb setup`";
|
|
573
573
|
}
|
|
574
574
|
|
|
@@ -31,7 +31,7 @@ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
|
|
|
31
31
|
/** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
|
|
32
32
|
* does not echo the tenant, so derive it from the committed binding exactly as
|
|
33
33
|
* selfHealAgentSession does; host/worktree/minted_at are derived locally. */
|
|
34
|
-
export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost } = {}) {
|
|
34
|
+
export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost, harnessSessionId = null } = {}) {
|
|
35
35
|
const root = await worktreeRoot(cwd);
|
|
36
36
|
let tenant;
|
|
37
37
|
try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
|
|
@@ -50,6 +50,9 @@ export async function saveHarnessSession(session, { cwd = process.cwd(), machine
|
|
|
50
50
|
// so this marker no longer gates timer relaying; it still distinguishes a
|
|
51
51
|
// bootstrap-issued cache for the bootstrap bridge's own reuse checks.
|
|
52
52
|
harness_origin: "bootstrap_agent_session",
|
|
53
|
+
// BOT-2425: the session-lifetime identity key. Later CLI mints present it with
|
|
54
|
+
// the agent_id, and a cache from another harness session is never reused.
|
|
55
|
+
...(typeof harnessSessionId === "string" && harnessSessionId ? { harness_session_id: harnessSessionId.toLowerCase() } : {}),
|
|
53
56
|
...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
|
|
54
57
|
};
|
|
55
58
|
await writeAgentState(root, state);
|
|
@@ -390,7 +393,7 @@ export async function bootstrapHarnessSession({
|
|
|
390
393
|
}
|
|
391
394
|
let state;
|
|
392
395
|
try {
|
|
393
|
-
state = await store(session, { cwd: worktree });
|
|
396
|
+
state = await store(session, { cwd: worktree, harnessSessionId });
|
|
394
397
|
} catch (error) {
|
|
395
398
|
// The server session is unusable if this harness cannot persist its only
|
|
396
399
|
// credential. The freshly issued session bearer is scoped to this exact
|
package/src/stack.mjs
CHANGED
|
@@ -985,7 +985,7 @@ export function retryingStackCall({
|
|
|
985
985
|
// taxonomy so automation sees AUTH/BACKEND, not a CLI-defect INTERNAL. Credential
|
|
986
986
|
// problems are AUTH (3); a transport/server mint failure is BACKEND (5); anything
|
|
987
987
|
// unrecognized stays INTERNAL (7).
|
|
988
|
-
const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch"]);
|
|
988
|
+
const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch", "agent_name_conflict"]);
|
|
989
989
|
// BOT-1750 (Codex R11 P2): `session_mint_invalid` is an incompatible/malformed SERVER
|
|
990
990
|
// response, not a credential problem — classify it with the transport/server failures.
|
|
991
991
|
const BACKEND_ERROR_CODES = new Set(["session_mint_failed", "session_mint_invalid"]);
|
|
@@ -1423,6 +1423,7 @@ export const PARK_AUTH_BUDGET_MS = 5 * 60_000;
|
|
|
1423
1423
|
// the regular auth budget; it is not evidence that the lease changed owner.
|
|
1424
1424
|
const DEFINITIVE_REAUTH_CODES = new Set([
|
|
1425
1425
|
"session_identity_mismatch", "client_key_required", "tenant_binding_required", "session_token_malformed", "agent_tenant_immutable",
|
|
1426
|
+
"agent_name_conflict",
|
|
1426
1427
|
]);
|
|
1427
1428
|
|
|
1428
1429
|
function abortableSleep(ms, signal) {
|
package/src/wait.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
21
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
22
22
|
import { clearAgentState, hasAgentSessionRuntime, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
23
|
+
import { repairFailureRecovery } from "./agent-doctor.mjs";
|
|
23
24
|
import { touchAgentStateExpiry } from "./agent-state.mjs";
|
|
24
25
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
25
26
|
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
@@ -1679,7 +1680,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1679
1680
|
opts.credentialSource = credential.source;
|
|
1680
1681
|
} catch (error) {
|
|
1681
1682
|
const code = error?.code ?? "session_mint_failed";
|
|
1682
|
-
|
|
1683
|
+
// BOT-2415: a stale cache naming another agent is not fixed by `bb setup` + retry.
|
|
1684
|
+
const recovery = code === "agent_name_conflict"
|
|
1685
|
+
? repairFailureRecovery(code)
|
|
1686
|
+
: code === "client_key_required"
|
|
1683
1687
|
? "run `bb setup` from this project worktree, then retry"
|
|
1684
1688
|
: code === "tenant_binding_required"
|
|
1685
1689
|
? "run from a configured project worktree, then run `bb setup` and retry"
|