@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.
- package/package.json +1 -1
- package/src/agent-cleanup.mjs +150 -0
- package/src/agent-key.mjs +1 -1
- package/src/agent-session.mjs +0 -0
- package/src/agent-state-fs.mjs +90 -0
- package/src/agent-state.mjs +78 -11
- package/src/codex-bridge.mjs +364 -15
- package/src/commands.mjs +153 -0
- package/src/session-bootstrap.mjs +375 -0
- package/src/wait.mjs +6 -2
package/src/codex-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
651
|
+
cwd: threadCwd,
|
|
415
652
|
approvalPolicy: cmd.params.approval_policy || "never",
|
|
416
|
-
sandbox: cmd.params
|
|
653
|
+
sandbox: resolveThreadStartSandbox(cmd.params),
|
|
417
654
|
});
|
|
418
655
|
// Track thread
|
|
419
656
|
if (result?.thread?.id) {
|
|
420
|
-
|
|
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:
|
|
427
|
-
}
|
|
428
|
-
|
|
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
|