@botbuddy/cli 1.30.0 → 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/src/pw/run.mjs CHANGED
@@ -7,7 +7,8 @@ import { canonicalizeHostString } from "./host.mjs";
7
7
  import { resolveAgentBinding } from "../wait-profile.mjs";
8
8
  import { loadConfig, getConfig } from "../config.mjs";
9
9
  import { VERSION } from "../version.mjs";
10
- import { readAgentKeyEnv, AGENT_KEY_RE } from "../agent-key.mjs";
10
+ import { readAgentSessionTokenEnv, AGENT_KEY_RE } from "../agent-key.mjs";
11
+ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
11
12
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
12
13
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
13
14
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
@@ -23,19 +24,19 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
23
24
  async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
24
25
  async function gate({ env, host, lane, deps }) {
25
26
  if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
26
- // BOT-1572/1582: a per-session `bb_agent_` token authenticates lock verification
27
+ // BOT-1649: a per-session `bb_sess_` token authenticates lock verification
27
28
  // AS the session agent — the server resolves it, holder matching rides on
28
- // owner_is_caller (plus any local session/registered agent id). $BOTBUDDY_AGENT_KEY
29
- // is the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
29
+ // owner_is_caller (plus any local session/registered agent id).
30
+ // $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; the former names remain aliases.
30
31
  //
31
32
  // BOT-1608: the retired profile-identity path is gone (its `agent-profiles.json`
32
33
  // store no longer exists). When no session token is exported, fall back to the
33
34
  // worktree binding's mcp_env credential (the `bb_mcp_` key from `.mcp.json`) —
34
35
  // it authenticates the status call the same way and matching still rides on the
35
36
  // server's owner_is_caller.
36
- const sessionToken = deps.sessionToken ?? readAgentKeyEnv(env);
37
+ const sessionToken = deps.sessionToken ?? readAgentSessionTokenEnv(env);
37
38
  if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
38
- return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed (tier-3 session token; expected bb_agent_<64 hex>). Re-register: register_agent export BOTBUDDY_AGENT_KEY. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
39
+ return { allowed: false, message: "bb-pw: the supplied session override is malformed. Remove it and run bb setup from this project worktree, then retry. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
39
40
  }
40
41
  const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
41
42
  const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
@@ -49,7 +50,7 @@ async function gate({ env, host, lane, deps }) {
49
50
  } catch { token = null; }
50
51
  }
51
52
  if (!token) {
52
- return { allowed: false, message: "bb-pw: no BotBuddy credentiala lane needs the tier-3 session token (bb_agent_: register_agent → export $BOTBUDDY_AGENT_KEY) or your tier-2 .mcp.json key ($BOTBUDDY_MCP_KEY, bb mcp setup). See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
53
+ return { allowed: false, message: "bb-pw: no worktree sessionrun bb setup from this project worktree, then retry. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
53
54
  }
54
55
  coordinator = createSessionTokenCoordinator({ token, fetchImpl: deps.fetch });
55
56
  }
@@ -82,7 +83,7 @@ async function gate({ env, host, lane, deps }) {
82
83
  const holderName = status.heldByName ? ` (${status.heldByName})` : "";
83
84
  return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is held by ${holder}${holderName}, not you (caller ${callerId}). Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` };
84
85
  } catch (error) {
85
- return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
86
+ return { allowed: false, errorCode: error?.code ?? null, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
86
87
  }
87
88
  }
88
89
  // BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
@@ -101,16 +102,41 @@ export async function runPw(argv, deps = {}) {
101
102
  async function runPwInner(argv, deps = {}) {
102
103
  const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
103
104
  if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
104
- // BOT-1572/1582 (AC-8): --session-token / $BOTBUDDY_AGENT_KEY is accepted as a
105
+ // BOT-1649: --agent-session-token / $BOTBUDDY_AGENT_SESSION_TOKEN is canonical;
106
+ // legacy flags and env aliases remain accepted as a
105
107
  // session identity alongside --session-id, so a token-armed session need not
106
108
  // pass an id. Holder matching still rides on the server's owner_is_caller and
107
109
  // the resolved agent ids (gate()).
108
- while (args[0] === "--tenant" || args[0] === "--session-id" || args[0] === "--session-token") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--tenant" ? { ...deps, tenant: args[1] } : flag === "--session-token" ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
110
+ while (["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"].includes(args[0])) { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--tenant" ? { ...deps, tenant: args[1] } : ["--agent-session-token", "--agent-key", "--session-token"].includes(flag) ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
109
111
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
110
112
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
111
113
  if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
114
+ // Local-only lanes explicitly opt out of lock ownership, so they also need no
115
+ // remote session mint. Every lock-gated shipped invocation shares the resolver.
116
+ let managedCredential = null;
117
+ if (env.BB_PW_NO_LOCK !== "1" && !deps.sessionToken && !readAgentSessionTokenEnv(env) && (!deps.coordinator || deps.resolveCredential)) {
118
+ try {
119
+ const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
120
+ managedCredential = credential;
121
+ deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
122
+ } catch (error) {
123
+ stderr.write(`bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`);
124
+ return 3;
125
+ }
126
+ }
112
127
  const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
113
- const auth = await gate({ env, host, lane: plan.lane, deps }); if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
128
+ let auth = await gate({ env, host, lane: plan.lane, deps });
129
+ // Only an ACTUAL auth rejection carries an errorCode (the coordinator surfaces
130
+ // the MCP auth code). A plain lock denial (lane held by another agent) returns
131
+ // allowed:false with NO errorCode and must never rotate the session (Codex round-5).
132
+ if (!auth.allowed && !deps.coordinator && auth.errorCode != null && isRejectedCachedMcpSession({ auth: true, code: auth.errorCode }, managedCredential?.source, 0)) {
133
+ await clearAgentState(deps.cwd ?? process.cwd());
134
+ const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
135
+ managedCredential = credential;
136
+ deps = { ...deps, sessionToken: credential.token, sessionId: credential.sessionId ?? deps.sessionId ?? null };
137
+ auth = await gate({ env, host, lane: plan.lane, deps });
138
+ }
139
+ if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
114
140
  // Report the host the lock service actually stored (alias-collapsed), so the
115
141
  // status view and lane telemetry match the server, not the local string (AC-4).
116
142
  const effHost = auth.canonicalHost ?? host;
package/src/run.mjs CHANGED
@@ -14,14 +14,15 @@ import { spawn } from "node:child_process";
14
14
  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
- import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
17
+ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
18
+ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
18
19
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
19
20
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
20
21
  import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
21
22
  import { VERSION } from "./version.mjs";
22
23
 
23
24
  export const RUN_SCHEMA_VERSION = 1;
24
- export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
25
+ export const EXIT = Object.freeze({ OK: 0, AUTH: 3, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
25
26
  export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
26
27
  export const TIMEOUT_GRACE_MS = 5_000;
27
28
  const MAX_CAPTURE_BYTES = 8_192;
@@ -30,16 +31,16 @@ const MAX_CAPTURE_BYTES = 8_192;
30
31
  const LANE_FLUSH_INTERVAL_MS = 2_500;
31
32
  const FOREGROUND_KINDS = new Set(["test", "wait", "browser", "ci", "hook", "stack"]);
32
33
 
33
- // BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
34
- // BOT-1572 bb_sess_ legacy alias, both accepted for one release.
34
+ // BOT-1649: the canonical session-token shape is bb_sess_ + 64 hex; bb_agent_
35
+ // remains accepted as a one-release legacy alias.
35
36
  const SESSION_TOKEN_RE = AGENT_KEY_RE;
36
37
 
37
38
  export function parseRunArgs(argv, env = process.env) {
38
- // BOT-1572/1582 (AC-8): $BOTBUDDY_AGENT_KEY authenticates the run and carries its
39
+ // BOT-1649: $BOTBUDDY_AGENT_SESSION_TOKEN authenticates the run and carries its
39
40
  // session, so --session-id becomes optional (the relay derives it). The old
40
41
  // $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
41
42
  // is the default when a plain id is used (parity with `botbuddy test`).
42
- const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, foreground: false, json: false };
43
+ const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentSessionTokenEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, foreground: false, json: false };
43
44
  const errors = [];
44
45
  const separator = argv.indexOf("--");
45
46
  const flags = separator === -1 ? argv : argv.slice(0, separator);
@@ -52,6 +53,8 @@ export function parseRunArgs(argv, env = process.env) {
52
53
  const flag = flags[i];
53
54
  switch (flag) {
54
55
  case "--session-id": opts.sessionId = value(flag, i); i++; break;
56
+ case "--agent-session-token":
57
+ case "--agent-key":
55
58
  case "--session-token": opts.sessionToken = value(flag, i); i++; break;
56
59
  case "--environment": opts.environment = value(flag, i); i++; break;
57
60
  case "--category": opts.category = value(flag, i); i++; break;
@@ -75,8 +78,8 @@ export function parseRunArgs(argv, env = process.env) {
75
78
  // BOT-1572: a session token stands in for --session-id (the backend derives the
76
79
  // session from the token). A malformed token, or a plain id AND a differing
77
80
  // token, is a hard error.
78
- if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>");
79
- if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
81
+ if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy alias)");
82
+ if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_SESSION_TOKEN");
80
83
  if (opts.foreground && !FOREGROUND_KINDS.has(opts.kind)) errors.push("--kind must be test, wait, browser, ci, hook, or stack with --foreground");
81
84
  if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
82
85
  if (!command.length) errors.push("a workload is required after --");
@@ -228,8 +231,40 @@ function workerInvocation(context) {
228
231
  return [process.execPath, [bin, "run", "--worker", context]];
229
232
  }
230
233
 
231
- export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null } = {}) {
232
- const { opts, command, errors } = parseRunArgs(argv);
234
+ export async function launchRun(argv, {
235
+ cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
236
+ resolveCredential = null, env = process.env,
237
+ } = {}) {
238
+ let effectiveArgv = argv;
239
+ let parsed = parseRunArgs(effectiveArgv, env);
240
+ let managedCredential = null;
241
+ // A local test double owns its credential boundary; production invocations
242
+ // self-heal when the session credential was not supplied by flag or env.
243
+ // Keep all other usage failures local and deterministic before minting.
244
+ 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)) {
247
+ try {
248
+ const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
249
+ managedCredential = credential;
250
+ effectiveArgv = ["--agent-session-token", credential.token, ...argv];
251
+ parsed = parseRunArgs(effectiveArgv, env);
252
+ } 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
+ };
265
+ }
266
+ }
267
+ const { opts, command, errors } = parsed;
233
268
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
234
269
  // BOT-1572 (AC-8): when a session token is present it is the credential — pin it
235
270
  // so register_command authenticates as the session's agent and derives session_id
@@ -238,7 +273,7 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
238
273
  ? (t, a) => callToolJson(t, a, { auth: { "x-agent-api-key": opts.sessionToken } })
239
274
  : callToolJson);
240
275
  const runId = randomUUID();
241
- const registration = await effectiveCall("register_command", {
276
+ const registerArgs = {
242
277
  // Omit session_id entirely when only a token is present — the backend derives
243
278
  // it. A plain id is still sent (and honoured) when supplied.
244
279
  ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
@@ -246,7 +281,19 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
246
281
  command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
247
282
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
248
283
  rerun_reason: opts.rerunReason,
249
- });
284
+ };
285
+ let registration = await effectiveCall("register_command", registerArgs);
286
+ // A cached session can be revoked server-side before its local expiry. The
287
+ // managed cache is ours to replace once; explicit flag/env credentials belong
288
+ // to the caller and are never silently rotated.
289
+ 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);
296
+ }
250
297
  if (!registration.ok || registration.isError || !registration.data?.accepted || !registration.data?.run_credential) {
251
298
  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 } };
252
299
  }
@@ -4,9 +4,9 @@
4
4
  // guide all agree. The snapshot test (agent-connectivity.test.mjs) asserts the
5
5
  // SETUP block appears verbatim in all four help outputs, so drift is caught.
6
6
  //
7
- // This documents the CURRENT supported process (BOT-1644 scope). It does NOT
8
- // describe the P1–P6 "self-healing auth" redesign, which is a separate future
9
- // project.
7
+ // This documents the supported BOT-1649 process. The CLI owns registration and
8
+ // session persistence; normal operator output must never turn that into a
9
+ // multi-command credential handoff.
10
10
 
11
11
  /** The canonical guide, referenced from every SETUP block and recovery. */
12
12
  export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
@@ -35,41 +35,30 @@ export const TIERS = Object.freeze({
35
35
  setup: "bb mcp setup",
36
36
  scope: "tenant",
37
37
  }),
38
- // Tier 3 — per-session agent token. `register_agent` (over MCP), tenant-bound,
39
- // 8 h TTL, revoked on re-register. The ONLY credential a wait/run/test/pw
40
- // accepts. Exported as $BOTBUDDY_AGENT_KEY (+ $BOTBUDDY_SESSION_ID for
41
- // attribution).
38
+ // Tier 3 — per-session agent token, tenant-bound, 8 h TTL, revoked on
39
+ // re-register. The CLI caches it in .botbuddy/agent-state.json and restores
40
+ // it automatically for wait/run/test/pw.
42
41
  session: Object.freeze({
43
42
  n: 3,
44
43
  label: "session token",
45
- prefix: "bb_agent_",
46
- env: "BOTBUDDY_AGENT_KEY",
44
+ prefix: "bb_sess_",
45
+ env: "BOTBUDDY_AGENT_SESSION_TOKEN",
47
46
  setup: "register_agent",
48
47
  scope: "session",
49
48
  }),
50
49
  });
51
50
 
52
- // The register_agent → export handoff, named once. Recovery strings reuse it so
53
- // the exact two export lines never drift from the guide.
54
- export const EXPORT_STEP =
55
- "register_agent → export BOTBUDDY_AGENT_KEY=<session_token> (and BOTBUDDY_SESSION_ID=<session_id>)";
56
-
57
51
  /**
58
52
  * The shared SETUP block reproduced verbatim in every `--help` output. Plain
59
53
  * text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
60
- * The E2E ladder in order, then the fresh-shell caveat and the guide link.
54
+ * Keep it to the normal two-command flow; low-level credential plumbing belongs
55
+ * only in the advanced reference.
61
56
  */
62
- export const SETUP_BLOCK = `SETUP — the connectivity ladder (install → connect → wait)
57
+ export const SETUP_BLOCK = `SETUP — one command, then wait
63
58
  1. npm i -g @botbuddy/cli install the CLI
64
- 2. bb login tier 1 · client key (bb_cli_, user-scoped) Keychain
65
- 3. bb mcp setup tier 2 · MCP key (bb_mcp_, tenant-bound) → Keychain + .mcp.json ($BOTBUDDY_MCP_KEY)
66
- (Keychainless host: the key is printed ONCE store + export + edit .mcp.json yourself)
67
- the repo's pre-committed .botbuddy-agent.json binds tenant + mcp_env
68
- multi-tenant machine: mint each tenant under a distinct --env <NAME> var (they collide otherwise)
69
- 4. register_agent (over MCP) tier 3 · session token (bb_agent_, per-session, 8 h)
70
- 5. export BOTBUDDY_AGENT_KEY=<session_token> and BOTBUDDY_SESSION_ID=<session_id>
71
- 6. bb wait needs the tier-3 token; bb run/test also accept $BOTBUDDY_SESSION_ID,
72
- bb pw falls back to the tier-2 MCP key
73
- NOTE: each Claude Code Bash call is a FRESH shell — exports do NOT persist between tool calls;
74
- re-export (or pass --agent-key <token>) in the same command that runs the wait.
59
+ 2. bb setup [--tenant <slug>] sign in, verify this repo binding, and cache a worktree session
60
+ 3. bb wait '<condition>' no environment variables or setup flags required
61
+ NOTE: setup keeps one user-scoped client key; --tenant only validates this worktree binding.
62
+ bb wait restores or renews its cached worktree session automatically.
63
+ bb mcp setup is separate and only needed to configure an MCP client.
75
64
  Full guide: ${CONNECTIVITY_GUIDE}`;
package/src/test-lane.mjs CHANGED
@@ -21,7 +21,8 @@ import { execFileSync } from "node:child_process";
21
21
 
22
22
  import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
23
23
  import { callToolJson } from "./api.mjs";
24
- import { readAgentKeyEnv } from "./agent-key.mjs";
24
+ import { readAgentSessionTokenEnv } from "./agent-key.mjs";
25
+ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
25
26
 
26
27
  // EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
27
28
  export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
@@ -38,9 +39,9 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
38
39
  const opts = {
39
40
  sessionId: env.BOTBUDDY_SESSION_ID ?? null,
40
41
  // BOT-1572/1582 (AC-8): a session token stands in for --session-id; it flows to
41
- // the sub-invoked `run`/`wait` which derive the session from it. $BOTBUDDY_AGENT_KEY
42
- // is the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
43
- sessionToken: readAgentKeyEnv(env),
42
+ // the sub-invoked `run`/`wait` which derive the session from it.
43
+ // $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; former names remain aliases.
44
+ sessionToken: readAgentSessionTokenEnv(env),
44
45
  environment: "local",
45
46
  ticket: null, pr: null, repo: null,
46
47
  laneKind: null,
@@ -57,6 +58,8 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
57
58
  const flag = flags[i];
58
59
  switch (flag) {
59
60
  case "--session-id": opts.sessionId = value(flag, i); i++; break;
61
+ case "--agent-session-token":
62
+ case "--agent-key":
60
63
  case "--session-token": opts.sessionToken = value(flag, i); i++; break;
61
64
  case "--environment": opts.environment = value(flag, i); i++; break;
62
65
  case "--ticket": opts.ticket = value(flag, i); i++; break;
@@ -139,11 +142,11 @@ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null,
139
142
  // worker, carrying the telemetry env into its detached child.
140
143
  async function defaultLaunchLane({ command, sessionId, sessionToken, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
141
144
  // BOT-1572/1582: pass --session-id only when a plain id is used; a token-only lane
142
- // relies on the inherited $BOTBUDDY_AGENT_KEY (and an explicit --session-token
145
+ // relies on the inherited $BOTBUDDY_AGENT_SESSION_TOKEN (and an explicit --agent-session-token
143
146
  // so the child never falls back to a machine credential).
144
147
  const argv = [
145
148
  ...(sessionId ? ["--session-id", sessionId] : []),
146
- ...(sessionToken ? ["--session-token", sessionToken] : []),
149
+ ...(sessionToken ? ["--agent-session-token", sessionToken] : []),
147
150
  "--environment", environment, "--category", "validation", "--kind", "full_suite",
148
151
  "--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command,
149
152
  ];
@@ -152,7 +155,7 @@ async function defaultLaunchLane({ command, sessionId, sessionToken, environment
152
155
  }
153
156
 
154
157
  function waitCommand(testRunId, sessionId, sessionToken) {
155
- // BOT-1572/1582: a token-armed session runs the wait with only $BOTBUDDY_AGENT_KEY.
158
+ // BOT-1649: a token-armed session runs the wait with only the canonical session token.
156
159
  const idFlag = sessionId && !sessionToken ? ` --session-id ${sessionId}` : "";
157
160
  return `botbuddy wait 'test-run:id=${testRunId}'${idFlag} --heartbeat`;
158
161
  }
@@ -162,6 +165,8 @@ export async function launchTestLane(argv, {
162
165
  env = process.env,
163
166
  call = callToolJson,
164
167
  launchLane = defaultLaunchLane,
168
+ resolveCredential = null,
169
+ clearState = clearAgentState,
165
170
  gitInfo = defaultGitInfo,
166
171
  eventsDir = join(homedir(), ".botbuddy", "test-lanes"),
167
172
  } = {}) {
@@ -170,9 +175,24 @@ export async function launchTestLane(argv, {
170
175
  for (const e of errors) process.stderr.write(`botbuddy test: ${e}\n`);
171
176
  return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
172
177
  }
178
+ let managedCredential = null;
173
179
  if (!opts.sessionId && !opts.sessionToken) {
174
- process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $BOTBUDDY_AGENT_KEY)\n");
175
- return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
180
+ // Test doubles intentionally do not self-mint. The shipped command does,
181
+ // using exactly the same cache/re-adoption resolver as `bb wait` and `run`.
182
+ if (!resolveCredential && call !== callToolJson) {
183
+ process.stderr.write("botbuddy test: --session-id is required (or set BOTBUDDY_AGENT_SESSION_TOKEN)\n");
184
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
185
+ }
186
+ try {
187
+ const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
188
+ managedCredential = credential;
189
+ opts.sessionToken = credential.token;
190
+ opts.sessionId = credential.sessionId ?? null;
191
+ } catch (error) {
192
+ const code = error?.code ?? "agent_session_unavailable";
193
+ process.stderr.write(`botbuddy test: ${error?.message ?? code}\n`);
194
+ return { exitCode: 3, line: JSON.stringify({ outcome: "rejected", error: code, recovery: code === "client_key_required" ? "run `bb login` then retry" : "run `bb doctor --fix` then retry" }) };
195
+ }
176
196
  }
177
197
 
178
198
  // BOT-1572 (Codex P1): a token-only session has no owner/profile credential, so
@@ -180,9 +200,20 @@ export async function launchTestLane(argv, {
180
200
  // otherwise both fail and the run launches with test_run_id=null, silently
181
201
  // dropping the test-run record and case telemetry. Wrap the call so the token
182
202
  // is pinned; an injected test `call` that ignores the 3rd arg is unaffected.
183
- const apiCall = opts.sessionToken
184
- ? (name, args) => call(name, args, { auth: { "x-agent-api-key": opts.sessionToken } })
185
- : call;
203
+ const apiCall = (name, args) => opts.sessionToken
204
+ ? call(name, args, { auth: { "x-agent-api-key": opts.sessionToken } })
205
+ : call(name, args);
206
+ const callWithSessionRetry = async (name, args) => {
207
+ let result = await apiCall(name, args);
208
+ if (!isRejectedCachedMcpSession(result, managedCredential?.source, 0)) return result;
209
+ await clearState(cwd);
210
+ const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
211
+ managedCredential = credential;
212
+ opts.sessionToken = credential.token;
213
+ opts.sessionId = credential.sessionId ?? opts.sessionId;
214
+ result = await apiCall(name, args);
215
+ return result;
216
+ };
186
217
 
187
218
  const resolved = resolveLane(laneName, { cwd });
188
219
  if (!resolved.ok) {
@@ -218,12 +249,12 @@ export async function launchTestLane(argv, {
218
249
  // BOT-1549: stamp the resolved lane kind on the run; absent ⇒ omitted (NULL).
219
250
  lane_kind: laneKind ?? undefined,
220
251
  };
221
- const created = await apiCall("create_test_run", createArgs);
252
+ const created = await callWithSessionRetry("create_test_run", createArgs);
222
253
  const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
223
254
 
224
255
  if (testRunId) {
225
256
  // Step 2: attach the ticket + promote to active in one update.
226
- await apiCall("update_test_run", {
257
+ await callWithSessionRetry("update_test_run", {
227
258
  test_run_id: testRunId,
228
259
  ticket_id: git.ticket ?? undefined,
229
260
  ticket_url: git.ticketUrl ?? undefined,