@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.
@@ -15,6 +15,56 @@ export const SCHEMA_VERSION = 1;
15
15
  export const DEFAULT_PROJECTED_ENDPOINTS = 10;
16
16
  export const DEFAULT_WARN_PRESSURE = 192;
17
17
  export const DEFAULT_FAIL_PRESSURE = 224;
18
+
19
+ // BOT-1681 / BOT-1630 Step 3 — explicit Docker-engine allowlist. The org cut the
20
+ // dev fleet back from OrbStack to Docker Desktop (OrbStack Helper heap runaway,
21
+ // SG ENT-4356 F4), so preflight must admit Docker Desktop deliberately instead of
22
+ // hard-requiring OrbStack. Each entry maps `docker info`.OperatingSystem to a
23
+ // stable `validation` label persisted on the lease and matched at teardown, plus
24
+ // an optional secondary identity guard. Anything not listed fails closed. The
25
+ // `orbstack_unavailable` fault label is deliberately NOT an admitted engine.
26
+ export const DOCKER_ENGINE_ALLOWLIST = Object.freeze([
27
+ Object.freeze({ operating_system: "OrbStack", validation: "orbstack", identity: /orbstack/i }),
28
+ Object.freeze({ operating_system: "Docker Desktop", validation: "docker-desktop", identity: null }),
29
+ ]);
30
+
31
+ // `hygiene` DELETES containers, so it keeps the original OrbStack-only fence
32
+ // (BOT-1630 Must-NOT-1). Only the read-only `preflight` widens to the full
33
+ // allowlist; that is what stack leases gate on.
34
+ const HYGIENE_ENGINE_ALLOWLIST = Object.freeze(
35
+ DOCKER_ENGINE_ALLOWLIST.filter((entry) => entry.validation === "orbstack"),
36
+ );
37
+
38
+ // Validation labels the stack-lease target identity/fence accepts, shared with
39
+ // stack.mjs so provision and teardown agree on what a valid engine looks like.
40
+ export const ADMITTED_DOCKER_VALIDATIONS = Object.freeze(
41
+ new Set(DOCKER_ENGINE_ALLOWLIST.map((entry) => entry.validation)),
42
+ );
43
+
44
+ // Optional fleet override: `BOTBUDDY_DOCKER_ENGINES` (comma-separated validation
45
+ // labels, e.g. "orbstack") narrows admission for machines that must stay on a
46
+ // single engine. Unset/blank → the org-default full allowlist. Because this is a
47
+ // SAFETY control that RESTRICTS admission, a non-empty value that names no known
48
+ // engine label (a typo like "orbstak", or only empties) fails CLOSED with an
49
+ // error rather than silently widening back to the default — otherwise a
50
+ // misspelled restriction would admit exactly the engine it meant to block
51
+ // (Codex P2). Known-but-inapplicable labels (e.g. "docker-desktop" for the
52
+ // OrbStack-only hygiene command) narrow to nothing and fall back to the command's
53
+ // inherent base, which is the same or stricter — never wider.
54
+ export function resolveEngineAllowlist(command, env = process.env) {
55
+ const base = command === "hygiene" ? HYGIENE_ENGINE_ALLOWLIST : DOCKER_ENGINE_ALLOWLIST;
56
+ const raw = env?.BOTBUDDY_DOCKER_ENGINES;
57
+ if (typeof raw !== "string" || !raw.trim()) return base;
58
+ const requested = raw.split(",").map((label) => label.trim().toLowerCase()).filter(Boolean);
59
+ const unknown = requested.filter((label) => !ADMITTED_DOCKER_VALIDATIONS.has(label));
60
+ if (requested.length === 0 || unknown.length > 0) {
61
+ throw new Error(`engine_override_invalid: BOTBUDDY_DOCKER_ENGINES="${raw}" names no usable engine label${unknown.length ? ` (unknown: ${unknown.join(", ")})` : ""}; known labels: ${[...ADMITTED_DOCKER_VALIDATIONS].join(", ")}`);
62
+ }
63
+ const wanted = new Set(requested);
64
+ const narrowed = base.filter((entry) => wanted.has(entry.validation));
65
+ return narrowed.length > 0 ? narrowed : base;
66
+ }
67
+
18
68
  const INSPECT_BATCH_SIZE = 10;
19
69
  const MAX_APPLY_BUDGET_MS = 5 * 60 * 1000;
20
70
  const MAX_DOCKER_COMMAND_MS = 30 * 1000;
@@ -50,8 +100,10 @@ REQUIRED SELECTOR
50
100
  --endpoint <uri> Explicit Docker endpoint, e.g.
51
101
  unix:///Users/me/.orbstack/run/docker.sock
52
102
 
53
- Exactly one selector is required. The target must identify itself as
54
- OrbStack; the ambient Docker context and DOCKER_HOST are never trusted.
103
+ Exactly one selector is required; the ambient Docker context and DOCKER_HOST
104
+ are never trusted. hygiene (which deletes) admits only OrbStack. preflight
105
+ admits an allowlisted engine — OrbStack or Docker Desktop — and refuses any
106
+ other daemon with engine_not_allowed:<OperatingSystem>.
55
107
 
56
108
  HYGIENE OPTIONS
57
109
  --ticket <BOT|ENT-N> Scope discovery/apply to the owning ticket's project.
@@ -68,6 +120,11 @@ PREFLIGHT OPTIONS
68
120
  --fail-pressure <n> Refuse threshold (default ${DEFAULT_FAIL_PRESSURE})
69
121
  --json Emit exactly one compact machine-readable receipt.
70
122
 
123
+ Engine allowlist (preflight): OrbStack and Docker Desktop are admitted by
124
+ default. Set BOTBUDDY_DOCKER_ENGINES to a comma-separated list of engine
125
+ labels (orbstack, docker-desktop) to keep a fleet on a single engine, e.g.
126
+ BOTBUDDY_DOCKER_ENGINES=orbstack.
127
+
71
128
  SAFETY
72
129
  • Apply requires a ticket that matches each candidate's Docker project label.
73
130
  • Apply must run from a Git worktree branch carrying that same ticket.
@@ -90,8 +147,10 @@ RELIABILITY MEASUREMENT
90
147
 
91
148
  LIFECYCLE
92
149
  Use one worktree and one ticket-scoped managed stack for one test/fix batch;
93
- finish or reap that stack before starting another. Run preflight before start,
94
- and ticket-scoped dry-run hygiene when pressure warns or refuses admission.
150
+ finish or reap that stack before starting another. Run preflight before start.
151
+ When pressure warns or refuses: on OrbStack, run ticket-scoped dry-run hygiene;
152
+ on Docker Desktop (where hygiene stays OrbStack-only) release your own idle stack
153
+ with 'botbuddy stack done <lease>' instead.
95
154
 
96
155
  PERSISTENT ORBSTACK GHOSTS
97
156
  If an exact ID remains listed but OrbStack cannot inspect it, inventory is
@@ -672,7 +731,7 @@ function readInventory(runDocker, selector) {
672
731
  };
673
732
  }
674
733
 
675
- function validateOrbStack(runDocker, opts) {
734
+ function validateDockerEngine(runDocker, opts, allowlist = DOCKER_ENGINE_ALLOWLIST) {
676
735
  let resolvedEndpoint = opts.endpoint;
677
736
  let targetHintsOrbStack = /orbstack/i.test(String(opts.endpoint || ""));
678
737
  if (opts.context) {
@@ -694,10 +753,14 @@ function validateOrbStack(runDocker, opts) {
694
753
  }
695
754
  throw error;
696
755
  }
697
- const osIsOrbStack = info?.OperatingSystem === "OrbStack";
698
756
  const identity = `${info?.KernelVersion || ""} ${info?.Name || ""} ${info?.ServerVersion || ""}`;
699
- if (!osIsOrbStack || !/orbstack/i.test(identity)) {
700
- throw new Error(`Docker target is not OrbStack (OperatingSystem=${info?.OperatingSystem || "unknown"}, kernel=${info?.KernelVersion || "unknown"})`);
757
+ const engine = allowlist.find((entry) =>
758
+ entry.operating_system === info?.OperatingSystem &&
759
+ (!entry.identity || entry.identity.test(identity)));
760
+ if (!engine) {
761
+ // Fail closed on any daemon we do not positively recognise. The reason string
762
+ // is stable (`engine_not_allowed:<OperatingSystem>`) so wrappers can branch on it.
763
+ throw new Error(`engine_not_allowed:${info?.OperatingSystem || "unknown"} (Docker target is not an allowlisted engine; OperatingSystem=${info?.OperatingSystem || "unknown"}, kernel=${info?.KernelVersion || "unknown"}, admitted=${allowlist.map((entry) => entry.operating_system).join(", ") || "(none)"})`);
701
764
  }
702
765
 
703
766
  return {
@@ -705,7 +768,7 @@ function validateOrbStack(runDocker, opts) {
705
768
  receipt: {
706
769
  requested: opts.context ? { type: "context", value: opts.context } : { type: "endpoint", value: opts.endpoint },
707
770
  resolved_endpoint: resolvedEndpoint,
708
- validation: "orbstack",
771
+ validation: engine.validation,
709
772
  server: {
710
773
  id: info.ID || null,
711
774
  name: info.Name || null,
@@ -784,6 +847,15 @@ function baseReceipt(command, opts, now) {
784
847
 
785
848
  export function buildReliabilityTelemetry(receipt, { dedupeKey } = {}) {
786
849
  const unavailable = receipt.context?.validation === "orbstack_unavailable";
850
+ // BOT-1681: the reliability experiment (BOT-1421, `v_orbstack_*`) is OrbStack-only
851
+ // and correlates rows by host/time with no engine stratum. A newly-admitted Docker
852
+ // Desktop preflight must NOT emit an unlabeled `capacity_observed` row that would
853
+ // contaminate the OrbStack strata, so suppress telemetry for any non-OrbStack
854
+ // engine outright. (Engine-stratified reliability reporting is BOT-1543/BOT-1630.)
855
+ const validation = receipt.context?.validation;
856
+ if (validation && validation !== "orbstack" && !unavailable) {
857
+ return null;
858
+ }
787
859
  const ghostInventory = (receipt.inventory_errors?.length ?? 0) > 0;
788
860
  // A successful target check is not a capacity measurement. If discovery
789
861
  // fails before evaluatePressure produces a decision, suppress the generic
@@ -902,10 +974,22 @@ function addSkipOnce(receipt, item) {
902
974
  }
903
975
  }
904
976
 
905
- function recommendationFor(opts, apply = false, candidates = []) {
977
+ function recommendationFor(opts, apply = false, candidates = [], engine = "orbstack") {
906
978
  const selector = opts.context ? `--context ${opts.context}` : `--endpoint ${opts.endpoint}`;
907
979
  const ticket = opts.ticket ? ` --ticket ${opts.ticket}` : "";
908
- if (!apply) return `botbuddy docker hygiene ${selector}${ticket}`;
980
+ if (!apply) {
981
+ // BOT-1681: `docker hygiene` is OrbStack-only, so recommending it on a
982
+ // non-OrbStack engine (e.g. Docker Desktop) would hand back a command that
983
+ // refuses with engine_not_allowed. Give a remedy that actually works there.
984
+ if (engine !== "orbstack") {
985
+ // Only recommend the OWNERSHIP-SCOPED remedy. Never suggest broad supabase_*
986
+ // container removal here: it bypasses hygiene's ticket filter, reviewed
987
+ // candidate IDs, and file/stack locks, so on a shared host it could delete a
988
+ // stopped service belonging to another live stack (Codex round-7 P2).
989
+ return `docker hygiene is OrbStack-only; on ${engine} reduce Docker load by releasing your own idle stack leases (botbuddy stack done <lease>), then retry`;
990
+ }
991
+ return `botbuddy docker hygiene ${selector}${ticket}`;
992
+ }
909
993
  if (!opts.ticket) return `botbuddy docker hygiene ${selector} --ticket <BOT-or-ENT-ticket>`;
910
994
  const projects = [...new Set(candidates.map((item) => item.project))];
911
995
  const lockSlot = projects.length === 1 ? lockSlotForProject(projects[0], opts.ticket) : null;
@@ -923,6 +1007,7 @@ export function runDockerWorkflow(argv, {
923
1007
  monotonicNow = Date.now,
924
1008
  platform = process.platform,
925
1009
  now = () => new Date(),
1010
+ env = process.env,
926
1011
  } = {}) {
927
1012
  const parsed = parseDockerArgs(argv);
928
1013
  const receipt = baseReceipt(parsed.command || "unknown", parsed.opts, now);
@@ -970,10 +1055,10 @@ export function runDockerWorkflow(argv, {
970
1055
  let validated;
971
1056
  let inventory;
972
1057
  try {
973
- validated = validateOrbStack(runDocker, parsed.opts);
1058
+ validated = validateDockerEngine(runDocker, parsed.opts, resolveEngineAllowlist(parsed.command, env));
974
1059
  receipt.context = validated.receipt;
975
1060
  inventory = classifyInventory(readInventory(runDocker, validated.selector));
976
- receipt.recommendation = recommendationFor(parsed.opts);
1061
+ receipt.recommendation = recommendationFor(parsed.opts, false, [], validated.receipt.validation);
977
1062
  } catch (error) {
978
1063
  receipt.outcome = "error";
979
1064
  receipt.errors = [error.message];
@@ -1013,7 +1098,12 @@ export function runDockerWorkflow(argv, {
1013
1098
  for (const item of receipt.inventory_errors) {
1014
1099
  receipt.errors.push(`${item.type} ${item.id}${item.name ? ` (${item.name})` : ""} remains listed but cannot be authoritatively inspected: ${item.error}`);
1015
1100
  }
1016
- receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
1101
+ // BOT-1681: this inventory-error path is shared by preflight, which now admits
1102
+ // Docker Desktop. Keep the OrbStack remedy for OrbStack, but never tell a Docker
1103
+ // Desktop operator to "repair OrbStack" or run the OrbStack-only hygiene dry run.
1104
+ receipt.recommendation = receipt.context?.validation === "orbstack"
1105
+ ? "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup."
1106
+ : `Persistent Docker inventory blocker: restart or repair the ${receipt.context?.server?.operating_system || "Docker"} daemon, then rerun preflight with the same explicit selector; never use broad cleanup.`;
1017
1107
  return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
1018
1108
  }
1019
1109
 
@@ -1025,12 +1115,20 @@ export function runDockerWorkflow(argv, {
1025
1115
  warn: parsed.opts.warnPressure,
1026
1116
  fail: parsed.opts.failPressure,
1027
1117
  });
1118
+ // BOT-1681: dry-run hygiene is the OrbStack-only remedy. On Docker Desktop it
1119
+ // refuses, so the pressure warning/refusal must not tell operators to run it;
1120
+ // point them at the engine-aware recommendation instead.
1121
+ const pressureEngine = receipt.context?.validation;
1028
1122
  if (receipt.pressure.status === "warn") {
1029
1123
  receipt.outcome = "warn";
1030
- receipt.warnings.push("Projected OrbStack network pressure is at or above the warning threshold; run dry-run hygiene before starting another stack.");
1124
+ receipt.warnings.push(pressureEngine === "orbstack"
1125
+ ? "Projected OrbStack network pressure is at or above the warning threshold; run dry-run hygiene before starting another stack."
1126
+ : "Projected Docker network pressure is at or above the warning threshold; reduce Docker load (see recommendation) before starting another stack.");
1031
1127
  } else if (receipt.pressure.status === "fail") {
1032
1128
  receipt.outcome = "refused";
1033
- receipt.errors.push("Projected OrbStack network pressure is at or above the refusal threshold; cleanup or release an owning stack before start.");
1129
+ receipt.errors.push(pressureEngine === "orbstack"
1130
+ ? "Projected OrbStack network pressure is at or above the refusal threshold; cleanup or release an owning stack before start."
1131
+ : "Projected Docker network pressure is at or above the refusal threshold; release an owning stack (see recommendation) before start.");
1034
1132
  return { exitCode: EXIT.PRESSURE, receipt, json: parsed.opts.json };
1035
1133
  }
1036
1134
  return { exitCode: EXIT.OK, receipt, json: parsed.opts.json };
@@ -5,7 +5,13 @@ function mcpCaller(authHeader, fetchImpl) {
5
5
  let id = 0;
6
6
  return async function call(name, args) {
7
7
  const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", ...authHeader }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
8
- if (!response.ok) throw new Error(`BotBuddy lock verification returned HTTP ${response.status}`);
8
+ if (!response.ok) {
9
+ let payload = null;
10
+ try { payload = await response.json(); } catch { /* keep the HTTP diagnostic */ }
11
+ const error = new Error(payload?.error?.message ?? payload?.error ?? `BotBuddy lock verification returned HTTP ${response.status}`);
12
+ error.code = payload?.error?.code ?? payload?.error?.error ?? payload?.code ?? payload?.error ?? null;
13
+ throw error;
14
+ }
9
15
  const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
10
16
  const text = json.result?.content?.find((item) => item.type === "text")?.text; try { return text ? JSON.parse(text) : {}; } catch { throw new Error("BotBuddy lock verification returned invalid JSON"); }
11
17
  };
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}`;