@botbuddy/cli 1.45.7 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/run.mjs +77 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.45.7",
3
+ "version": "1.46.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
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 } = {}) {