@botbuddy/cli 1.29.4 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stack.mjs CHANGED
@@ -31,7 +31,7 @@ import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
34
- import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
34
+ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
35
35
  import { machineUuid } from "./machine-id.mjs";
36
36
  import { bold, dim, yellow } from "./utils.mjs";
37
37
 
@@ -75,15 +75,18 @@ ${bold("up OPTIONS")}
75
75
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
76
76
  --no-wait If the host is full, print the queue position and exit (don't park).
77
77
  --local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
78
- --docker-context <name> Explicit OrbStack Docker context for the mandatory local preflight.
79
- --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
78
+ --docker-context <name> Explicit Docker context for the mandatory local preflight
79
+ (an allowlisted engine: OrbStack or Docker Desktop, e.g. desktop-linux).
80
+ --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
80
81
 
81
82
  ${bold("done OPTIONS")}
82
83
  --stop Keep volumes (cheap re-provision next batch). Default: destroy.
83
84
  --local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
84
- --docker-context <name> Explicit OrbStack context used by the matching local-exec up.
85
- --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
86
- Pre-1.5.0 leases also require exact worktree + live connection proof.
85
+ --docker-context <name> Explicit Docker context used by the matching local-exec up
86
+ (OrbStack or Docker Desktop; must match the engine that provisioned the lease).
87
+ --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
88
+ Pre-1.5.0 leases have no persisted target and stay OrbStack-only; they also
89
+ require exact worktree + live connection proof.
87
90
 
88
91
  ${bold("run OPTIONS")}
89
92
  --repo <repo> Required approved repository name.
@@ -528,7 +531,15 @@ export async function runLocalExecTargetCheck(opts, runWorkflow = runDockerWorkf
528
531
  const selector = opts.dockerContext
529
532
  ? ["--context", opts.dockerContext]
530
533
  : ["--endpoint", opts.dockerEndpoint];
531
- return runWorkflow(["preflight", ...selector, "--json"]);
534
+ // BOT-1681 teardown is an IDENTITY check against the daemon already persisted
535
+ // on the lease, not a new-admission decision. The BOTBUDDY_DOCKER_ENGINES fleet
536
+ // override gates PROVISIONING only; honouring it here would reject a still-valid
537
+ // Docker Desktop lease the moment a host is pinned to orbstack, fencing the lease
538
+ // and its stack slot forever (Codex P2). Validate against the full engine
539
+ // allowlist and let dockerTargetsMatch fence the observed daemon to the lease.
540
+ const env = { ...process.env };
541
+ delete env.BOTBUDDY_DOCKER_ENGINES;
542
+ return runWorkflow(["preflight", ...selector, "--json"], { env });
532
543
  }
533
544
 
534
545
  export function dockerEnvForSelector(opts, env = process.env) {
@@ -544,9 +555,14 @@ export function dockerTargetFromPreflight(receipt) {
544
555
  const context = receipt?.context;
545
556
  const endpoint = context?.resolved_endpoint;
546
557
  const serverId = context?.server?.id;
547
- if (context?.validation !== "orbstack" || typeof endpoint !== "string" || !endpoint ||
558
+ const validation = context?.validation;
559
+ // BOT-1681 — accept any allowlisted engine (orbstack, docker-desktop), not a
560
+ // hardcoded "orbstack". Carry the real engine label so teardown fences to the
561
+ // exact engine that provisioned the lease. Fault labels (e.g. orbstack_unavailable)
562
+ // are not admitted validations, so they still return null.
563
+ if (!ADMITTED_DOCKER_VALIDATIONS.has(validation) || typeof endpoint !== "string" || !endpoint ||
548
564
  typeof serverId !== "string" || !serverId) return null;
549
- return { validation: "orbstack", resolved_endpoint: endpoint, server_id: serverId };
565
+ return { validation, resolved_endpoint: endpoint, server_id: serverId };
550
566
  }
551
567
 
552
568
  export function connectionWithDockerTarget(connection, target) {
@@ -556,8 +572,12 @@ export function connectionWithDockerTarget(connection, target) {
556
572
  return { ...(connection || {}), botbuddy_docker_target: { ...target } };
557
573
  }
558
574
 
559
- function dockerTargetsMatch(expected, actual) {
560
- return expected?.validation === "orbstack" && actual?.validation === "orbstack" &&
575
+ export function dockerTargetsMatch(expected, actual) {
576
+ // BOT-1681 the fence is still exact: provision and teardown must agree on the
577
+ // engine (validation), the resolved endpoint, AND the daemon server_id. Widening
578
+ // the allowlist must never let a cross-engine or cross-daemon target match.
579
+ return ADMITTED_DOCKER_VALIDATIONS.has(expected?.validation) &&
580
+ expected?.validation === actual?.validation &&
561
581
  expected.resolved_endpoint === actual.resolved_endpoint && expected.server_id === actual.server_id;
562
582
  }
563
583
 
@@ -705,12 +725,16 @@ export async function cmdUp(opts, {
705
725
  if (checked.exitCode !== 0 || !localDockerTarget) {
706
726
  return emitResult(buildReceipt({
707
727
  command: "up", outcome: "refused", slot,
708
- error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
728
+ error: checked.receipt.errors?.[0] || "the local preflight did not return a stable Docker server identity",
709
729
  preflight: localPreflight,
710
730
  }), opts, EXIT.LEASE_FAILED);
711
731
  }
712
732
  if (checked.receipt.outcome === "warn") {
713
- process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
733
+ // BOT-1681: surface the engine-aware recommendation (dry-run hygiene is
734
+ // OrbStack-only) rather than a hardcoded OrbStack cleanup instruction.
735
+ const engine = checked.receipt.context?.validation === "orbstack" ? "OrbStack" : "Docker";
736
+ const remedy = checked.receipt.recommendation || "reduce Docker load before starting another stack";
737
+ process.stderr.write(`${yellow("⚠")} stack: ${engine} preflight warns of network pressure; ${remedy}.\n`);
714
738
  }
715
739
  }
716
740
  const auth = await authProvider();
@@ -926,11 +950,23 @@ export async function cmdDone(leaseId, opts, {
926
950
  dockerTarget = dockerTargetFromPreflight(checked.receipt);
927
951
  if (expected && !dockerTargetsMatch(expected, dockerTarget)) {
928
952
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
929
- error: "teardown Docker target does not exactly match the OrbStack server persisted at provisioning; lease and slot remain fenced",
953
+ error: "teardown Docker target does not exactly match the Docker daemon persisted at provisioning; lease and slot remain fenced",
930
954
  expected_docker_target: expected || null, observed_docker_target: dockerTarget,
931
955
  }), opts, EXIT.LEASE_FAILED);
932
956
  }
933
957
  if (!expected) {
958
+ // BOT-1681: legacy (pre-1.5.0) leases carry no persisted botbuddy_docker_target,
959
+ // and proveLegacyLocalExecTarget authorizes teardown from only the worktree +
960
+ // live API/DB URLs — not the engine. Those leases were created under OrbStack and
961
+ // the compatibility contract still pins them there, so keep this path OrbStack-only:
962
+ // a Docker Desktop daemon (newly admitted by the preflight allowlist for MODERN
963
+ // leases) must never authorize teardown of a targetless legacy OrbStack lease.
964
+ if (dockerTarget?.validation !== "orbstack") {
965
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
966
+ error: `pre-1.5.0 lease has no persisted Docker target; only an OrbStack daemon may authorize legacy teardown, not ${dockerTarget?.validation || "an unverified engine"}; lease and slot remain fenced`,
967
+ expected_docker_target: null, observed_docker_target: dockerTarget,
968
+ }), opts, EXIT.LEASE_FAILED);
969
+ }
934
970
  const proof = proveLegacyTarget(current.data, dockerTarget, opts);
935
971
  if (!proof?.ok) {
936
972
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
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,