@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.
@@ -14,7 +14,11 @@
14
14
 
15
15
  import { createHash, randomBytes } from "crypto";
16
16
  import { getConfig, SERVER_URL } from "./config.mjs";
17
+ import { callToolJson } from "./api.mjs";
17
18
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
19
+ import { bootstrapHarnessSession, heartbeatHarnessSession } from "./session-bootstrap.mjs";
20
+ import { clearHarnessSession } from "./session-bootstrap.mjs";
21
+ import { worktreeRoot } from "./agent-state.mjs";
18
22
  import { green, red, cyan, dim, bold, yellow, die } from "./utils.mjs";
19
23
  import { VERSION } from "./version.mjs";
20
24
 
@@ -64,6 +68,115 @@ function isAddressInUseError(text) {
64
68
  return /address already in use|eaddrinuse|os error 48/i.test(text);
65
69
  }
66
70
 
71
+ // BOT-1650: a ticketless investigation is a read-only contract. The sandbox is
72
+ // the enforcement point — never let a `no_ticket_reason` thread mutate the
73
+ // workspace, regardless of the requested (or defaulted) sandbox policy. The UI
74
+ // param is not a trust boundary, so the bridge, not the caller, decides.
75
+ export function resolveThreadStartSandbox(params = {}) {
76
+ if (params.no_ticket_reason) return "read-only";
77
+ return params.sandbox || "workspace-write";
78
+ }
79
+
80
+ // BOT-1650: only threads that completed bootstrap admission may relay lifecycle
81
+ // upserts. A thread whose bootstrap failed never entered `bootstrappedThreads`
82
+ // and has no `codex_threads` row; upserting one from a later lifecycle
83
+ // notification (e.g. thread/archived) would recreate the unreachable dashboard
84
+ // row the bootstrap gate is meant to suppress and point the bridge at it.
85
+ export function shouldRelayThreadLifecycle(bootstrappedThreads, threadId) {
86
+ return !!threadId && !!bootstrappedThreads?.has?.(threadId);
87
+ }
88
+
89
+ // BOT-1650 (Codex round-35 P2): a per-turn model override must be written back to
90
+ // the stored session so keepalive heartbeats send the CURRENT model, not the one
91
+ // chosen at thread creation (which would overwrite agents.model and misattribute
92
+ // the overridden turn on the dashboard). Only a SUCCESSFUL turn (no error result)
93
+ // updates the stored model; a no-op override or an unknown thread is ignored.
94
+ export function applyTurnModelOverride(bootstrappedSessions, threadId, model, result) {
95
+ if (!model || result?.error) return false;
96
+ const stored = bootstrappedSessions?.get?.(threadId);
97
+ if (!stored) return false;
98
+ stored.model = model;
99
+ return true;
100
+ }
101
+
102
+ // BOT-1650: retire a thread's admission only after its terminal (archived)
103
+ // lifecycle update has persisted. relayPost returns the relay's error body (no
104
+ // throw) on a transient 4xx/5xx, so a missing thread_id means the archived
105
+ // status did not land. Closing the session first — or closing after an
106
+ // unpersisted update — would drop the thread from the admission maps, and
107
+ // shouldRelayThreadLifecycle would then gate out every replay, stranding the
108
+ // dashboard row active/idle forever despite its session being closed. Post
109
+ // first; retire only on confirmed persistence, otherwise retain admission so a
110
+ // later archived notification (or shutdown cleanup) can re-post it.
111
+ // BOT-1650: reconcile the initial thread-link upsert against an ambiguous
112
+ // transport failure. codex-relay's upsert is idempotent (keyed by session +
113
+ // codex_thread_id) and, on insert, points the bridge agent at the row — so a
114
+ // lost response may hide a row that is already committed and reachable. Retry
115
+ // once before the caller decides to retire; on a persisted result (first call
116
+ // or retry) return it, otherwise re-throw the original transport error so the
117
+ // caller retires the possibly-orphaned thread rather than admitting it blind.
118
+ export async function reconcileThreadLink({ relayPost, linkPayload }) {
119
+ try {
120
+ return await relayPost("/bridge/thread-update", linkPayload);
121
+ } catch (error) {
122
+ const retried = await relayPost("/bridge/thread-update", linkPayload).catch(() => null);
123
+ if (retried?.thread_id) return retried;
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ export async function relayThreadArchival({
129
+ threadId,
130
+ sessionId,
131
+ relayPost,
132
+ closeThread,
133
+ retryClose,
134
+ retryArchival = null,
135
+ log = console.error,
136
+ }) {
137
+ const archived = await relayPost("/bridge/thread-update", {
138
+ session_id: sessionId,
139
+ codex_thread_id: threadId,
140
+ status: "archived",
141
+ }).catch((err) => ({ error: String(err) }));
142
+ if (!archived?.thread_id) {
143
+ // Retaining admission does not by itself guarantee a replay — the app
144
+ // server may emit only one thread/archived, and shutdown just retires the
145
+ // session without re-posting. Schedule an explicit retry so the terminal
146
+ // status eventually lands instead of leaving the row active/idle forever.
147
+ log("[bridge] archived lifecycle update did not persist; scheduling retry:", { threadId, error: archived?.error });
148
+ if (retryArchival) retryArchival(threadId);
149
+ return false;
150
+ }
151
+ if (!await closeThread(threadId)) retryClose(threadId);
152
+ return true;
153
+ }
154
+
155
+ // BOT-1650: a bridge shutdown is terminal for its harness threads, but a thread
156
+ // whose `archived` status has NOT yet persisted still has a pending
157
+ // retryThreadArchival timer. Calling closeBootstrappedThread directly on it
158
+ // would retire the session and DISCARD that pending re-post — shouldRelayThread-
159
+ // Lifecycle then gates out every replay and the dashboard row is stranded
160
+ // active/idle forever. Route a pending-archival thread through the archival flush
161
+ // (post archived, then close on persistence) instead; close the rest directly.
162
+ // Returns true only once the thread is genuinely retired (no longer admitted),
163
+ // so the shutdown loop knows when to stop retrying.
164
+ export async function retireBootstrappedThreadOnShutdown(threadId, {
165
+ bootstrappedThreads,
166
+ pendingArchival,
167
+ flushArchival,
168
+ closeThread,
169
+ }) {
170
+ if (pendingArchival.has(threadId)) {
171
+ await flushArchival(threadId);
172
+ // flushArchival retires the session itself once the archived status lands; a
173
+ // thread still admitted here (archival or its close unconfirmed) is retried
174
+ // by the shutdown loop's next pass.
175
+ return !bootstrappedThreads.has(threadId);
176
+ }
177
+ return closeThread(threadId);
178
+ }
179
+
67
180
  export async function runBridge(args) {
68
181
  const cfg = getConfig(); // non-secret metadata only (agent_name)
69
182
  // BOT-1520: authenticate from the Keychain — the MCP key
@@ -186,6 +299,99 @@ export async function runBridge(args) {
186
299
  let initialized = false;
187
300
  const pendingRequests = new Map(); // id → { resolve, reject }
188
301
  const threads = new Map(); // codex_thread_id → bb_thread_id
302
+ const bootstrappedThreads = new Set();
303
+ const bootstrappedSessions = new Map();
304
+ // BOT-1650: threads whose terminal `archived` status has been attempted but has
305
+ // NOT yet persisted (a retryThreadArchival timer is outstanding). Shutdown must
306
+ // flush these before discarding admission so the terminal status is not dropped.
307
+ const pendingArchival = new Set();
308
+
309
+ // Keep admission closed until the server has actually retired the session.
310
+ // Otherwise a transient close failure leaves an unseen active ticket holder
311
+ // and the next thread's bootstrap is guaranteed to conflict.
312
+ async function closeBootstrappedThread(threadId) {
313
+ const session = bootstrappedSessions.get(threadId);
314
+ if (!session) {
315
+ bootstrappedThreads.delete(threadId);
316
+ return true;
317
+ }
318
+ // BOT-1650: the durable confirmation path is generation-fenced. A lost
319
+ // self-close response, or an expired/revoked tier-3 bearer, leaves the
320
+ // durable fallback as the only cleanup route, and the final API rejects a
321
+ // generated row without its `session_token_generation`. Carry the bootstrap
322
+ // generation so an active row is retired instead of returning
323
+ // `session_generation_required` and retrying admission indefinitely.
324
+ const closeArgs = {
325
+ session_id: session.session_id,
326
+ ...(session.session_token_generation
327
+ ? { session_token_generation: session.session_token_generation }
328
+ : {}),
329
+ };
330
+ try {
331
+ const close = await callToolJson("close_session", closeArgs, {
332
+ // A live session bearer can close only itself, so it remains the most
333
+ // reliable cleanup authority when the bridge's startup credential has
334
+ // expired or been revoked during a long-running task.
335
+ auth: { "x-agent-api-key": session.session_token },
336
+ });
337
+ if (!close?.ok || close.isError) {
338
+ // A revoked tier-3 token may mean a previous close succeeded but its
339
+ // response was lost. Only then use durable auth to confirm the outcome.
340
+ const confirmed = await callToolJson("close_session", closeArgs, {
341
+ auth: await authHeaders(),
342
+ });
343
+ if (!confirmed?.ok || confirmed.isError) throw new Error(confirmed?.error || "close_session rejected");
344
+ }
345
+ // The launcher may start a thread in a nested or sibling worktree. The
346
+ // runtime credential was persisted there, not necessarily at the bridge
347
+ // root, so retire precisely the state file that owns this session.
348
+ await clearHarnessSession(session.session_id, {
349
+ cwd: session.cwd,
350
+ sessionToken: session.session_token,
351
+ });
352
+ bootstrappedSessions.delete(threadId);
353
+ bootstrappedThreads.delete(threadId);
354
+ return true;
355
+ } catch (error) {
356
+ console.error("[bridge] retaining thread admission until session cleanup succeeds:", { threadId, error });
357
+ return false;
358
+ }
359
+ }
360
+
361
+ function retryCloseBootstrappedThread(threadId) {
362
+ setTimeout(async () => {
363
+ if (!bootstrappedThreads.has(threadId)) return;
364
+ if (!await closeBootstrappedThread(threadId)) retryCloseBootstrappedThread(threadId);
365
+ }, PING_INTERVAL_MS);
366
+ }
367
+
368
+ // Post a thread's archived status and track whether it persisted. The pending
369
+ // set lets shutdown flush an outstanding archival before discarding admission
370
+ // (BOT-1650) instead of closing the session and dropping the terminal status.
371
+ async function flushThreadArchival(threadId) {
372
+ const persisted = await relayThreadArchival({
373
+ threadId,
374
+ sessionId,
375
+ relayPost,
376
+ closeThread: closeBootstrappedThread,
377
+ retryClose: retryCloseBootstrappedThread,
378
+ retryArchival: retryThreadArchival,
379
+ });
380
+ if (persisted) pendingArchival.delete(threadId);
381
+ else pendingArchival.add(threadId);
382
+ return persisted;
383
+ }
384
+
385
+ // Re-post a thread's archived status when the first attempt did not persist.
386
+ // Runs only while the thread is still admitted (relayThreadArchival retires
387
+ // admission itself once the update lands), so this stops as soon as the
388
+ // terminal status is confirmed.
389
+ function retryThreadArchival(threadId) {
390
+ setTimeout(() => {
391
+ if (!bootstrappedThreads.has(threadId)) return;
392
+ void flushThreadArchival(threadId);
393
+ }, PING_INTERVAL_MS);
394
+ }
189
395
 
190
396
  // Generate a nonce for localhost verification
191
397
  const bridgeNonce = randomBytes(16).toString("hex");
@@ -293,13 +499,16 @@ export async function runBridge(args) {
293
499
  const threadId = payload.thread?.id;
294
500
  if (threadId) {
295
501
  console.log(` ${cyan("◆")} Thread started: ${threadId}`);
296
- const result = await relayPost("/bridge/thread-update", {
297
- session_id: sessionId,
298
- codex_thread_id: threadId,
299
- status: "active",
300
- model,
301
- });
302
- if (result?.thread_id) threads.set(threadId, result.thread_id);
502
+ // `thread/start` records the relay row only after bootstrap succeeds.
503
+ // A rejected bootstrap must never leave an unreachable dashboard thread.
504
+ if (shouldRelayThreadLifecycle(bootstrappedThreads, threadId)) {
505
+ await relayPost("/bridge/thread-update", {
506
+ session_id: sessionId,
507
+ codex_thread_id: threadId,
508
+ status: "active",
509
+ model,
510
+ });
511
+ }
303
512
  }
304
513
  }
305
514
 
@@ -315,7 +524,11 @@ export async function runBridge(args) {
315
524
  const usage = payload.turn?.usage || payload.usage;
316
525
  if (usage) {
317
526
  const threadId = payload.thread?.id || payload.threadId;
318
- if (threadId) {
527
+ // A turn can complete on an unadmitted thread when the user drives the
528
+ // local app-server directly (bypassing the bridge's turn/start gate).
529
+ // Its usage upsert must not insert a codex_threads row for a thread
530
+ // whose bootstrap failed — same admission gate as every lifecycle event.
531
+ if (shouldRelayThreadLifecycle(bootstrappedThreads, threadId)) {
319
532
  const updatePayload = { session_id: sessionId, codex_thread_id: threadId };
320
533
  if (usage.input_tokens !== undefined) updatePayload.input_tokens = usage.input_tokens;
321
534
  if (usage.output_tokens !== undefined) updatePayload.output_tokens = usage.output_tokens;
@@ -354,7 +567,10 @@ export async function runBridge(args) {
354
567
  if (eventType === "thread/status/changed") {
355
568
  const threadId = payload.threadId;
356
569
  const status = payload.status?.type;
357
- if (threadId && status) {
570
+ // Same admission gate as the other lifecycle upserts: a status change
571
+ // for an unadmitted thread (bootstrap failed or still pending) must not
572
+ // upsert a codex_threads row and point the bridge at an unreachable one.
573
+ if (status && shouldRelayThreadLifecycle(bootstrappedThreads, threadId)) {
358
574
  await relayPost("/bridge/thread-update", {
359
575
  session_id: sessionId,
360
576
  codex_thread_id: threadId,
@@ -363,6 +579,19 @@ export async function runBridge(args) {
363
579
  }
364
580
  }
365
581
 
582
+ // App-server emits archival as its own lifecycle notification; status
583
+ // changes are operational states such as active/idle, not terminal work.
584
+ if (eventType === "thread/archived") {
585
+ const threadId = payload.threadId || payload.thread?.id;
586
+ // Only retire + mark archived for admitted threads. An unadmitted thread
587
+ // (thread/start created the Codex thread but bootstrap failed) has no
588
+ // codex_threads row; upserting an archived status here would recreate
589
+ // the unreachable dashboard row the bootstrap gate suppresses.
590
+ if (shouldRelayThreadLifecycle(bootstrappedThreads, threadId)) {
591
+ await flushThreadArchival(threadId);
592
+ }
593
+ }
594
+
366
595
  // Forward event to BotBuddy
367
596
  const bbThreadId = payload.thread?.id ? threads.get(payload.thread.id) :
368
597
  payload.threadId ? threads.get(payload.threadId) : null;
@@ -409,23 +638,94 @@ export async function runBridge(args) {
409
638
 
410
639
  switch (cmd.command) {
411
640
  case "thread/start": {
641
+ const threadCwd = cmd.params.cwd || repoPath;
642
+ const requestedWorktree = await worktreeRoot(threadCwd);
643
+ const activeInWorktree = [...bootstrappedSessions.values()].some(
644
+ (session) => session.cwd === requestedWorktree,
645
+ );
646
+ if (activeInWorktree) {
647
+ throw new Error("This bridge worktree already has an active bootstrapped thread; finish that thread or use a separate worktree before starting another.");
648
+ }
412
649
  result = await sendWs("thread/start", {
413
650
  model: cmd.params.model || model,
414
- cwd: cmd.params.cwd || repoPath,
651
+ cwd: threadCwd,
415
652
  approvalPolicy: cmd.params.approval_policy || "never",
416
- sandbox: cmd.params.sandbox || "workspace-write",
653
+ sandbox: resolveThreadStartSandbox(cmd.params),
417
654
  });
418
655
  // Track thread
419
656
  if (result?.thread?.id) {
420
- const bbResult = await relayPost("/bridge/thread-update", {
657
+ // BOT-1650: the thread exists before its first turn, so bind and
658
+ // persist its short-lived credential now. A failure is surfaced to
659
+ // the command dispatcher and prevents a turn from starting without
660
+ // a wait-capable principal.
661
+ const bootstrap = await bootstrapHarnessSession({
662
+ name: `${cfg.agent_name || `codex-bridge-${host}`}-${result.thread.id.slice(0, 8)}`,
663
+ type: "codex",
664
+ harnessSessionId: result.thread.id,
665
+ codexThreadId: result.thread.id,
666
+ cwd: requestedWorktree,
667
+ model: cmd.params.model || model,
668
+ effort: cmd.params.effort || null,
669
+ branch: cmd.params.branch || null,
670
+ ticketId: cmd.params.ticket_id || null,
671
+ ticketUrl: cmd.params.ticket_url || null,
672
+ noTicketReason: cmd.params.no_ticket_reason || null,
673
+ prId: cmd.params.pr_id || null,
674
+ prNumber: cmd.params.pr_number ?? null,
675
+ prUrl: cmd.params.pr_url || null,
676
+ harness: "botbuddy-codex-bridge",
677
+ call: callToolJson,
678
+ callOptions: { auth: await authHeaders() },
679
+ });
680
+ if (bootstrap.ticket_signals?.concurrency) {
681
+ throw new Error(bootstrap.ticket_signals.concurrency.warning);
682
+ }
683
+ if (bootstrap.linkage?.status === "gap") {
684
+ throw new Error(bootstrap.linkage.warning || "Session bootstrap requires ticket attribution.");
685
+ }
686
+ // Own cleanup before any fallible relay linkage. A rejected fetch
687
+ // must not strand the just-minted secret/session outside the bridge
688
+ // lifecycle maps and let a second thread bypass admission.
689
+ bootstrappedThreads.add(result.thread.id);
690
+ bootstrappedSessions.set(result.thread.id, {
691
+ session_id: bootstrap.session_id,
692
+ session_token: bootstrap.session_token,
693
+ // BOT-1650: retain the generation so the durable close fallback in
694
+ // closeBootstrappedThread can fence its cleanup on this exact row.
695
+ session_token_generation: bootstrap.session_token_generation,
696
+ // Keep this thread's EFFECTIVE model so keepalive heartbeats persist
697
+ // it, not the bridge-wide default — otherwise a thread started on a
698
+ // non-default model (e.g. the UI picks o3) would have agents.model
699
+ // overwritten to the default on the first 15s heartbeat.
700
+ model: cmd.params.model || model,
701
+ cwd: requestedWorktree,
702
+ });
703
+ const linkPayload = {
421
704
  session_id: sessionId,
422
705
  codex_thread_id: result.thread.id,
423
706
  title: cmd.params.title,
424
707
  model: cmd.params.model || model,
425
708
  status: "idle",
426
- cwd: cmd.params.cwd || repoPath,
427
- });
428
- if (bbResult?.thread_id) threads.set(result.thread.id, bbResult.thread_id);
709
+ cwd: threadCwd,
710
+ };
711
+ let bbResult;
712
+ try {
713
+ bbResult = await reconcileThreadLink({ relayPost, linkPayload });
714
+ } catch (error) {
715
+ if (!await closeBootstrappedThread(result.thread.id)) retryCloseBootstrappedThread(result.thread.id);
716
+ throw error;
717
+ }
718
+ if (!bbResult?.thread_id) {
719
+ if (!await closeBootstrappedThread(result.thread.id)) retryCloseBootstrappedThread(result.thread.id);
720
+ throw new Error("Bridge thread linkage failed; refusing to admit an unreachable thread.");
721
+ }
722
+ threads.set(result.thread.id, bbResult.thread_id);
723
+ result.bootstrap = {
724
+ agent_id: bootstrap.agent_id,
725
+ display_name: bootstrap.display_name,
726
+ session_id: bootstrap.session_id,
727
+ expires_at: bootstrap.expires_at,
728
+ };
429
729
  }
430
730
  break;
431
731
  }
@@ -436,11 +736,20 @@ export async function runBridge(args) {
436
736
  result = { error: "Thread not found" };
437
737
  break;
438
738
  }
739
+ if (!bootstrappedThreads.has(codexThreadId)) {
740
+ result = { error: "Thread session bootstrap did not complete; start a new thread after resolving its authentication or ticket conflict." };
741
+ break;
742
+ }
439
743
  result = await sendWs("turn/start", {
440
744
  threadId: codexThreadId,
441
745
  input: [{ type: "text", text: cmd.params.prompt || cmd.params.text || "" }],
442
746
  ...(cmd.params.model && { model: cmd.params.model }),
443
747
  });
748
+ // BOT-1650 (Codex round-35 P2): a per-turn model override must be written
749
+ // back to the stored session, or the 15s keepalive keeps sending the
750
+ // creation-time model and overwrites agents.model — misattributing the
751
+ // overridden turn on the dashboard and in effectiveness reporting.
752
+ applyTurnModelOverride(bootstrappedSessions, codexThreadId, cmd.params.model, result);
444
753
  break;
445
754
  }
446
755
 
@@ -496,6 +805,27 @@ export async function runBridge(args) {
496
805
  try {
497
806
  await relayPost("/bridge/ping", { session_id: sessionId });
498
807
  } catch {}
808
+ // The relay ping maintains the bridge connection, not the work-graph
809
+ // session created via the REST bootstrap. Refresh every active thread with
810
+ // its scoped tier-3 token so ticket-holder admission cannot stale it while
811
+ // Codex is working silently between BotBuddy calls.
812
+ await Promise.all([...bootstrappedSessions.entries()].map(async ([threadId, harnessSession]) => {
813
+ try {
814
+ await heartbeatHarnessSession({
815
+ session: harnessSession,
816
+ codexThreadId: threadId,
817
+ // Send this thread's effective model, not the bridge-wide default, so
818
+ // a keepalive never rewrites agents.model to the wrong value.
819
+ model: harnessSession.model ?? model,
820
+ call: callToolJson,
821
+ // BOT-1650 (round-30): mirror the server's expiry roll into this thread's
822
+ // worktree cache so a later relay wait keeps the bootstrap session.
823
+ cwd: harnessSession.cwd,
824
+ });
825
+ } catch (error) {
826
+ console.error("[bridge] bootstrapped session heartbeat failed:", { threadId, error });
827
+ }
828
+ }));
499
829
  }, PING_INTERVAL_MS);
500
830
 
501
831
  const pollInterval = setInterval(pollCommands, POLL_INTERVAL_MS);
@@ -505,6 +835,25 @@ export async function runBridge(args) {
505
835
  console.log(`\n${dim("→ Shutting down bridge...")}`);
506
836
  clearInterval(pingInterval);
507
837
  clearInterval(pollInterval);
838
+ // A bridge shutdown is terminal for its harness threads. Retire their
839
+ // per-thread session credentials before disconnecting so a sequential
840
+ // bridge does not collide with an orphaned live ticket holder. BOT-1650: a
841
+ // thread with an unpersisted archival must FLUSH it here (route through
842
+ // relayThreadArchival) before its admission is discarded, or the terminal
843
+ // status is dropped and the dashboard row is stranded active/idle.
844
+ while (bootstrappedThreads.size > 0) {
845
+ const closed = await Promise.all([...bootstrappedThreads].map((threadId) =>
846
+ retireBootstrappedThreadOnShutdown(threadId, {
847
+ bootstrappedThreads,
848
+ pendingArchival,
849
+ flushArchival: flushThreadArchival,
850
+ closeThread: closeBootstrappedThread,
851
+ })
852
+ ));
853
+ if (closed.every(Boolean)) break;
854
+ console.error(dim("→ Session cleanup did not complete; retaining bridge shutdown until it can be retried."));
855
+ await sleep(PING_INTERVAL_MS);
856
+ }
508
857
  try {
509
858
  await relayPost("/bridge/disconnect", { session_id: sessionId });
510
859
  } catch {}
package/src/commands.mjs CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { callTool, callToolJson, readResource } from "./api.mjs";
6
6
  import { doLogin } from "./auth.mjs";
7
7
  import { runBridge } from "./codex-bridge.mjs";
8
+ import { bootstrapHarnessSession, clearHarnessSession, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
8
9
  import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
9
10
  import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
10
11
  import { agentProfileKeyLabel } from "./credential-kinds.mjs";
@@ -38,6 +39,16 @@ export function shouldCheckForUpdates(argv) {
38
39
  return true;
39
40
  }
40
41
 
42
+ const TICKET_KEY_RE = /^[A-Za-z][A-Za-z0-9]{1,9}-\d{1,6}$/;
43
+
44
+ export function validateBootstrapLinkage(ticketId, noTicketReason) {
45
+ const ticket = typeof ticketId === "string" ? ticketId.trim() : "";
46
+ const reason = typeof noTicketReason === "string" ? noTicketReason.trim() : "";
47
+ if (!!ticket === !!reason) return "bb bootstrap requires exactly one of --ticket-id <ticket> or --no-ticket-reason <reason>";
48
+ if (ticket && !TICKET_KEY_RE.test(ticket)) return "bb bootstrap --ticket-id must be a ticket key like BOT-123";
49
+ return null;
50
+ }
51
+
41
52
  export async function run(argv, {
42
53
  loadConfig: load = loadConfig,
43
54
  warnStale = maybeWarnStale,
@@ -49,6 +60,7 @@ export async function run(argv, {
49
60
 
50
61
  switch (command) {
51
62
  case "start": return cmdStart(args);
63
+ case "bootstrap": return cmdBootstrap(args);
52
64
  case "login": return cmdLogin(args, { errorLog });
53
65
  case "setup": return cmdSetup(args, { errorLog });
54
66
  case "logout": return cmdLogout();
@@ -129,6 +141,9 @@ ${dim(`Run everything as ${cyan("bb <command>")} — the short, preferred alias.
129
141
 
130
142
  ${bold("USAGE")}
131
143
  bb start Start BotBuddy (login + codex server + bridge)
144
+ bb bootstrap --session-id <uuid> (--ticket-id <BOT-n> | --no-ticket-reason <reason>)
145
+ Internal launcher contract: persist a wait-capable
146
+ harness session without printing its secret
132
147
 
133
148
  ${bold("OPTIONS")}
134
149
  start [options]
@@ -571,6 +586,144 @@ async function cmdStart(args) {
571
586
  return runBridge(args);
572
587
  }
573
588
 
589
+ // BOT-1650: a harness/launcher calls this once after it knows its own session
590
+ // UUID. It writes the short-lived token to the gitignored 0600 runtime file and
591
+ // prints only non-secret metadata, so later `bb wait` calls need no exports.
592
+ export async function closeStoredHarnessSession({
593
+ session,
594
+ durableAuth,
595
+ call = callToolJson,
596
+ } = {}) {
597
+ const sessionAuth = { "x-agent-api-key": session.session_token };
598
+ // BOT-1650: the durable close path is generation-fenced. When the cached
599
+ // bearer has expired or been revoked while its row stays active, the durable
600
+ // owner/MCP credential is the only cleanup route — but the final
601
+ // `close_session` API rejects durable closure of a generated row unless
602
+ // `session_token_generation` is supplied (`session_generation_required`), so
603
+ // the bearer retry cannot authenticate either. Pass the cached generation so
604
+ // `bb bootstrap close` can clear the session and its runtime cache.
605
+ const closeArgs = {
606
+ session_id: session.session_id,
607
+ ...(session.session_token_generation
608
+ ? { session_token_generation: session.session_token_generation }
609
+ : {}),
610
+ };
611
+ let result;
612
+ let firstError;
613
+ try {
614
+ result = await call("close_session", closeArgs, { auth: durableAuth });
615
+ } catch (error) {
616
+ firstError = error;
617
+ }
618
+ const usedSessionBearer = durableAuth?.["x-agent-api-key"] === session.session_token;
619
+ // BOT-1650: a THROW from the durable attempt means the response was LOST — the
620
+ // durable close is generation-fenced and idempotent, so it may already have
621
+ // COMMITTED server-side, which also revokes the generated bearer. Falling
622
+ // straight to the session-bearer retry would then authenticate with a
623
+ // now-revoked credential, report a false failure, and keep the runtime cache.
624
+ // Re-issue the idempotent DURABLE request to confirm the ambiguous outcome
625
+ // first. A STRUCTURED rejection (result returned with !ok/isError) is a
626
+ // definite server refusal of the durable credential/args — the server did
627
+ // answer — so it skips this retry and falls through to the scoped bearer.
628
+ if (firstError && !usedSessionBearer) {
629
+ try {
630
+ result = await call("close_session", closeArgs, { auth: durableAuth });
631
+ firstError = undefined;
632
+ } catch (error) {
633
+ firstError = error;
634
+ }
635
+ }
636
+ // A locally unexpired owner/MCP credential may already be revoked remotely,
637
+ // while the exact cached bearer remains valid to retire its own session.
638
+ // Retry only with that scoped bearer; never widen the fallback credential.
639
+ if ((!result?.ok || result.isError || firstError) && !usedSessionBearer) {
640
+ result = await call("close_session", closeArgs, { auth: sessionAuth });
641
+ firstError = undefined;
642
+ }
643
+ if (firstError) throw firstError;
644
+ return result;
645
+ }
646
+
647
+ async function cmdBootstrap(args) {
648
+ if (args[0] === "close") {
649
+ return withHarnessBootstrapLock({}, async () => {
650
+ const session = await loadHarnessSession();
651
+ if (!session) {
652
+ console.log(JSON.stringify({ outcome: "no_session" }));
653
+ return;
654
+ }
655
+ const owner = await resolveOwnerToken({ getConfig });
656
+ // Match resolveCallAuth: a stored but expired owner token must never mask
657
+ // a usable MCP key or the exact session credential that needs cleanup.
658
+ const activeOwner = owner && !(owner.expiresAt && Date.now() >= owner.expiresAt) ? owner : null;
659
+ const agentKey = activeOwner ? null : await resolveAgentKey();
660
+ const durableAuth = activeOwner
661
+ ? { Authorization: `Bearer ${activeOwner.token}` }
662
+ : agentKey
663
+ ? { "x-agent-api-key": agentKey }
664
+ : { "x-agent-api-key": session.session_token };
665
+ const result = await closeStoredHarnessSession({ session, durableAuth });
666
+ if (!result?.ok || result.isError) die(result?.error || "bb bootstrap close failed");
667
+ await clearHarnessSession(session.session_id, { sessionToken: session.session_token, lock: false }); // already inside withHarnessBootstrapLock (non-reentrant)
668
+ console.log(JSON.stringify({ outcome: "closed", session_id: session.session_id }));
669
+ });
670
+ }
671
+ let harnessSessionId = null;
672
+ let type = "custom";
673
+ let codexThreadId = null;
674
+ let branch = null;
675
+ let ticketId = null;
676
+ let ticketUrl = null;
677
+ let noTicketReason = null;
678
+ let prId = null;
679
+ let prNumber = null;
680
+ let prUrl = null;
681
+ let model = null;
682
+ let effort = null;
683
+ let name = getConfig()?.agent_name || `botbuddy-${os.hostname()}`;
684
+ let nameExplicit = false;
685
+ for (let i = 0; i < args.length; i += 1) {
686
+ if (args[i] === "--session-id") harnessSessionId = args[++i] || null;
687
+ else if (args[i] === "--type") type = args[++i] || type;
688
+ else if (args[i] === "--codex-thread-id") codexThreadId = args[++i] || null;
689
+ else if (args[i] === "--name") { name = args[++i] || name; nameExplicit = true; }
690
+ else if (args[i] === "--branch") branch = args[++i] || null;
691
+ else if (args[i] === "--ticket-id") ticketId = args[++i] || null;
692
+ else if (args[i] === "--ticket-url") ticketUrl = args[++i] || null;
693
+ else if (args[i] === "--no-ticket-reason") noTicketReason = args[++i] || null;
694
+ else if (args[i] === "--pr-id") prId = args[++i] || null;
695
+ else if (args[i] === "--pr-number") prNumber = Number(args[++i]);
696
+ else if (args[i] === "--pr-url") prUrl = args[++i] || null;
697
+ else if (args[i] === "--model") model = args[++i] || null;
698
+ else if (args[i] === "--effort") effort = args[++i] || null;
699
+ else die("Usage: bb bootstrap --session-id <harness UUID> (--ticket-id <ticket> | --no-ticket-reason <reason>) [--type codex|claude|gpt|custom] [--codex-thread-id <UUID>] [--name <technical-name>] [--branch <branch>] [--ticket-url <url>] [--model <model>] [--effort <effort>]");
700
+ }
701
+ if (!harnessSessionId) die("bb bootstrap requires --session-id <harness UUID>");
702
+ const linkageError = validateBootstrapLinkage(ticketId, noTicketReason);
703
+ if (linkageError) die(linkageError);
704
+ ticketId = ticketId?.trim() || null;
705
+ noTicketReason = noTicketReason?.trim() || null;
706
+ if (!nameExplicit) name = `${name}-${harnessSessionId.slice(0, 8)}`;
707
+ const state = await bootstrapHarnessSession({
708
+ name,
709
+ type,
710
+ harnessSessionId,
711
+ codexThreadId,
712
+ branch,
713
+ ticketId,
714
+ ticketUrl,
715
+ noTicketReason,
716
+ prId,
717
+ prNumber: Number.isSafeInteger(prNumber) && prNumber > 0 ? prNumber : null,
718
+ prUrl,
719
+ model,
720
+ effort,
721
+ harness: `botbuddy-${type}-launcher`,
722
+ });
723
+ if (state.linkage?.status === "gap") die(state.linkage.warning || "bb bootstrap requires ticket attribution or --no-ticket-reason.");
724
+ console.log(JSON.stringify({ agent_id: state.agent_id, display_name: state.display_name, session_id: state.session_id, expires_at: state.expires_at }));
725
+ }
726
+
574
727
  // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
575
728
  // credential is actually accepted. `status` used to decode only local metadata,
576
729
  // so a Keychain item holding a typed password (the BOT-1566 incident) was