@botbuddy/cli 1.29.4 → 1.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-doctor.mjs +93 -0
- package/src/agent-key.mjs +14 -9
- package/src/agent-session.mjs +0 -0
- package/src/agent-state.mjs +166 -0
- package/src/commands.mjs +151 -12
- package/src/credential-kinds.mjs +4 -11
- package/src/docker-hygiene.mjs +114 -16
- package/src/pw/coordinator.mjs +7 -1
- package/src/pw/run.mjs +37 -11
- package/src/run.mjs +59 -12
- package/src/setup-block.mjs +16 -27
- package/src/stack.mjs +50 -14
- package/src/test-lane.mjs +45 -14
- package/src/wait.mjs +245 -168
package/src/wait.mjs
CHANGED
|
@@ -18,7 +18,9 @@ import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
|
-
import { AGENT_KEY_RE,
|
|
21
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
22
|
+
import { clearAgentState, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
23
|
+
import { touchAgentStateExpiry } from "./agent-state.mjs";
|
|
22
24
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
23
25
|
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
24
26
|
import { realpath } from "node:fs/promises";
|
|
@@ -28,7 +30,7 @@ import { realpath } from "node:fs/promises";
|
|
|
28
30
|
// implementation gets a typed, safe upgrade instruction.
|
|
29
31
|
// BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
|
|
30
32
|
// server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
|
|
31
|
-
// BOT-
|
|
33
|
+
// BOT-1649: protocol 3 — a $BOTBUDDY_AGENT_SESSION_TOKEN (bb_sess_) authenticates the
|
|
32
34
|
// wait on its own; no profile / --session-id / --token needed (the relay derives
|
|
33
35
|
// agent, tenant, and session from the token). Falls back to protocol-2 behaviour
|
|
34
36
|
// when no session token is present.
|
|
@@ -36,10 +38,14 @@ export const WAIT_PROTOCOL_VERSION = 3;
|
|
|
36
38
|
const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
|
|
37
39
|
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
38
40
|
const MAX_RECEIPT_MAX_BYTES = 64 * 1024;
|
|
39
|
-
// BOT-
|
|
40
|
-
// BOT-1572
|
|
41
|
+
// BOT-1649: bb_sess_ is canonical; bb_agent_ remains accepted as a one-release
|
|
42
|
+
// alias (BOT-1582/1572 shape: bb_(agent|sess)_ + 64 hex).
|
|
41
43
|
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
42
44
|
|
|
45
|
+
export function shouldRenewCachedSession(error, credentialSource, attempts) {
|
|
46
|
+
return isRejectedCachedSession(error, credentialSource, attempts);
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
44
50
|
|
|
45
51
|
USAGE
|
|
@@ -122,25 +128,20 @@ OPTIONS
|
|
|
122
128
|
--receipt-max-bytes <n> cap the receipt (512..65536; default 10240; payloads truncate to pointers)
|
|
123
129
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
124
130
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
125
|
-
--agent-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
(--session-token is the one-release legacy alias.)
|
|
129
|
-
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
|
|
130
|
-
Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
|
|
131
|
+
--agent-session-token <token> advanced override for a managed runner; normal worktrees do not need it.
|
|
132
|
+
(--agent-key and --session-token are one-release aliases.)
|
|
133
|
+
--session-id <uuid> advanced attribution override; normal worktrees derive it automatically.
|
|
131
134
|
--help show this help
|
|
132
135
|
resume <local_wait_id> recover one explicitly named saved wait
|
|
133
136
|
cancel <local_wait_id> explicitly stop one saved wait
|
|
134
137
|
acknowledge <local_wait_id> mark one saved terminal receipt read
|
|
135
138
|
|
|
136
139
|
AUTH
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
--token and --profile are RETIRED (BOT-1574)
|
|
141
|
-
|
|
142
|
-
per-machine client key (botbuddy login) is a setup credential, never a wait
|
|
143
|
-
credential.
|
|
140
|
+
In a configured project worktree, bb wait restores or renews its session automatically.
|
|
141
|
+
For first-time setup or repair, run bb setup once, then retry the same wait. No environment
|
|
142
|
+
variables, copied identifiers, or credential flags are needed for normal waits.
|
|
143
|
+
--token and --profile are RETIRED (BOT-1574); the session override above is for managed
|
|
144
|
+
runners only. The per-machine client key is a setup credential, never a wait credential.
|
|
144
145
|
|
|
145
146
|
OUTPUT
|
|
146
147
|
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
@@ -184,11 +185,10 @@ function parseArgv(argv) {
|
|
|
184
185
|
// BOT-1467: attribute this wait to the arming session's agent (the id from
|
|
185
186
|
// register_agent) instead of the tenant-bound profile agent. Env fallback.
|
|
186
187
|
sessionId: process.env.BOTBUDDY_SESSION_ID || null,
|
|
187
|
-
// BOT-
|
|
188
|
-
// profile, --session-id, and --token are unnecessary.
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
sessionToken: readAgentKeyEnv(process.env),
|
|
188
|
+
// BOT-1649: the per-session token. When present it is the ONLY credential —
|
|
189
|
+
// profile, --session-id, and --token are unnecessary.
|
|
190
|
+
// $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; aliases remain accepted.
|
|
191
|
+
sessionToken: readAgentSessionTokenEnv(process.env),
|
|
192
192
|
help: false,
|
|
193
193
|
deadlineAt: null,
|
|
194
194
|
resumeLocalWait: null,
|
|
@@ -218,13 +218,13 @@ function parseArgv(argv) {
|
|
|
218
218
|
// following value so it is not mis-parsed as a condition, then fail fast in
|
|
219
219
|
// runWait with a migration hint (AC3).
|
|
220
220
|
else if (a === "--token" || a === "--profile") { opts.retiredFlag ??= a; optionValue(); }
|
|
221
|
-
// BOT-
|
|
222
|
-
// carries the
|
|
223
|
-
// $
|
|
221
|
+
// BOT-1649: --agent-session-token is the canonical one-liner for the wait
|
|
222
|
+
// credential — it carries the bb_sess_ token (equivalent to exporting
|
|
223
|
+
// $BOTBUDDY_AGENT_SESSION_TOKEN), and NOTHING else: a client key / profile key is the
|
|
224
224
|
// wrong shape and is refused invalid_session_token, so it can never smuggle a
|
|
225
225
|
// machine credential onto a wait. --session-token is the one-release legacy
|
|
226
226
|
// alias for the same value.
|
|
227
|
-
else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
|
|
227
|
+
else if (a === "--agent-session-token" || a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
|
|
228
228
|
else if (a === "--session-id") opts.sessionId = optionValue();
|
|
229
229
|
else if (a === "--resume-local-wait") opts.resumeLocalWait = optionValue();
|
|
230
230
|
else if (a.startsWith("--")) opts.unknown = a;
|
|
@@ -259,8 +259,8 @@ async function reserveClaimQueueBoundary(opts, conditions) {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
async function registerWait(opts, conditions, deadlineIso) {
|
|
262
|
-
// BOT-
|
|
263
|
-
// ($
|
|
262
|
+
// BOT-1649: a relay wait authenticates from the per-session `bb_sess_` token
|
|
263
|
+
// ($BOTBUDDY_AGENT_SESSION_TOKEN) — the relay derives agent, tenant, and session from it.
|
|
264
264
|
// The retired protocol-2 profile register (profile + expected_tenant) is gone
|
|
265
265
|
// with the profile bridge; runWait fails closed before here if no session token.
|
|
266
266
|
const registerBody = {
|
|
@@ -492,6 +492,21 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
|
|
|
492
492
|
// parsed frames. Server emits `id: <seq>` per signal and replays `seq > since`
|
|
493
493
|
// on connect, so a reconnect never loses an event.
|
|
494
494
|
function makeConnect(opts) {
|
|
495
|
+
// BOT-1649: mirror the relay's keepalive expiry roll into the 0600 cache. The
|
|
496
|
+
// server bumps session_token_expires_at on connect and every keepalive, but
|
|
497
|
+
// the cache keeps its register-time deadline — so a wait near the 8 h cap goes
|
|
498
|
+
// locally stale ~5 min early while the server token is still valid, and a
|
|
499
|
+
// concurrent command would remint and reconcile away this running wait. Touch
|
|
500
|
+
// the cache on live-stream activity, throttled to at most one write / 4 min.
|
|
501
|
+
// touchAgentStateExpiry self-gates on a token match, so flag/env sources whose
|
|
502
|
+
// token isn't the cached one are a no-op.
|
|
503
|
+
let lastCacheTouch = 0;
|
|
504
|
+
const touchCache = () => {
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
if (now - lastCacheTouch < 240_000) return;
|
|
507
|
+
lastCacheTouch = now;
|
|
508
|
+
void touchAgentStateExpiry(process.cwd(), opts.token).catch(() => {});
|
|
509
|
+
};
|
|
495
510
|
return async function connect(since) {
|
|
496
511
|
const sinceParam = normalizeSince(since);
|
|
497
512
|
const url = new URL(`${opts.url.replace(/\/$/, "")}/event-stream`);
|
|
@@ -527,7 +542,7 @@ function makeConnect(opts) {
|
|
|
527
542
|
if (res.status === 403) return errorStream("forbidden");
|
|
528
543
|
if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
|
|
529
544
|
|
|
530
|
-
return sseFrameStream(res.body, { onIdle: () => ac.abort() });
|
|
545
|
+
return sseFrameStream(res.body, { onIdle: () => ac.abort(), onActivity: touchCache });
|
|
531
546
|
};
|
|
532
547
|
}
|
|
533
548
|
|
|
@@ -605,7 +620,7 @@ function sseIdleTimeoutMs() {
|
|
|
605
620
|
|
|
606
621
|
const SSE_IDLE = Symbol("sse_idle");
|
|
607
622
|
|
|
608
|
-
export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
|
|
623
|
+
export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null, onActivity = null } = {}) {
|
|
609
624
|
const decoder = new TextDecoder();
|
|
610
625
|
let buf = "";
|
|
611
626
|
const iterator = body[Symbol.asyncIterator]();
|
|
@@ -633,6 +648,11 @@ export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdl
|
|
|
633
648
|
}
|
|
634
649
|
const { value: chunk, done } = result;
|
|
635
650
|
if (done) return;
|
|
651
|
+
// A chunk (data frame OR a keepalive comment) means the relay is live and
|
|
652
|
+
// has just rolled the session's server-side expiry forward. Let the caller
|
|
653
|
+
// mirror that roll locally (BOT-1649) so a long wait's cache never goes
|
|
654
|
+
// stale ahead of the still-valid token.
|
|
655
|
+
if (onActivity) onActivity();
|
|
636
656
|
buf += decoder.decode(chunk, { stream: true });
|
|
637
657
|
const { frames, rest } = parseSseFrames(buf);
|
|
638
658
|
buf = rest;
|
|
@@ -712,15 +732,14 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
|
|
|
712
732
|
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
713
733
|
// and env-var names only.
|
|
714
734
|
export const RECOVERY = Object.freeze({
|
|
715
|
-
// BOT-
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
sessionToken: "
|
|
719
|
-
|
|
720
|
-
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
735
|
+
// BOT-1649: normal waits restore or mint their per-worktree session themselves.
|
|
736
|
+
// Keep recovery as one concise command; credential plumbing is intentionally
|
|
737
|
+
// hidden from agents and operators.
|
|
738
|
+
sessionToken: "run `bb setup` from this project worktree, then retry the same wait (or `bb doctor --fix` for diagnosis). See docs/agent-connectivity.md",
|
|
739
|
+
sessionId: "run `bb setup` from this project worktree, then retry the same wait",
|
|
721
740
|
// A --token / session-token contradiction: the session token is the whole
|
|
722
741
|
// credential, so drop --token (never advise also setting a session id here).
|
|
723
|
-
sessionTokenConflict: "unset --token —
|
|
742
|
+
sessionTokenConflict: "unset --token — the session credential is the whole credential (protocol 3)",
|
|
724
743
|
// Grammar/condition errors point at the help and the canonical doc.
|
|
725
744
|
conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
|
|
726
745
|
});
|
|
@@ -745,7 +764,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
|
|
|
745
764
|
// Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
|
|
746
765
|
// authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
|
|
747
766
|
// would only 401. Discriminate by the credential's SOURCE, not its shape: a
|
|
748
|
-
// session token arrives only via $
|
|
767
|
+
// session token arrives only via $BOTBUDDY_AGENT_SESSION_TOKEN / --agent-session-token
|
|
749
768
|
// (opts.sessionToken), whereas a resolved PROFILE credential may legitimately be a
|
|
750
769
|
// legacy bb_agent_ CARRIER key that ingest CAN authenticate — the two share a shape
|
|
751
770
|
// (BOT-1582), so a shape check would wrongly drop the carrier case (Codex P1).
|
|
@@ -928,42 +947,89 @@ export function restoreCapacityStaleDeadlines(conditions, checkpoint) {
|
|
|
928
947
|
return conditions;
|
|
929
948
|
}
|
|
930
949
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const controller = new AbortController();
|
|
939
|
-
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
950
|
+
// BOT-1649 (Codex round-5 P1): recovery actions (cancel / finalize / acknowledge
|
|
951
|
+
// / lookup) must authenticate with the SAME managed credential the arm used —
|
|
952
|
+
// flag → env → 0600 cache → self-heal — not env alone. After the variable-free
|
|
953
|
+
// `bb setup` flow the session token lives only in the worktree cache, so an
|
|
954
|
+
// env-only read left cancel/finalize permanently `*_pending` and could orphan a
|
|
955
|
+
// claim wait that later grabs its resource.
|
|
956
|
+
export async function resolveRecoveryToken(cwd = process.cwd(), env = process.env) {
|
|
940
957
|
try {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
+
const credential = await resolveAgentSessionCredential({ argv: [], env, cwd });
|
|
959
|
+
const token = credential?.token ?? null;
|
|
960
|
+
return token && SESSION_TOKEN_RE.test(token) ? token : null;
|
|
961
|
+
} catch { return null; }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// BOT-1649 (Codex round-6 P1): every recovery call (cancel / finalize /
|
|
965
|
+
// acknowledge / lookup) POSTs to /event-stream and must survive a
|
|
966
|
+
// server-revoked CACHE token exactly like normal wait registration. Resolve the
|
|
967
|
+
// credential WITH its source, and on a 401 session-token rejection of a
|
|
968
|
+
// cache-sourced token, clear the 0600 cache, re-mint under the durable client
|
|
969
|
+
// key, and retry the request once. Without this a locally cancelled/finalized
|
|
970
|
+
// claim wait stays `*_pending` forever while its cloud row remains ACTIVE and
|
|
971
|
+
// can still acquire the resource. Flag/env tokens are caller-owned and never
|
|
972
|
+
// silently replaced; the re-mint source is "minted", so a second rejection
|
|
973
|
+
// cannot loop. Returns one of: {error} (no usable token), {transportError:
|
|
974
|
+
// "timeout"|"unavailable"}, or {ok, status, body}.
|
|
975
|
+
async function postRecovery(relayUrl, payload, { cwd = process.cwd(), env = process.env } = {}) {
|
|
976
|
+
let credential = null;
|
|
977
|
+
try { credential = await resolveAgentSessionCredential({ argv: [], env, cwd }); } catch { credential = null; }
|
|
978
|
+
let token = credential?.token ?? null;
|
|
979
|
+
let source = credential?.source ?? null;
|
|
980
|
+
if (!token || !SESSION_TOKEN_RE.test(token)) return { error: "session_token_required" };
|
|
981
|
+
const baseUrl = relayUrl || env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
|
|
982
|
+
const url = `${baseUrl.replace(/\/$/, "")}/event-stream`;
|
|
983
|
+
let attempts = 0;
|
|
984
|
+
for (;;) {
|
|
985
|
+
const controller = new AbortController();
|
|
986
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
987
|
+
let res;
|
|
988
|
+
try {
|
|
989
|
+
res = await fetch(url, {
|
|
990
|
+
method: "POST",
|
|
991
|
+
headers: { Authorization: `Bearer ${token}`, "x-agent-api-key": token, "Content-Type": "application/json" },
|
|
992
|
+
body: JSON.stringify(payload),
|
|
993
|
+
signal: controller.signal,
|
|
994
|
+
});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
clearTimeout(timer);
|
|
997
|
+
return { transportError: error?.name === "AbortError" ? "timeout" : "unavailable" };
|
|
958
998
|
}
|
|
959
|
-
return { delivered: true };
|
|
960
|
-
} catch (error) {
|
|
961
|
-
return { delivered: false, error: error?.name === "AbortError" ? `${action}_timeout` : `${action}_unavailable` };
|
|
962
|
-
} finally {
|
|
963
999
|
clearTimeout(timer);
|
|
1000
|
+
const body = await res.json().catch(() => ({}));
|
|
1001
|
+
// A revoked/expired session token returns a typed 401. Renew a cache token
|
|
1002
|
+
// once (isRejectedCachedSession gates on source==="cache" && attempts===0).
|
|
1003
|
+
if (res.status === 401 && isRejectedCachedSession({ code: body?.error }, source, attempts)) {
|
|
1004
|
+
attempts += 1;
|
|
1005
|
+
await clearAgentState(cwd);
|
|
1006
|
+
let renewed = null;
|
|
1007
|
+
try { renewed = await selfHealAgentSession({ cwd, env }); } catch { renewed = null; }
|
|
1008
|
+
if (renewed?.token && SESSION_TOKEN_RE.test(renewed.token)) {
|
|
1009
|
+
token = renewed.token;
|
|
1010
|
+
source = renewed.source;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { ok: res.ok, status: res.status, body };
|
|
964
1015
|
}
|
|
965
1016
|
}
|
|
966
1017
|
|
|
1018
|
+
async function deliverRecoveryAction(checkpoint, action) {
|
|
1019
|
+
if (!checkpoint.cloud_wait_session_id) return { delivered: true };
|
|
1020
|
+
const result = await postRecovery(checkpoint.relay_url, { action, wait_session_id: checkpoint.cloud_wait_session_id });
|
|
1021
|
+
if (result.error) return { delivered: false, error: result.error };
|
|
1022
|
+
if (result.transportError) return { delivered: false, error: `${action}_${result.transportError}` };
|
|
1023
|
+
const resultKey = action === "acknowledge" ? "acknowledged" : "cancelled";
|
|
1024
|
+
// The recovery endpoint is deliberately idempotent and uses a 200 response
|
|
1025
|
+
// for a rejected state transition. Only its explicit boolean proves that
|
|
1026
|
+
// the cloud row changed; otherwise retain the local delivery outbox.
|
|
1027
|
+
if (!result.ok || result.body?.[resultKey] !== true) {
|
|
1028
|
+
return { delivered: false, error: result.body?.error || `${action}_not_applied` };
|
|
1029
|
+
}
|
|
1030
|
+
return { delivered: true };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
967
1033
|
function checkpointNeedsRelay(checkpoint) {
|
|
968
1034
|
return checkpoint.conditions.some((condition) => condition.type !== "timer");
|
|
969
1035
|
}
|
|
@@ -972,85 +1038,51 @@ async function resolveRecoveryWaitSession(checkpoint, { reserveMissing = false }
|
|
|
972
1038
|
if (checkpoint.cloud_wait_session_id || !checkpointNeedsRelay(checkpoint)) {
|
|
973
1039
|
return { registration: null };
|
|
974
1040
|
}
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
1041
|
+
const result = await postRecovery(checkpoint.relay_url, {
|
|
1042
|
+
action: reserveMissing ? "recover_cancel_absent" : "recover_lookup",
|
|
1043
|
+
local_wait_id: checkpoint.local_wait_id,
|
|
1044
|
+
});
|
|
1045
|
+
if (result.error) return { error: result.error };
|
|
1046
|
+
if (result.transportError) {
|
|
1047
|
+
return { error: result.transportError === "timeout" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
|
|
978
1048
|
}
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
method: "POST",
|
|
985
|
-
headers: {
|
|
986
|
-
Authorization: `Bearer ${token}`,
|
|
987
|
-
"x-agent-api-key": token,
|
|
988
|
-
"Content-Type": "application/json",
|
|
989
|
-
},
|
|
990
|
-
body: JSON.stringify({ action: reserveMissing ? "recover_cancel_absent" : "recover_lookup", local_wait_id: checkpoint.local_wait_id }),
|
|
991
|
-
signal: controller.signal,
|
|
992
|
-
});
|
|
993
|
-
const body = await res.json().catch(() => ({}));
|
|
994
|
-
if (!res.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
|
|
995
|
-
if (body?.found !== true) return { missing: true };
|
|
996
|
-
if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
|
|
997
|
-
return { error: "wait_session_id_unavailable" };
|
|
998
|
-
}
|
|
999
|
-
return {
|
|
1000
|
-
registration: {
|
|
1001
|
-
waitSessionId: body.wait_session_id,
|
|
1002
|
-
cursorStart: body.cursor_start ?? null,
|
|
1003
|
-
sessionTenant: checkpoint.tenant_id ?? null,
|
|
1004
|
-
agentId: checkpoint.agent_id ?? null,
|
|
1005
|
-
sessionId: checkpoint.arming_session_id ?? null,
|
|
1006
|
-
sessionAgentId: null,
|
|
1007
|
-
status: typeof body.status === "string" ? body.status : "active",
|
|
1008
|
-
terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
|
|
1009
|
-
},
|
|
1010
|
-
};
|
|
1011
|
-
} catch (error) {
|
|
1012
|
-
return { error: error?.name === "AbortError" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
|
|
1013
|
-
} finally {
|
|
1014
|
-
clearTimeout(timer);
|
|
1049
|
+
const body = result.body ?? {};
|
|
1050
|
+
if (!result.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
|
|
1051
|
+
if (body?.found !== true) return { missing: true };
|
|
1052
|
+
if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
|
|
1053
|
+
return { error: "wait_session_id_unavailable" };
|
|
1015
1054
|
}
|
|
1055
|
+
return {
|
|
1056
|
+
registration: {
|
|
1057
|
+
waitSessionId: body.wait_session_id,
|
|
1058
|
+
cursorStart: body.cursor_start ?? null,
|
|
1059
|
+
sessionTenant: checkpoint.tenant_id ?? null,
|
|
1060
|
+
agentId: checkpoint.agent_id ?? null,
|
|
1061
|
+
sessionId: checkpoint.arming_session_id ?? null,
|
|
1062
|
+
sessionAgentId: null,
|
|
1063
|
+
status: typeof body.status === "string" ? body.status : "active",
|
|
1064
|
+
terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1016
1067
|
}
|
|
1017
1068
|
|
|
1018
1069
|
async function deliverTerminalFinalization(checkpoint) {
|
|
1019
1070
|
if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt) return { delivered: true };
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
},
|
|
1034
|
-
body: JSON.stringify({
|
|
1035
|
-
action: "recover_finalize",
|
|
1036
|
-
wait_session_id: checkpoint.cloud_wait_session_id,
|
|
1037
|
-
status: receipt.outcome,
|
|
1038
|
-
receipt,
|
|
1039
|
-
reconnects: receipt.reconnects ?? 0,
|
|
1040
|
-
degraded: receipt.degraded ?? [],
|
|
1041
|
-
}),
|
|
1042
|
-
signal: controller.signal,
|
|
1043
|
-
});
|
|
1044
|
-
const body = await res.json().catch(() => ({}));
|
|
1045
|
-
if (!res.ok || body?.finalized !== true) {
|
|
1046
|
-
return { delivered: false, error: body?.error || "finalize_not_applied" };
|
|
1047
|
-
}
|
|
1048
|
-
return { delivered: true };
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
return { delivered: false, error: error?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
|
|
1051
|
-
} finally {
|
|
1052
|
-
clearTimeout(timer);
|
|
1071
|
+
const receipt = checkpoint.terminal_receipt;
|
|
1072
|
+
const result = await postRecovery(checkpoint.relay_url, {
|
|
1073
|
+
action: "recover_finalize",
|
|
1074
|
+
wait_session_id: checkpoint.cloud_wait_session_id,
|
|
1075
|
+
status: receipt.outcome,
|
|
1076
|
+
receipt,
|
|
1077
|
+
reconnects: receipt.reconnects ?? 0,
|
|
1078
|
+
degraded: receipt.degraded ?? [],
|
|
1079
|
+
});
|
|
1080
|
+
if (result.error) return { delivered: false, error: result.error };
|
|
1081
|
+
if (result.transportError) return { delivered: false, error: `finalize_${result.transportError}` };
|
|
1082
|
+
if (!result.ok || result.body?.finalized !== true) {
|
|
1083
|
+
return { delivered: false, error: result.body?.error || "finalize_not_applied" };
|
|
1053
1084
|
}
|
|
1085
|
+
return { delivered: true };
|
|
1054
1086
|
}
|
|
1055
1087
|
|
|
1056
1088
|
async function flushPendingFinalization(store, checkpoint) {
|
|
@@ -1329,9 +1361,8 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1329
1361
|
// a typed `unknown_option` receipt, then exit 4 — before any relay call.
|
|
1330
1362
|
if (opts.retiredFlag) {
|
|
1331
1363
|
process.stderr.write(
|
|
1332
|
-
`botbuddy wait: ${opts.retiredFlag} is retired
|
|
1333
|
-
+ "
|
|
1334
|
-
+ "The per-machine client key (botbuddy login) is never a wait credential.\n",
|
|
1364
|
+
`botbuddy wait: ${opts.retiredFlag} is retired — run bb wait from a configured worktree and it restores its session automatically. `
|
|
1365
|
+
+ "If setup is needed, run bb setup, then retry the same wait.\n",
|
|
1335
1366
|
);
|
|
1336
1367
|
emitReceipt({
|
|
1337
1368
|
schema_version: 1,
|
|
@@ -1472,20 +1503,40 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1472
1503
|
}
|
|
1473
1504
|
|
|
1474
1505
|
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
1475
|
-
|
|
1476
|
-
|
|
1506
|
+
if (needsRelay) {
|
|
1507
|
+
try {
|
|
1508
|
+
const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });
|
|
1509
|
+
opts.sessionToken = credential.token;
|
|
1510
|
+
opts.sessionId = credential.sessionId ?? opts.sessionId;
|
|
1511
|
+
opts.credentialSource = credential.source;
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
const code = error?.code ?? "session_mint_failed";
|
|
1514
|
+
const recovery = code === "client_key_required"
|
|
1515
|
+
? "run `bb setup` from this project worktree, then retry"
|
|
1516
|
+
: code === "tenant_binding_required"
|
|
1517
|
+
? "run from a configured project worktree, then run `bb setup` and retry"
|
|
1518
|
+
: "run `bb setup` and retry";
|
|
1519
|
+
process.stderr.write(`botbuddy wait: ${error?.message ?? error}; ${recovery}\n`);
|
|
1520
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
1521
|
+
await reportSetupError(opts, { error: code, exitCode: EXIT.AUTH, conditions });
|
|
1522
|
+
process.exit(EXIT.AUTH);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
// BOT-1649: a relay wait authenticates from the per-session
|
|
1526
|
+
// $BOTBUDDY_AGENT_SESSION_TOKEN (bb_sess_; bb_agent_ remains a legacy alias)
|
|
1527
|
+
// — the relay derives agent, tenant, and session
|
|
1477
1528
|
// from the token. The retired protocol-2 profile register (profile + --session-id
|
|
1478
1529
|
// + a tenant-bound carrier from `profile setup`) is gone with the profile bridge,
|
|
1479
|
-
// so
|
|
1530
|
+
// so an absent/stale cache self-heals before this validation.
|
|
1480
1531
|
if (needsRelay && opts.sessionToken) {
|
|
1481
1532
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
1482
|
-
process.stderr.write(`botbuddy wait:
|
|
1533
|
+
process.stderr.write(`botbuddy wait: BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy prefix); ${RECOVERY.sessionToken}\n`);
|
|
1483
1534
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
1484
1535
|
await reportSetupError(opts, { error: "invalid_session_token", exitCode: EXIT.INVALID, conditions });
|
|
1485
1536
|
process.exit(EXIT.INVALID);
|
|
1486
1537
|
}
|
|
1487
1538
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
1488
|
-
process.stderr.write(`botbuddy wait: --token conflicts with $
|
|
1539
|
+
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
|
|
1489
1540
|
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
1490
1541
|
await reportSetupError(opts, { error: "session_token_conflict", exitCode: EXIT.INVALID, conditions });
|
|
1491
1542
|
process.exit(EXIT.INVALID);
|
|
@@ -1495,7 +1546,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1495
1546
|
} else if (needsRelay) {
|
|
1496
1547
|
// No session token → the wait has no credential. Fail fast (before any network
|
|
1497
1548
|
// call) so a wait is never filed under whatever machine credential is lying
|
|
1498
|
-
// around.
|
|
1549
|
+
// around. The normal recovery is the one-command worktree bootstrap.
|
|
1499
1550
|
process.stderr.write(
|
|
1500
1551
|
`botbuddy wait: a relay wait requires the per-session agent token — ${RECOVERY.sessionToken}, then: botbuddy wait '<condition>'\n`,
|
|
1501
1552
|
);
|
|
@@ -1537,14 +1588,45 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1537
1588
|
let replayExpiredRecovery = false;
|
|
1538
1589
|
if (needsRelay) {
|
|
1539
1590
|
try {
|
|
1540
|
-
//
|
|
1541
|
-
// database clock. An expired recovery with no
|
|
1542
|
-
// cannot release a queue re-created by another
|
|
1591
|
+
// BOT-1656: the claim-queue reservation is made while this checkpoint is
|
|
1592
|
+
// still live, on the database clock. An expired recovery with no
|
|
1593
|
+
// reservation fails closed and cannot release a queue re-created by another
|
|
1594
|
+
// logical wait. It happens once, before (and outside) the self-heal retry.
|
|
1543
1595
|
if ((Date.now() < deadlineMs || recoveryLocalWaitId != null) &&
|
|
1544
1596
|
conditions.some((condition) => condition.type === "lock" && condition.params?.claim === true)) {
|
|
1545
1597
|
await reserveClaimQueueBoundary(opts, conditions);
|
|
1546
1598
|
}
|
|
1547
|
-
|
|
1599
|
+
// BOT-1649: a revoked/expired token from the 0600 cache is recoverable:
|
|
1600
|
+
// remove only that cache, re-adopt the public agent id under the client
|
|
1601
|
+
// key, and retry registration exactly once. Explicit flags/env are never
|
|
1602
|
+
// silently replaced because the caller owns those credentials. Re-register
|
|
1603
|
+
// is idempotent on the checkpoint's logical_wait_id (BOT-1656), so the
|
|
1604
|
+
// reservation above is not repeated.
|
|
1605
|
+
let attempts = 0;
|
|
1606
|
+
let reg;
|
|
1607
|
+
for (;;) {
|
|
1608
|
+
try {
|
|
1609
|
+
reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
|
|
1610
|
+
break;
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
if (!shouldRenewCachedSession(error, opts.credentialSource, attempts)) throw error;
|
|
1613
|
+
attempts += 1;
|
|
1614
|
+
await clearAgentState(process.cwd());
|
|
1615
|
+
const credential = await selfHealAgentSession({ cwd: process.cwd(), env: process.env });
|
|
1616
|
+
opts.sessionToken = credential.token;
|
|
1617
|
+
opts.sessionId = credential.sessionId ?? opts.sessionId;
|
|
1618
|
+
opts.credentialSource = credential.source;
|
|
1619
|
+
opts.token = credential.token;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
// BOT-1649 (Codex P1): the server just rolled this session's expiry forward
|
|
1623
|
+
// on register. Mirror that roll into the 0600 cache so a token in active use
|
|
1624
|
+
// is never judged locally stale and re-minted — a re-mint would revoke the
|
|
1625
|
+
// token a concurrent wait still holds. Touch only the cache we actually used
|
|
1626
|
+
// (source "cache"); a "minted" source already wrote a fresh deadline.
|
|
1627
|
+
if (opts.credentialSource === "cache") {
|
|
1628
|
+
await touchAgentStateExpiry(process.cwd(), opts.token).catch(() => {});
|
|
1629
|
+
}
|
|
1548
1630
|
waitSessionId = reg.waitSessionId;
|
|
1549
1631
|
opts.waitSessionId = waitSessionId;
|
|
1550
1632
|
sessionTenant = reg.sessionTenant;
|
|
@@ -1679,12 +1761,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1679
1761
|
process.exit(EXIT.INVALID);
|
|
1680
1762
|
}
|
|
1681
1763
|
if (err && err.auth) {
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
// register a work agent and export its session token.
|
|
1764
|
+
// A per-machine client key cannot arm a wait directly. Keep the recovery
|
|
1765
|
+
// to the normal worktree bootstrap rather than exposing session internals.
|
|
1685
1766
|
if (err.errorCode === "client_key_cannot_wait") {
|
|
1686
1767
|
process.stderr.write(
|
|
1687
|
-
"botbuddy wait:
|
|
1768
|
+
"botbuddy wait: this worktree needs a session; run bb setup, then retry the same wait\n",
|
|
1688
1769
|
);
|
|
1689
1770
|
emitReceipt({
|
|
1690
1771
|
schema_version: 1,
|
|
@@ -1694,11 +1775,8 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1694
1775
|
});
|
|
1695
1776
|
process.exit(EXIT.AUTH);
|
|
1696
1777
|
}
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
// a wrong-tenant session. Retrying with the SAME identity just repeats the
|
|
1700
|
-
// 403, so lead with the reliable fix: register a WORK agent and export its
|
|
1701
|
-
// NEW session token/id.
|
|
1778
|
+
// Session-identity failures need a new worktree session. Do not make the
|
|
1779
|
+
// caller reconstruct it by hand.
|
|
1702
1780
|
const SESSION_IDENTITY_ERRORS = new Set([
|
|
1703
1781
|
"wait_actor_required", "session_agent_forbidden", "session_agent_tenant_mismatch",
|
|
1704
1782
|
]);
|
|
@@ -1709,29 +1787,28 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1709
1787
|
? "the session token resolves to a different tenant than this wait"
|
|
1710
1788
|
: "the session token is not a live agent session you own";
|
|
1711
1789
|
process.stderr.write(
|
|
1712
|
-
`botbuddy wait: ${cause};
|
|
1790
|
+
`botbuddy wait: ${cause}; run bb setup, then retry the same wait\n`,
|
|
1713
1791
|
);
|
|
1714
1792
|
emitReceipt(withPrincipalReceipt({
|
|
1715
1793
|
schema_version: 1,
|
|
1716
1794
|
outcome: "error",
|
|
1717
1795
|
error: err.errorCode,
|
|
1718
1796
|
...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
|
|
1719
|
-
recovery: "
|
|
1797
|
+
recovery: "run `bb setup`, then retry the same wait",
|
|
1720
1798
|
}, null, { sessionTenant, agentId: null }));
|
|
1721
1799
|
await reportSetupError(opts, { error: err.errorCode, exitCode: EXIT.AUTH, conditions });
|
|
1722
1800
|
process.exit(EXIT.AUTH);
|
|
1723
1801
|
}
|
|
1724
1802
|
// Any other relay auth refusal (revoked/expired/rotated token, or the relay
|
|
1725
|
-
// refusing to honour it) is
|
|
1726
|
-
// session token — the profile bridge and its recoveries are gone (BOT-1608).
|
|
1803
|
+
// refusing to honour it) is repaired by the worktree bootstrap.
|
|
1727
1804
|
process.stderr.write(
|
|
1728
|
-
`botbuddy wait: session
|
|
1805
|
+
`botbuddy wait: session rejected (${err.errorCode || "unauthorized"}) — run bb setup, then retry the same wait\n`,
|
|
1729
1806
|
);
|
|
1730
1807
|
emitReceipt(withPrincipalReceipt({
|
|
1731
1808
|
schema_version: 1,
|
|
1732
1809
|
outcome: "error",
|
|
1733
1810
|
error: err.errorCode || "unauthorized",
|
|
1734
|
-
recovery: "
|
|
1811
|
+
recovery: "run `bb setup`, then retry the same wait",
|
|
1735
1812
|
}, null, { sessionTenant, agentId: registeredAgentId, sessionId: registeredSessionId }));
|
|
1736
1813
|
// Session-token path → reportSetupError self-skips (a bb_agent_ bearer
|
|
1737
1814
|
// ingest cannot authenticate); called for allowlist symmetry.
|