@botbuddy/cli 1.28.0 → 1.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.28.0",
3
+ "version": "1.29.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -612,6 +612,9 @@ export function mcpHelp(log = console.log) {
612
612
  ${cyan("botbuddy login")} owner/client credential), store it in the
613
613
  Keychain under ${dim("BOTBUDDY_MCP_KEY")} (or --env <NAME>), and print
614
614
  the .mcp.json / .codex/config.toml snippets that reference it.
615
+ A bb_mcp_ key is tenant-sealed: on a machine serving more than
616
+ one tenant, mint each tenant under a distinct --env <NAME> var
617
+ and commit that repo's matching .botbuddy-agent.json mcp_env.
615
618
  Refuses an occupied Keychain slot unless --force (revoke the
616
619
  old key first — --force leaves it live server-side).
617
620
  revoke <agent_id> [--tenant <slug>] [--env <NAME>]
@@ -65,6 +65,7 @@ export const SETUP_BLOCK = `SETUP — the connectivity ladder (install → conne
65
65
  3. bb mcp setup tier 2 · MCP key (bb_mcp_, tenant-bound) → Keychain + .mcp.json ($BOTBUDDY_MCP_KEY)
66
66
  (Keychainless host: the key is printed ONCE — store + export + edit .mcp.json yourself)
67
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)
68
69
  4. register_agent (over MCP) tier 3 · session token (bb_agent_, per-session, 8 h)
69
70
  5. export BOTBUDDY_AGENT_KEY=<session_token> and BOTBUDDY_SESSION_ID=<session_id>
70
71
  6. bb wait needs the tier-3 token; bb run/test also accept $BOTBUDDY_SESSION_ID,
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) {
@@ -697,6 +705,7 @@ export async function cmdUp(opts, {
697
705
  }
698
706
  const auth = await authProvider();
699
707
  if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
708
+ const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
700
709
 
701
710
  // BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
702
711
  // physical machine (hardware id), since a hostname is not machine-unique. The
@@ -705,7 +714,7 @@ export async function cmdUp(opts, {
705
714
  if (!hardwareUuid) {
706
715
  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
716
  }
708
- const req = await callTool("request_stack_lease", {
717
+ const req = await call("request_stack_lease", {
709
718
  slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
710
719
  ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
711
720
  pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
@@ -748,7 +757,7 @@ export async function cmdUp(opts, {
748
757
  localPreflight = compactPreflight(checked.receipt);
749
758
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
750
759
  if (checked.exitCode !== 0 || !localDockerTarget) {
751
- const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
760
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
752
761
  const leaseCancellation = cancelled.ok && cancelled.data?.success
753
762
  ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
754
763
  : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
@@ -768,7 +777,7 @@ export async function cmdUp(opts, {
768
777
  // Persist the validated Docker target with the reservation so that if the
769
778
  // provisioner partially starts a stack and then fails, the fenced lease can
770
779
  // still be torn down by `stack done --local-exec` (BOT-1421 review).
771
- const reserved = await callTool("reserve_stack_lease", {
780
+ const reserved = await call("reserve_stack_lease", {
772
781
  lease_id: leaseId,
773
782
  connection: connectionWithDockerTarget({}, localDockerTarget),
774
783
  });
@@ -801,7 +810,7 @@ export async function cmdUp(opts, {
801
810
  // Fall through to the authoritative `get_stack_lease` read below — do
802
811
  // NOT run the local provisioner; the Helper owns this stack.
803
812
  } else {
804
- const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
813
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
805
814
  const leaseCancellation = cancelled.ok && cancelled.data?.success
806
815
  ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
807
816
  : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
@@ -825,7 +834,7 @@ export async function cmdUp(opts, {
825
834
  // review); do NOT cancel.
826
835
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
827
836
  }
828
- const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
837
+ const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
829
838
  if (!act.ok || !act.data?.success) {
830
839
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
831
840
  }
@@ -840,7 +849,7 @@ export async function cmdUp(opts, {
840
849
  }
841
850
 
842
851
  // Authoritative final read (connection block, current state).
843
- const got = await callTool("get_stack_lease", { lease_id: leaseId });
852
+ const got = await call("get_stack_lease", { lease_id: leaseId });
844
853
  const g = got.ok && got.data?.success ? got.data : null;
845
854
  if (!g || g.state !== "active") {
846
855
  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 +863,9 @@ export async function cmdUp(opts, {
854
863
  }
855
864
 
856
865
  async function cmdStatus(leaseId, opts) {
857
- const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
866
+ const auth = await stackAuthHeader();
867
+ if (!auth) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
868
+ const got = await callToolJson("get_stack_lease", { lease_id: leaseId }, { auth });
858
869
  if (!got.ok) return got.auth
859
870
  ? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
860
871
  : emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
@@ -868,7 +879,9 @@ async function cmdStatus(leaseId, opts) {
868
879
  }
869
880
 
870
881
  async function cmdTouch(leaseId, opts) {
871
- const r = await callToolJson("touch_stack_lease", { lease_id: leaseId });
882
+ const auth = await stackAuthHeader();
883
+ if (!auth) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
884
+ const r = await callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth });
872
885
  if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
873
886
  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
887
  return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
@@ -876,15 +889,19 @@ async function cmdTouch(leaseId, opts) {
876
889
 
877
890
  export async function cmdDone(leaseId, opts, {
878
891
  callTool = callToolJson,
892
+ authProvider = stackAuthHeader,
879
893
  runPreflight = runLocalExecTargetCheck,
880
894
  proveLegacyTarget = proveLegacyLocalExecTarget,
881
895
  localTeardownFn = localTeardown,
882
896
  emitResult = emit,
883
897
  } = {}) {
898
+ const auth = await authProvider();
899
+ if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
900
+ const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
884
901
  let dockerTarget = null;
885
902
  let legacyTargetProof = null;
886
903
  if (opts.localExec) {
887
- const current = await callTool("get_stack_lease", { lease_id: leaseId });
904
+ const current = await call("get_stack_lease", { lease_id: leaseId });
888
905
  if (!current.ok || !current.data?.success) {
889
906
  return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
890
907
  error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
@@ -915,7 +932,7 @@ export async function cmdDone(leaseId, opts, {
915
932
  }
916
933
  }
917
934
 
918
- const r = await callTool("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
935
+ const r = await call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
919
936
  if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
920
937
  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
938
  let state = r.data.state;
@@ -931,7 +948,7 @@ export async function cmdDone(leaseId, opts, {
931
948
  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
949
  }), opts, EXIT.LEASE_FAILED);
933
950
  }
934
- const fin = await callTool("finalize_stack_lease", { lease_id: leaseId });
951
+ const fin = await call("finalize_stack_lease", { lease_id: leaseId });
935
952
  if (fin.ok && fin.data?.success) state = fin.data.state;
936
953
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
937
954
  }
@@ -1049,13 +1066,13 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
1049
1066
  * without Docker or a live BotBuddy service.
1050
1067
  */
1051
1068
  export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1069
+ const auth = adapters.auth ?? await stackAuthHeader();
1052
1070
  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 }),
1071
+ request: (args) => callToolJson("request_stack_lease", args, { auth }),
1072
+ get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }, { auth }),
1073
+ touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth }),
1074
+ release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { auth, signal }),
1057
1075
  };
1058
- const auth = adapters.auth ?? await stackAuthHeader();
1059
1076
  const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
1060
1077
  const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
1061
1078
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
@@ -1263,6 +1280,13 @@ export async function cmdStack(argv) {
1263
1280
  process.exitCode = code;
1264
1281
  return code;
1265
1282
  }
1283
+ const sessionToken = readAgentKeyEnv();
1284
+ if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
1285
+ process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; re-register and export a fresh session token.\n`);
1286
+ const code = emit(buildReceipt({ command, outcome: "error", error: "invalid_session_token" }), opts, EXIT.INVALID);
1287
+ process.exitCode = code;
1288
+ return code;
1289
+ }
1266
1290
  let code;
1267
1291
  try {
1268
1292
  switch (command) {