@botbuddy/cli 1.45.6 → 1.46.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.45.6",
3
+ "version": "1.46.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
package/src/run.mjs CHANGED
@@ -15,7 +15,9 @@ import { fileURLToPath } from "node:url";
15
15
  import { callToolJson } from "./api.mjs";
16
16
  import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
17
17
  import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
18
- import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
18
+ import { hasAgentSessionRuntime, isRejectedCachedMcpSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
19
+ import { isAgentStateFresh, readAgentState, worktreeRoot } from "./agent-state.mjs";
20
+ import { readAgentBinding } from "./wait-profile.mjs";
19
21
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
20
22
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
21
23
  import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
@@ -231,49 +233,82 @@ function workerInvocation(context) {
231
233
  return [process.execPath, [bin, "run", "--worker", context]];
232
234
  }
233
235
 
236
+ function sessionFailure(code) { return Object.assign(new Error(code), { code }); }
237
+
238
+ // A selected ID is metadata, not a credential. Require positive private-cache
239
+ // authority before resolving it; never let the API's ambient owner key stand in.
240
+ async function resolveRunCredential({ cwd, env, opts, resolveCredential }) {
241
+ const cached = await readAgentState(cwd);
242
+ const explicitId = opts.sessionId;
243
+ const validateBinding = async (identity) => {
244
+ const bound = await readAgentBinding(cwd);
245
+ if (!bound?.tenant || identity?.tenant !== bound.tenant || !identity?.worktree
246
+ || await worktreeRoot(identity.worktree) !== await worktreeRoot(cwd)) {
247
+ throw sessionFailure("run_session_binding_mismatch");
248
+ }
249
+ };
250
+ if (explicitId && !cached) throw sessionFailure("run_session_authority_missing");
251
+ if (cached) {
252
+ if (explicitId && cached.session_id !== explicitId) throw sessionFailure("run_session_mismatch");
253
+ await validateBinding(cached);
254
+ if (explicitId && !isAgentStateFresh(cached, new Date())) throw sessionFailure("run_session_expired");
255
+ } else if (await hasAgentSessionRuntime({ cwd })) {
256
+ throw sessionFailure("run_session_authority_missing");
257
+ }
258
+ const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ cwd, env,
259
+ sessionId: explicitId, flagToken: null });
260
+ // The resolver reads again. A concurrent replacement must satisfy the same
261
+ // binding and explicit ID rather than silently adopting unrelated authority.
262
+ if (credential.source === "cache") {
263
+ await validateBinding(credential.identity);
264
+ if (explicitId && credential.identity?.sessionId !== explicitId) throw sessionFailure("run_session_mismatch");
265
+ }
266
+ return credential;
267
+ }
268
+
269
+ function rejectedSessionReceipt(error, runId) {
270
+ const code = error?.code ?? "agent_session_unavailable";
271
+ const exitCode = ["session_mint_failed", "session_mint_invalid"].includes(code) ? EXIT.BACKEND : EXIT.AUTH;
272
+ return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected",
273
+ ...(runId ? { run_id: runId } : {}), error: code,
274
+ recovery: code === "client_key_required"
275
+ ? "run `bb login` to install the durable client key, then retry"
276
+ : "run `bb doctor --fix` and select the current worktree session, then retry",
277
+ exit_code: exitCode } };
278
+ }
279
+
234
280
  export async function launchRun(argv, {
235
281
  cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
236
- resolveCredential = null, env = process.env,
282
+ resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
237
283
  } = {}) {
238
- let effectiveArgv = argv;
239
- let parsed = parseRunArgs(effectiveArgv, env);
284
+ const parsed = parseRunArgs(argv, env);
240
285
  let managedCredential = null;
241
286
  // A local test double owns its credential boundary; production invocations
242
287
  // self-heal when the session credential was not supplied by flag or env.
243
288
  // Keep all other usage failures local and deterministic before minting.
244
289
  const onlyMissingIdentity = parsed.errors.length === 1 && parsed.errors[0].includes("session-id");
245
- if (!parsed.opts.foreground && !parsed.opts.sessionId && !parsed.opts.sessionToken
246
- && onlyMissingIdentity && (resolveCredential || call === null)) {
290
+ if (!parsed.opts.foreground && !parsed.opts.sessionToken
291
+ && (parsed.errors.length === 0 || onlyMissingIdentity)
292
+ && (resolveCredential || call === null || call === callToolJson)) {
247
293
  try {
248
- const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
294
+ const credential = await resolveRunCredential({ cwd, env, opts: parsed.opts, resolveCredential });
249
295
  managedCredential = credential;
250
- effectiveArgv = ["--agent-session-token", credential.token, ...argv];
251
- parsed = parseRunArgs(effectiveArgv, env);
296
+ parsed.opts.sessionToken = credential.token;
297
+ if (!SESSION_TOKEN_RE.test(credential.token)) throw sessionFailure("session_mint_invalid");
298
+ if (onlyMissingIdentity) parsed.errors.length = 0;
252
299
  } catch (error) {
253
- return {
254
- exitCode: EXIT.AUTH,
255
- receipt: {
256
- schema_version: RUN_SCHEMA_VERSION,
257
- outcome: "rejected",
258
- error: error?.code ?? "agent_session_unavailable",
259
- recovery: error?.code === "client_key_required"
260
- ? "run `bb login` to install the durable client key, then retry"
261
- : "run `bb doctor --fix` to repair agent authentication, then retry",
262
- exit_code: EXIT.AUTH,
263
- },
264
- };
300
+ return rejectedSessionReceipt(error);
265
301
  }
266
302
  }
267
303
  const { opts, command, errors } = parsed;
268
304
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
269
305
  // BOT-1572 (AC-8): when a session token is present it is the credential — pin it
270
306
  // so register_command authenticates as the session's agent and derives session_id
271
- // from the token. Tests inject `call` directly and bypass this.
272
- const effectiveCall = call ?? (opts.sessionToken
273
- ? (t, a) => callToolJson(t, a, { auth: { "x-agent-api-key": opts.sessionToken } })
274
- : callToolJson);
307
+ // from the token. Read the current token on each call, including after renewal.
308
+ const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
309
+ opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
275
310
  const runId = randomUUID();
276
- const registerArgs = {
311
+ const registerArgs = () => ({
277
312
  // Omit session_id entirely when only a token is present — the backend derives
278
313
  // it. A plain id is still sent (and honoured) when supplied.
279
314
  ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
@@ -281,18 +316,22 @@ export async function launchRun(argv, {
281
316
  command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
282
317
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
283
318
  rerun_reason: opts.rerunReason,
284
- };
285
- let registration = await effectiveCall("register_command", registerArgs);
319
+ });
320
+ let registration = await effectiveCall("register_command", registerArgs());
286
321
  // A cached session can be revoked server-side before its local expiry. The
287
322
  // managed cache is ours to replace once; explicit flag/env credentials belong
288
323
  // to the caller and are never silently rotated.
289
324
  if (isRejectedCachedMcpSession(registration, managedCredential?.source, 0)) {
290
- await clearAgentState(cwd);
291
- const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
292
- managedCredential = credential;
293
- opts.sessionToken = credential.token;
294
- opts.sessionId = credential.sessionId ?? opts.sessionId;
295
- registration = await effectiveCall("register_command", registerArgs);
325
+ try {
326
+ const credential = await renewCredential({ cwd, env, force: true, rejectedCredential: {
327
+ token: managedCredential.token, identity: managedCredential.identity,
328
+ } });
329
+ managedCredential = credential;
330
+ opts.sessionToken = credential.token;
331
+ opts.sessionId = credential.identity?.sessionId ?? credential.sessionId;
332
+ if (!SESSION_TOKEN_RE.test(opts.sessionToken) || !opts.sessionId) throw sessionFailure("session_mint_invalid");
333
+ registration = await effectiveCall("register_command", registerArgs());
334
+ } catch (error) { return rejectedSessionReceipt(error, runId); }
296
335
  }
297
336
  if (!registration.ok || registration.isError || !registration.data?.accepted || !registration.data?.run_credential) {
298
337
  return { exitCode: EXIT.BACKEND, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", run_id: runId, error: registration.data?.error ?? registration.error ?? "durable_owner_not_established", exit_code: EXIT.BACKEND } };
@@ -310,7 +349,10 @@ export async function launchRun(argv, {
310
349
  await effectiveCall("update_command_run", { run_id: runId, run_credential: registration.data.run_credential, status: "owner_lost", invalidated_reason: `launcher_failed:${error instanceof Error ? error.message : String(error)}` });
311
350
  return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "owner_lost", run_id: runId, exit_code: EXIT.INTERNAL } };
312
351
  }
313
- return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId, session_id: opts.sessionId, durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
352
+ const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
353
+ return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId,
354
+ ...(knownSession ? { session_id: knownSession } : { session_binding: "server_derived" }),
355
+ durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
314
356
  }
315
357
 
316
358
  export async function runWorker(contextFile, { spawnImpl = spawn, call = callToolJson } = {}) {
package/src/stack.mjs CHANGED
@@ -33,8 +33,7 @@ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
33
33
  import { SERVER_URL, getConfig } from "./config.mjs";
34
34
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
35
35
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
36
- import { resolveAgentSessionCredential } from "./agent-session.mjs";
37
- import { clearAgentState } from "./agent-state.mjs";
36
+ import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
38
37
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
39
38
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
40
39
  import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
@@ -828,7 +827,7 @@ export async function stackAuthHeader({
828
827
  // and silently authenticate as the OAuth/MCP-key principal, which is the exact
829
828
  // principal switch the catch above guards against.
830
829
  if (typeof credential.token === "string" && AGENT_KEY_RE.test(credential.token)) {
831
- return withSource({ "x-agent-api-key": credential.token }, credential.source);
830
+ return withSource({ "x-agent-api-key": credential.token }, credential.source, credential.identity);
832
831
  }
833
832
  throw Object.assign(new Error(`resolved agent session token has an unexpected shape (source: ${credential.source ?? "unknown"}); re-run \`bb doctor --fix\``), { code: "session_token_malformed" });
834
833
  }
@@ -848,37 +847,39 @@ export async function stackAuthHeader({
848
847
  }
849
848
 
850
849
  // BOT-1750 (Codex R7 P2): carry the credential SOURCE on the returned headers as a
851
- // NON-enumerable property, so callers can clear+self-heal+retry once on a
850
+ // NON-enumerable property, so callers can renew+retry once on a
852
851
  // server-rejected cached session (parity with run/test/pw/wait) without the source
853
852
  // ever leaking into the spread fetch headers.
854
- function withSource(headers, source) {
853
+ function withSource(headers, source, identity = null) {
855
854
  Object.defineProperty(headers, "__source", { value: source ?? null, enumerable: false });
855
+ Object.defineProperty(headers, "__identity", { value: identity, enumerable: false });
856
856
  return headers;
857
857
  }
858
858
 
859
859
  /**
860
860
  * BOT-1750 (Codex R7 P2): a locally-fresh cache whose token was revoked server-side
861
861
  * (e.g. a concurrent re-registration rotated the session) is rejected at the lease
862
- * RPC. Mirror the other consumers: when the FIRST auth came from the per-worktree
863
- * cache and the RPC returns an auth rejection, clear the cache and self-heal once,
864
- * then let the caller retry with the fresh headers. Returns the fresh headers, or
865
- * null when no retry applies (not cache-sourced, or the refresh could not authenticate).
862
+ * RPC. Renew once without deleting the cache or losing lease ownership identity.
863
+ * The shared lock re-reads and verifies the cache; a concurrent renewal wins.
864
+ * Failure leaves the existing cache untouched and propagates its typed error.
866
865
  */
867
866
  export async function refreshRejectedCacheAuth(auth, {
868
- cwd = process.cwd(), authProvider = stackAuthHeader, clear = clearAgentState,
867
+ cwd = process.cwd(), renew = selfHealAgentSession,
869
868
  } = {}) {
870
869
  if (auth?.__source !== "cache") return null;
871
- await clear(cwd);
872
- const fresh = await authProvider();
873
- // BOT-1750 (Codex R8 P2): retry ONLY with a genuinely self-healed session. If the
874
- // client key or binding vanished after clearing the cache, authProvider falls
875
- // through to the OAuth/MCP operator credential — a DIFFERENT principal whose token
876
- // also differs, so a bare "different token" check would retry as the wrong agent.
877
- // Require source "minted" AND a well-formed, changed session token.
878
- if (fresh?.__source !== "minted") return null;
879
- const token = fresh["x-agent-api-key"];
870
+ return renewStackSession(auth, { cwd, renew });
871
+ }
872
+
873
+ async function renewStackSession(auth, { cwd, renew }) {
874
+ const credential = await renew({ cwd, force: true,
875
+ rejectedCredential: { token: auth["x-agent-api-key"], identity: auth.__identity } });
876
+ // BOT-1750 (Codex R8 P2): the renewal adapter must never yield an operator
877
+ // principal. "cache" is permitted
878
+ // only for a newer same-identity session verified inside the renewal lock.
879
+ if (credential?.source !== "minted" && credential?.source !== "cache") return null;
880
+ const token = credential.token;
880
881
  if (typeof token !== "string" || !AGENT_KEY_RE.test(token) || token === auth["x-agent-api-key"]) return null;
881
- return fresh;
882
+ return withSource({ "x-agent-api-key": token }, credential.source, credential.identity);
882
883
  }
883
884
 
884
885
  /**
@@ -886,8 +887,8 @@ export async function refreshRejectedCacheAuth(auth, {
886
887
  * server-rejected cached session ONCE — the owner-scoped management commands
887
888
  * (`status`/`touch`/`done`) need the same recovery as the lease-creating paths, or a
888
889
  * revoked-but-locally-fresh cache leaves an active lease unreleasable. `init()`
889
- * resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, clears +
890
- * self-heals + retries with the fresh credential (every later `call` reuses it).
890
+ * resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, renews +
891
+ * retries with the fresh credential (every later `call` reuses it).
891
892
  */
892
893
  export function retryingStackCall({
893
894
  callTool = callToolJson, authProvider = stackAuthHeader,
@@ -1362,25 +1363,18 @@ function abortableSleep(ms, signal) {
1362
1363
  * BOT-2099: re-establish the credential a parked lease waits with. On a transport failure
1363
1364
  * the credential is fine — keep it. On an auth rejection, first re-read (another process in
1364
1365
  * this worktree may already have re-minted, and minting again would supersede it), then
1365
- * clear the per-worktree cache and self-heal once. Only a well-formed agent SESSION token
1366
+ * renew under the shared cache lock without removing recovery identity. Only a well-formed agent SESSION token
1366
1367
  * replaces a session credential: the operator fallback is a different principal that
1367
1368
  * cannot see the lease (BOT-1750 R8), so it is never adopted. Throws the mint error, which
1368
1369
  * the park treats as one more failed attempt.
1369
1370
  */
1370
1371
  export async function reauthenticateParkedLease(current, kind, {
1371
- cwd = process.cwd(), authProvider = stackAuthHeader, clear = clearAgentState,
1372
+ cwd = process.cwd(), renew = selfHealAgentSession,
1372
1373
  } = {}) {
1373
1374
  if (kind !== "auth") return current;
1374
1375
  const source = current?.__source ?? null;
1375
1376
  if (source !== "cache" && source !== "minted") return current;
1376
- const isFreshSession = (h) => (h?.__source === "cache" || h?.__source === "minted")
1377
- && typeof h["x-agent-api-key"] === "string" && AGENT_KEY_RE.test(h["x-agent-api-key"])
1378
- && h["x-agent-api-key"] !== current["x-agent-api-key"];
1379
- const reread = await authProvider();
1380
- if (isFreshSession(reread)) return reread;
1381
- await clear(cwd);
1382
- const minted = await authProvider();
1383
- return isFreshSession(minted) ? minted : current;
1377
+ return (await renewStackSession(current, { cwd, renew })) ?? current;
1384
1378
  }
1385
1379
 
1386
1380
  /**
@@ -2482,7 +2476,7 @@ export async function cmdDone(leaseId, opts, {
2482
2476
  } = {}) {
2483
2477
  // BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
2484
2478
  // its own — otherwise an active lease can't be released until the cache is manually
2485
- // repaired. The retrying caller clears+self-heals+retries the FIRST auth rejection.
2479
+ // repaired. The retrying caller renews+retries the FIRST auth rejection.
2486
2480
  const rpc = retryingStackCall({ callTool, authProvider, refresh: refreshAuth });
2487
2481
  const auth = await rpc.init();
2488
2482
  if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
@@ -2839,7 +2833,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
2839
2833
  // BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
2840
2834
  // long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
2841
2835
  // can be rotated server-side at ANY of those points, so EVERY RPC — not just the
2842
- // initial request — must be able to clear + self-heal + retry once. Route them all
2836
+ // initial request — must be able to renew + retry once. Route them all
2843
2837
  // through one retryingStackCall so a single self-heal re-authenticates the rest
2844
2838
  // (heartbeat, get, cleanup release), instead of fencing a healthy child or leaking
2845
2839
  // an unreleasable lease. Retry only when we own the credential or a test provides an
@@ -3110,7 +3104,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3110
3104
  ...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
3111
3105
  ...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
3112
3106
  };
3113
- // The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
3107
+ // The retrying caller (api → rpc.call) transparently renews+retries the
3114
3108
  // FIRST auth rejection among any of request/get/touch/release.
3115
3109
  // BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
3116
3110
  // --wait-capacity a TRANSIENT one parks on the server's capacity wait and re-admits once.