@botbuddy/cli 1.29.0 → 1.29.2

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/stack.mjs +56 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.29.0",
3
+ "version": "1.29.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -30,6 +30,7 @@ import { randomUUID } from "crypto";
30
30
  import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
+ import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
33
34
  import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
34
35
  import { machineUuid } from "./machine-id.mjs";
35
36
  import { bold, dim, yellow } from "./utils.mjs";
@@ -352,10 +353,17 @@ export function parseSupabaseStatus(text) {
352
353
 
353
354
  // ── runtime (network / process) ──────────────────────────────────────────────
354
355
 
355
- // BOT-1520: source both auth headers from the Keychain the owner OAuth token
356
- // (`botbuddy login`) is preferred, the tenant-bound MCP key
357
- // (`botbuddy mcp setup`, BOT-1608) is the fallback. No plaintext config.json secret.
356
+ // BOT-1599: a stack lease is a host-bound operation. When a session token is
357
+ // present it must win over the durable OAuth client credential, otherwise the
358
+ // lease RPC resolves the hostless/stale OAuth agent rather than the agent that
359
+ // registered this worktree and machine attestation. The MCP server accepts the
360
+ // session token through the same agent-key header used by bb-pw.
361
+ //
362
+ // Without a session token, retain the BOT-1520 Keychain-backed OAuth/MCP-key
363
+ // fallback for operator commands and legacy callers.
358
364
  export async function stackAuthHeader() {
365
+ const sessionToken = readAgentKeyEnv();
366
+ if (sessionToken) return AGENT_KEY_RE.test(sessionToken) ? { "x-agent-api-key": sessionToken } : null;
359
367
  const agentKey = await resolveAgentKey();
360
368
  const owner = await resolveOwnerToken({ getConfig });
361
369
  if (owner) {
@@ -369,6 +377,16 @@ export async function stackAuthHeader() {
369
377
  return null;
370
378
  }
371
379
 
380
+ // The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
381
+ // the event-stream relay requires that same token in its bearer form too. Keep
382
+ // the lease RPC shape unchanged and derive the relay-compatible form only at
383
+ // the wait boundary; never substitute an ambient OAuth credential.
384
+ function relayAuthHeaders(auth) {
385
+ const sessionToken = auth?.["x-agent-api-key"];
386
+ if (auth?.Authorization || !sessionToken || !AGENT_KEY_RE.test(sessionToken)) return auth;
387
+ return { ...auth, Authorization: `Bearer ${sessionToken}` };
388
+ }
389
+
372
390
  function parseSseFrames(buffer) {
373
391
  const frames = [];
374
392
  let idx, remaining = buffer;
@@ -394,7 +412,7 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
394
412
  const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
395
413
  const res = await fetchImpl(`${eventStreamBase()}`, {
396
414
  method: "POST",
397
- headers: { ...auth, "Content-Type": "application/json" },
415
+ headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
398
416
  body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
399
417
  signal,
400
418
  });
@@ -427,7 +445,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
427
445
  const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
428
446
  try {
429
447
  const response = await fetchImpl(eventStreamBase(), {
430
- method: "POST", headers: { ...auth, "Content-Type": "application/json" },
448
+ method: "POST", headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
431
449
  body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
432
450
  signal: finalizer.signal,
433
451
  });
@@ -455,7 +473,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
455
473
  url.searchParams.set("heartbeat", "1");
456
474
  let res;
457
475
  try {
458
- res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
476
+ res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
459
477
  } catch (err) {
460
478
  if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
461
479
  return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
@@ -697,6 +715,7 @@ export async function cmdUp(opts, {
697
715
  }
698
716
  const auth = await authProvider();
699
717
  if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
718
+ const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
700
719
 
701
720
  // BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
702
721
  // physical machine (hardware id), since a hostname is not machine-unique. The
@@ -705,7 +724,7 @@ export async function cmdUp(opts, {
705
724
  if (!hardwareUuid) {
706
725
  return emitResult(buildReceipt({ command: "up", outcome: "error", code: "MACHINE_UUID_REQUIRED", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" }), opts, EXIT.BACKEND);
707
726
  }
708
- const req = await callTool("request_stack_lease", {
727
+ const req = await call("request_stack_lease", {
709
728
  slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
710
729
  ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
711
730
  pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
@@ -748,7 +767,7 @@ export async function cmdUp(opts, {
748
767
  localPreflight = compactPreflight(checked.receipt);
749
768
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
750
769
  if (checked.exitCode !== 0 || !localDockerTarget) {
751
- const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
770
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
752
771
  const leaseCancellation = cancelled.ok && cancelled.data?.success
753
772
  ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
754
773
  : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
@@ -768,7 +787,7 @@ export async function cmdUp(opts, {
768
787
  // Persist the validated Docker target with the reservation so that if the
769
788
  // provisioner partially starts a stack and then fails, the fenced lease can
770
789
  // still be torn down by `stack done --local-exec` (BOT-1421 review).
771
- const reserved = await callTool("reserve_stack_lease", {
790
+ const reserved = await call("reserve_stack_lease", {
772
791
  lease_id: leaseId,
773
792
  connection: connectionWithDockerTarget({}, localDockerTarget),
774
793
  });
@@ -801,7 +820,7 @@ export async function cmdUp(opts, {
801
820
  // Fall through to the authoritative `get_stack_lease` read below — do
802
821
  // NOT run the local provisioner; the Helper owns this stack.
803
822
  } else {
804
- const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
823
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
805
824
  const leaseCancellation = cancelled.ok && cancelled.data?.success
806
825
  ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
807
826
  : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
@@ -825,7 +844,7 @@ export async function cmdUp(opts, {
825
844
  // review); do NOT cancel.
826
845
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
827
846
  }
828
- const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
847
+ const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
829
848
  if (!act.ok || !act.data?.success) {
830
849
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
831
850
  }
@@ -840,7 +859,7 @@ export async function cmdUp(opts, {
840
859
  }
841
860
 
842
861
  // Authoritative final read (connection block, current state).
843
- const got = await callTool("get_stack_lease", { lease_id: leaseId });
862
+ const got = await call("get_stack_lease", { lease_id: leaseId });
844
863
  const g = got.ok && got.data?.success ? got.data : null;
845
864
  if (!g || g.state !== "active") {
846
865
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
@@ -854,7 +873,9 @@ export async function cmdUp(opts, {
854
873
  }
855
874
 
856
875
  async function cmdStatus(leaseId, opts) {
857
- const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
876
+ const auth = await stackAuthHeader();
877
+ if (!auth) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
878
+ const got = await callToolJson("get_stack_lease", { lease_id: leaseId }, { auth });
858
879
  if (!got.ok) return got.auth
859
880
  ? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
860
881
  : emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
@@ -868,7 +889,9 @@ async function cmdStatus(leaseId, opts) {
868
889
  }
869
890
 
870
891
  async function cmdTouch(leaseId, opts) {
871
- const r = await callToolJson("touch_stack_lease", { lease_id: leaseId });
892
+ const auth = await stackAuthHeader();
893
+ if (!auth) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
894
+ const r = await callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth });
872
895
  if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
873
896
  if (!r.data.success) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
874
897
  return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
@@ -876,15 +899,19 @@ async function cmdTouch(leaseId, opts) {
876
899
 
877
900
  export async function cmdDone(leaseId, opts, {
878
901
  callTool = callToolJson,
902
+ authProvider = stackAuthHeader,
879
903
  runPreflight = runLocalExecTargetCheck,
880
904
  proveLegacyTarget = proveLegacyLocalExecTarget,
881
905
  localTeardownFn = localTeardown,
882
906
  emitResult = emit,
883
907
  } = {}) {
908
+ const auth = await authProvider();
909
+ if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
910
+ const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
884
911
  let dockerTarget = null;
885
912
  let legacyTargetProof = null;
886
913
  if (opts.localExec) {
887
- const current = await callTool("get_stack_lease", { lease_id: leaseId });
914
+ const current = await call("get_stack_lease", { lease_id: leaseId });
888
915
  if (!current.ok || !current.data?.success) {
889
916
  return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
890
917
  error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
@@ -915,7 +942,7 @@ export async function cmdDone(leaseId, opts, {
915
942
  }
916
943
  }
917
944
 
918
- const r = await callTool("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
945
+ const r = await call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
919
946
  if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
920
947
  if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
921
948
  let state = r.data.state;
@@ -931,7 +958,7 @@ export async function cmdDone(leaseId, opts, {
931
958
  error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
932
959
  }), opts, EXIT.LEASE_FAILED);
933
960
  }
934
- const fin = await callTool("finalize_stack_lease", { lease_id: leaseId });
961
+ const fin = await call("finalize_stack_lease", { lease_id: leaseId });
935
962
  if (fin.ok && fin.data?.success) state = fin.data.state;
936
963
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
937
964
  }
@@ -1049,13 +1076,13 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
1049
1076
  * without Docker or a live BotBuddy service.
1050
1077
  */
1051
1078
  export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1079
+ const auth = adapters.auth ?? await stackAuthHeader();
1052
1080
  const api = adapters.api ?? {
1053
- request: (args) => callToolJson("request_stack_lease", args),
1054
- get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
1055
- touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
1056
- release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
1081
+ request: (args) => callToolJson("request_stack_lease", args, { auth }),
1082
+ get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }, { auth }),
1083
+ touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth }),
1084
+ release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { auth, signal }),
1057
1085
  };
1058
- const auth = adapters.auth ?? await stackAuthHeader();
1059
1086
  const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
1060
1087
  const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
1061
1088
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
@@ -1263,6 +1290,13 @@ export async function cmdStack(argv) {
1263
1290
  process.exitCode = code;
1264
1291
  return code;
1265
1292
  }
1293
+ const sessionToken = readAgentKeyEnv();
1294
+ if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
1295
+ process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; re-register and export a fresh session token.\n`);
1296
+ const code = emit(buildReceipt({ command, outcome: "error", error: "invalid_session_token" }), opts, EXIT.INVALID);
1297
+ process.exitCode = code;
1298
+ return code;
1299
+ }
1266
1300
  let code;
1267
1301
  try {
1268
1302
  switch (command) {