@botbuddy/cli 1.31.0 → 1.31.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.
@@ -0,0 +1,375 @@
1
+ // BOT-1650 — the only CLI-side bridge between a harness and the tier-3 token.
2
+ // It never prints or exports a secret; the runtime store is consumed by `bb wait`.
3
+
4
+ import os from "node:os";
5
+ import { callToolJson } from "./api.mjs";
6
+ import { readAgentState, touchAgentStateExpiry, withStateLock, worktreeRoot, writeAgentState } from "./agent-state.mjs";
7
+ import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup } from "./agent-cleanup.mjs";
8
+ import { readAgentBinding } from "./wait-profile.mjs";
9
+ import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
10
+ import { getConfig } from "./config.mjs";
11
+
12
+ // BOT-1650 → BOT-1649 reconciliation: the harness session lives in the ONE
13
+ // unified client cache (agent-state.mjs), written in main's `agent_session_token`
14
+ // schema. These adapters map between the bootstrap RPC response shape (which uses
15
+ // `session_token`) and the on-disk state, so `bb wait`/`run`/`test`/`pw` can
16
+ // authenticate from the same file via resolveAgentSessionCredential.
17
+
18
+ /** Read the per-worktree cache back in the RPC-shaped form the bridge expects. */
19
+ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
20
+ const state = await readAgentState(cwd);
21
+ if (!state) return null;
22
+ return {
23
+ ...state,
24
+ session_token: state.agent_session_token,
25
+ ...(state.session_token_generation ? { session_token_generation: state.session_token_generation } : {}),
26
+ };
27
+ }
28
+
29
+ /** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
30
+ * does not echo the tenant, so derive it from the committed binding exactly as
31
+ * selfHealAgentSession does; host/worktree/minted_at are derived locally. */
32
+ export async function saveHarnessSession(session, { cwd = process.cwd() } = {}) {
33
+ const root = await worktreeRoot(cwd);
34
+ let tenant;
35
+ try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
36
+ const state = {
37
+ schema_version: 1,
38
+ agent_id: session.agent_id,
39
+ agent_session_token: session.session_token,
40
+ session_id: session.session_id ?? null,
41
+ ...(tenant ? { tenant } : {}),
42
+ host: os.hostname(),
43
+ worktree: root,
44
+ minted_at: new Date().toISOString(),
45
+ expires_at: session.expires_at,
46
+ // BOT-1650 (Codex round-28 P2): mark provenance so a TIMER wait only relays for
47
+ // a bootstrap-issued cache (the cross-shell receipt-of-principal contract). A
48
+ // bb setup / self-heal cache shares this schema but omits this marker, so a
49
+ // plain timer from such a shell stays fully offline.
50
+ harness_origin: "bootstrap_agent_session",
51
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
52
+ };
53
+ await writeAgentState(root, state);
54
+ // Return the RPC-shaped session so callers reading `.session_token` are unchanged.
55
+ return session;
56
+ }
57
+
58
+ /** Guarded, lock-free clear used inside the bootstrap transaction (re-exported so
59
+ * the Codex bridge and `bb setup` close path share one implementation). */
60
+ export const clearHarnessSession = clearAgentSessionIfMatches;
61
+
62
+ /** Serialize the whole remote-bootstrap-plus-save on the unified per-worktree
63
+ * state lock — the SAME `<agent-state>.lock` selfHealAgentSession takes — so a
64
+ * bootstrap and a self-heal can never both write the cache. */
65
+ export async function withHarnessBootstrapLock({ cwd = process.cwd() } = {}, operation) {
66
+ const root = await worktreeRoot(cwd);
67
+ return withStateLock(root, {}, () => operation(root));
68
+ }
69
+
70
+ export async function durableCleanupOptions({
71
+ resolveAgentKeyFn = resolveAgentKey,
72
+ resolveOwnerTokenFn = resolveOwnerToken,
73
+ getConfigFn = getConfig,
74
+ } = {}) {
75
+ const owner = await resolveOwnerTokenFn({ getConfig: getConfigFn });
76
+ if (owner && !(owner.expiresAt && owner.expiresAt <= Date.now())) {
77
+ return { auth: { Authorization: `Bearer ${owner.token}` } };
78
+ }
79
+ const agentKey = await resolveAgentKeyFn();
80
+ if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
81
+ return null;
82
+ }
83
+
84
+ function cleanupError(response) {
85
+ return response?.data?.error || response?.data?.message || response?.error || "remote session cleanup could not be confirmed";
86
+ }
87
+
88
+ async function closeWithDurableCredential(call, args, options) {
89
+ try {
90
+ const response = await call("close_session", args, options);
91
+ return { response, closed: !!response?.ok && !response.isError };
92
+ } catch (error) {
93
+ return { response: null, closed: false, error };
94
+ }
95
+ }
96
+
97
+ // BOT-1650 (Codex round-24/25): a session was admitted but could not be retired.
98
+ // Persist a cleanup marker so a later bootstrap retires it, then ALWAYS throw a
99
+ // failure that preserves the primary cause, the retirement failure, AND any
100
+ // persistence failure — never swallowing a marker-write failure (round-25 P2),
101
+ // which would silently revert to leaving an orphaned holder. Never returns.
102
+ async function recordOrphanAndFail({ session, cwd, retirementError, primaryError }) {
103
+ let persistError;
104
+ try {
105
+ await recordPendingCleanup(
106
+ { session_id: session.session_id, session_token_generation: session.session_token_generation },
107
+ { cwd },
108
+ );
109
+ } catch (error) {
110
+ persistError = error;
111
+ }
112
+ const parts = [];
113
+ if (primaryError) parts.push(primaryError.message);
114
+ parts.push(`the admitted session could not be retired: ${retirementError.message}`);
115
+ if (persistError) parts.push(`and its cleanup marker could not be persisted: ${persistError.message}`);
116
+ const err = new Error(parts.join("; "));
117
+ err.cause = primaryError ?? retirementError;
118
+ throw err;
119
+ }
120
+
121
+ // Keep a REST-bootstrapped harness alive without handing its durable setup
122
+ // credential to the bridge. The session bearer is scoped to this exact session;
123
+ // the server rejects a mismatched or ended session rather than refreshing an
124
+ // unrelated agent-global heartbeat.
125
+ export async function heartbeatHarnessSession({
126
+ session,
127
+ codexThreadId = null,
128
+ model = null,
129
+ call = callToolJson,
130
+ cwd = process.cwd(),
131
+ touchExpiry = touchAgentStateExpiry,
132
+ } = {}) {
133
+ if (!session?.session_id || !session?.session_token) {
134
+ throw new Error("bootstrap heartbeat requires a stored session credential");
135
+ }
136
+ const response = await call("heartbeat", {
137
+ session_id: session.session_id,
138
+ ...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
139
+ ...(model ? { model } : {}),
140
+ }, { auth: { "x-agent-api-key": session.session_token } });
141
+ if (!response?.ok || response.isError) {
142
+ throw new Error(response?.data?.message || response?.error || "bootstrap heartbeat failed");
143
+ }
144
+ // BOT-1650 (Codex round-30 P2): the server just rolled this session's expiry
145
+ // forward, but the runtime cache still holds the ORIGINAL deadline. Without
146
+ // mirroring the renewal, a later relay wait on a long-lived bridge judges the
147
+ // cache stale and self-heals into a DIFFERENT work session — losing the harness
148
+ // principal and creating a second live holder. touchAgentStateExpiry extends the
149
+ // cache only while it still holds THIS exact bearer, so it never revives a
150
+ // replaced credential. Best-effort: a heartbeat that cannot refresh the cache is
151
+ // not itself a failure.
152
+ await touchExpiry(cwd, session.session_token).catch(() => {});
153
+ return response.data;
154
+ }
155
+
156
+ export async function bootstrapHarnessSession({
157
+ name,
158
+ type = "codex",
159
+ harnessSessionId,
160
+ codexThreadId = null,
161
+ cwd = process.cwd(),
162
+ branch = null,
163
+ ticketId = null,
164
+ ticketUrl = null,
165
+ noTicketReason = null,
166
+ prId = null,
167
+ prNumber = null,
168
+ prUrl = null,
169
+ model = null,
170
+ effort = null,
171
+ harness = type,
172
+ call = callToolJson,
173
+ callOptions = undefined,
174
+ resolveCleanupOptions = durableCleanupOptions,
175
+ store = saveHarnessSession,
176
+ transaction = withHarnessBootstrapLock,
177
+ } = {}) {
178
+ if (!name || !harnessSessionId) throw new Error("bootstrap requires name and harnessSessionId");
179
+ const worktree = await worktreeRoot(cwd);
180
+ return transaction({ cwd: worktree }, async () => {
181
+ // BOT-1650 (Codex round-24): before admitting a new session, retry retiring
182
+ // any session a PRIOR bootstrap orphaned (its replacement retirement failed in
183
+ // a compound outage). The durable credential retires by id, so no bearer is
184
+ // needed. Best-effort — a missing durable credential just keeps the marker.
185
+ await drainPendingCleanup({
186
+ cwd: worktree,
187
+ retire: async (entry) => {
188
+ const opts = callOptions ?? await resolveCleanupOptions();
189
+ if (!opts) return false;
190
+ const closed = await closeWithDurableCredential(call, {
191
+ session_id: entry.session_id,
192
+ ...(entry.session_token_generation ? { session_token_generation: entry.session_token_generation } : {}),
193
+ }, opts);
194
+ return closed.closed;
195
+ },
196
+ }).catch(() => {});
197
+ const cached = await loadHarnessSession({ cwd: worktree });
198
+ // `expires_at` is a cache snapshot, not authority: an authenticated relay
199
+ // use can extend the server-side session after the file was written. Pass
200
+ // bootstrap straight through so the server reuses a still-live incarnation
201
+ // or rotates a truly expired one; never revoke an active wait from a stale
202
+ // local timestamp.
203
+ const response = await call("bootstrap_agent_session", {
204
+ name,
205
+ type,
206
+ harness_session_id: harnessSessionId,
207
+ host: os.hostname(),
208
+ worktree,
209
+ ...(branch ? { branch } : {}),
210
+ ...(ticketId ? { ticket_id: ticketId } : {}),
211
+ ...(ticketUrl ? { ticket_url: ticketUrl } : {}),
212
+ ...(noTicketReason ? { no_ticket_reason: noTicketReason } : {}),
213
+ ...(prId ? { pr_id: prId } : {}),
214
+ ...(prNumber != null ? { pr_number: prNumber } : {}),
215
+ ...(prUrl ? { pr_url: prUrl } : {}),
216
+ ...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
217
+ ...(model ? { model } : {}),
218
+ ...(effort ? { effort } : {}),
219
+ harness,
220
+ }, callOptions);
221
+ if (!response?.ok || response.isError) {
222
+ throw new Error(response?.data?.message || response?.error || "bootstrap_agent_session failed");
223
+ }
224
+ // Persist only the four session-secret fields. Ticket signals are intentionally
225
+ // returned to the launcher, never copied into the runtime credential file.
226
+ const { ticket_signals: ticketSignals, display_name: displayName, linkage, ...session } = response.data;
227
+ if (ticketSignals?.concurrency) throw new Error(ticketSignals.concurrency.warning || "Another live session holds this ticket.");
228
+ if (linkage?.status === "gap") throw new Error(linkage.warning || "Session bootstrap requires ticket attribution.");
229
+ // A new harness may be admitted only after an abandoned cache aged out of
230
+ // the server's liveness window. Before replacing that file, ask the server
231
+ // to retire the cached session *only if its authoritative holder-liveness
232
+ // window elapsed*. The file's timestamp is intentionally not authority:
233
+ // relay heartbeats can extend it after the snapshot was written.
234
+ if (cached && cached.session_id !== session.session_id) {
235
+ const cleanupOptions = callOptions ?? await resolveCleanupOptions();
236
+ if (!cleanupOptions) {
237
+ // BOT-1650 (Codex round-27): the new session was admitted but there is no
238
+ // durable credential to retire the OLD cached session, so the replacement
239
+ // cannot complete. Do not strand the NEW bearer (never persisted): self-close
240
+ // it with its own scoped token first, and if that cannot be confirmed record
241
+ // an orphan marker for a later bootstrap to drain — then fail closed. Matches
242
+ // the store()-failure path's no-durable-credential handling.
243
+ const primaryError = new Error("a replacement harness cache requires a durable credential to confirm remote session cleanup");
244
+ const direct = await closeWithDurableCredential(call, {
245
+ session_id: session.session_id,
246
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
247
+ }, { auth: { "x-agent-api-key": session.session_token } });
248
+ if (!direct.closed) {
249
+ await recordOrphanAndFail({
250
+ session,
251
+ cwd: worktree,
252
+ retirementError: new Error("no durable credential is available to retire the cached session, and the new session's self-close was not confirmed"),
253
+ primaryError,
254
+ });
255
+ }
256
+ throw primaryError;
257
+ }
258
+ const discardNewSession = async () => {
259
+ // A successful self-close revokes this tier-3 token before its response
260
+ // reaches us. If that response is lost, retrying the bearer loops on
261
+ // 401 forever; the durable setup credential is the authority that can
262
+ // confirm the idempotent close (including already_closed).
263
+ const closeArgs = {
264
+ session_id: session.session_id,
265
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
266
+ };
267
+ const direct = await closeWithDurableCredential(call, closeArgs, {
268
+ auth: { "x-agent-api-key": session.session_token },
269
+ });
270
+ if (direct.closed) return;
271
+ const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
272
+ if (confirmed.closed) return;
273
+ throw new Error(cleanupError(confirmed.response) || direct.error?.message || "new harness session cleanup could not be confirmed");
274
+ };
275
+ const cachedCloseArgs = {
276
+ session_id: cached.session_id,
277
+ if_stale: true,
278
+ ...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
279
+ };
280
+ let retired;
281
+ try {
282
+ retired = await call("close_session", cachedCloseArgs, cleanupOptions);
283
+ } catch (error) {
284
+ // BOT-1650 (Codex round-22): a THROWN cached-close (e.g. its idempotent
285
+ // close committed but the response was lost) must not bypass cleanup and
286
+ // strand the newly-admitted session — whose bearer is neither returned nor
287
+ // persisted — while the active holder blocks a later bridge thread with
288
+ // agent_session_conflict. Retry once (if_stale is idempotent) to confirm
289
+ // the old close; if it still cannot be confirmed, retire the new session
290
+ // before propagating.
291
+ const confirm = await call("close_session", cachedCloseArgs, cleanupOptions).catch((retryError) => ({ ok: false, error: retryError?.message }));
292
+ if (!confirm?.ok || confirm.isError) {
293
+ // BOT-1650 (Codex round-23): if retiring the new session ALSO fails (e.g.
294
+ // the same outage hits its direct AND durable close), do NOT swallow that
295
+ // and claim cleanup succeeded — the replacement bearer would stay active
296
+ // and block the next bootstrap. Surface both failures; a fresh bootstrap
297
+ // retries retirement (the orphaned session ages out / is re-reconciled).
298
+ try {
299
+ await discardNewSession();
300
+ } catch (discardError) {
301
+ // BOT-1650 (Codex round-24/25): retirement failed, so the new session
302
+ // is admitted but unpersisted. Record a cleanup marker (id+generation,
303
+ // no bearer) for a later bootstrap to drain, and fail closed preserving
304
+ // the cached-close cause, the retirement failure, and any marker-write
305
+ // failure (never swallowed).
306
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError, primaryError: error });
307
+ }
308
+ throw error;
309
+ }
310
+ retired = confirm;
311
+ }
312
+ if (!retired?.ok || retired.isError) {
313
+ try {
314
+ await discardNewSession();
315
+ } catch (discardError) {
316
+ // BOT-1650 (Codex round-24/25): retirement failed here too — record the
317
+ // orphan and fail closed (propagating any marker-write failure).
318
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError });
319
+ }
320
+ throw new Error(retired?.data?.error || retired?.error || "cached harness session is still live or could not be retired");
321
+ }
322
+ await clearHarnessSession(cached.session_id, {
323
+ cwd: worktree,
324
+ sessionToken: cached.session_token,
325
+ lock: false, // already inside withHarnessBootstrapLock (the same state lock); re-locking would deadlock
326
+ });
327
+ }
328
+ let state;
329
+ try {
330
+ state = await store(session, { cwd: worktree });
331
+ } catch (error) {
332
+ // The server session is unusable if this harness cannot persist its only
333
+ // credential. The freshly issued session bearer is scoped to this exact
334
+ // session and can self-close, so try it first — the durable OAuth/MCP
335
+ // credential may have expired or been revoked between bootstrap and
336
+ // cleanup, and using only that would strand an active session whose token
337
+ // was never returned or saved. Fall back to the durable credential just
338
+ // to confirm an ambiguous close (a lost tier-3 response that already
339
+ // revoked its own bearer).
340
+ const closeArgs = {
341
+ session_id: session.session_id,
342
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
343
+ };
344
+ const direct = await closeWithDurableCredential(call, closeArgs, {
345
+ auth: { "x-agent-api-key": session.session_token },
346
+ });
347
+ if (direct.closed) throw error;
348
+ const cleanupOptions = callOptions ?? await resolveCleanupOptions();
349
+ if (!cleanupOptions) {
350
+ // BOT-1650 (Codex round-26): the fresh bearer could not self-close AND no
351
+ // durable credential is available to retire the admitted session — so it
352
+ // cannot be closed at all. Record the orphan (so a later bootstrap with a
353
+ // credential drains it) and fail closed, instead of throwing with no marker.
354
+ await recordOrphanAndFail({
355
+ session,
356
+ cwd: worktree,
357
+ retirementError: new Error("no durable credential is available to confirm session cleanup"),
358
+ primaryError: error,
359
+ });
360
+ }
361
+ const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
362
+ if (!confirmed.closed) {
363
+ // BOT-1650 (Codex round-25): the session was admitted but its credential
364
+ // could not be persisted AND neither the fresh-bearer nor the durable
365
+ // close confirmed retirement — an orphaned live holder unrelated to cache
366
+ // replacement (e.g. a Windows ACL-hardening failure in store()). Record it
367
+ // so a later bootstrap drains it, and fail closed preserving both causes.
368
+ const retirementError = new Error(cleanupError(confirmed.response) || direct.error?.message || "bootstrap persistence cleanup could not be confirmed");
369
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError, primaryError: error });
370
+ }
371
+ throw error;
372
+ }
373
+ return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
374
+ });
375
+ }
package/src/wait.mjs CHANGED
@@ -19,7 +19,7 @@ import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
21
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
22
- import { clearAgentState, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
22
+ import { clearAgentState, hasBootstrapAgentSession, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
23
  import { touchAgentStateExpiry } from "./agent-state.mjs";
24
24
  import { SETUP_BLOCK } from "./setup-block.mjs";
25
25
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
@@ -1505,7 +1505,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1505
1505
  : new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
1506
1506
  }
1507
1507
 
1508
- const needsRelay = conditions.some((c) => c.type !== "timer");
1508
+ // BOT-1650: a plain timer stays offline, but a timer from a bootstrapped harness
1509
+ // registers so its terminal receipt proves the current session principal (the
1510
+ // cross-shell bootstrap contract). Detected without a network self-heal.
1511
+ const needsRelay = conditions.some((c) => c.type !== "timer")
1512
+ || await hasBootstrapAgentSession({ cwd: process.cwd() });
1509
1513
  if (needsRelay) {
1510
1514
  try {
1511
1515
  const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });