@botbuddy/cli 1.52.0 → 1.53.0
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-session.mjs +22 -4
- package/src/agent-state.mjs +27 -0
- package/src/session-bootstrap.mjs +5 -2
package/package.json
CHANGED
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,8 @@ 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
|
+
harness_session_id: presentedHarness ?? undefined,
|
|
333
349
|
harness: env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : "cli",
|
|
334
350
|
model: env.BOTBUDDY_MODEL || undefined,
|
|
335
351
|
effort: env.BOTBUDDY_EFFORT || undefined,
|
|
@@ -399,6 +415,8 @@ export async function selfHealAgentSession({
|
|
|
399
415
|
expires_at: expiry.toISOString(),
|
|
400
416
|
// BOT-1891: which durable credential minted this session (never the value).
|
|
401
417
|
minted_with: mintedWith,
|
|
418
|
+
// BOT-2425: the harness session this identity belongs to.
|
|
419
|
+
...(presentedHarness ? { harness_session_id: presentedHarness } : {}),
|
|
402
420
|
// BOT-1945: the non-secret incarnation fence. The durable close_session
|
|
403
421
|
// refuses a generated row without it, so a cache that drops it can never be
|
|
404
422
|
// 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;
|
|
@@ -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
|