@botbuddy/cli 1.22.0 → 1.24.0

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/commands.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import { callTool, callToolJson, readResource } from "./api.mjs";
2
3
  import { doLogin } from "./auth.mjs";
3
4
  import { runBridge } from "./codex-bridge.mjs";
@@ -14,6 +15,7 @@ import { runWait } from "./wait.mjs";
14
15
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
15
16
  import { VERSION } from "./version.mjs";
16
17
  import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
18
+ import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFAULT_MCP_ENV_VAR, McpKeyError, McpKeyStoreError } from "./mcp-key.mjs";
17
19
  import { runPw } from "./pw/run.mjs";
18
20
  import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
19
21
 
@@ -55,6 +57,8 @@ export async function run(argv, {
55
57
  case "wait": return runWait(args);
56
58
  case "pw": return runPw(args);
57
59
  case "profile": return cmdProfile(args);
60
+ case "carrier": return cmdCarrier(args);
61
+ case "mcp": return cmdMcp(args);
58
62
  case "resources": return callTool("list_resources");
59
63
  case "agents": return callTool("list_agents");
60
64
  case "tasks": return readResource("botbuddy://tasks");
@@ -96,13 +100,23 @@ ${bold("AUTH")}
96
100
  Authenticate via OAuth (opens browser + localhost callback)
97
101
  logout Remove saved credentials
98
102
  status Show current auth status (local metadata + server check)
99
- profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
103
+ carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
104
+ (interactive operators use ${cyan("login")} — it installs the client key)
105
+
106
+ ${bold("MCP CONFIG KEY")}
107
+ mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
108
+ Mint a bb_mcp_ key for your .mcp.json (independently
109
+ revocable; stored under ${dim("BOTBUDDY_MCP_KEY")})
110
+ mcp revoke <agent_id> Revoke only that MCP key (login + sessions unaffected)
111
+ mcp status Which env var holds the MCP key (never prints it)
100
112
 
101
113
  ${bold("TOOLS")}
102
114
  help --tools List every BotBuddy tool
103
115
  help <tool> Show one tool's arguments
104
116
  call <tool> [--key value] Invoke any tool
105
117
  call <tool> --json '{...}' Invoke with a JSON arguments object
118
+ call <tool> --pin <slug> Pin the connection tenant (a user token needs one;
119
+ default: BOTBUDDY_TENANT → repo profile → sole tenant)
106
120
 
107
121
  ${bold("STACK LEASES")}
108
122
  stack up [options] Request a batch-scoped local stack lease; park if full; hold it active
@@ -146,7 +160,8 @@ async function cmdCall(args) {
146
160
  if (e instanceof CallUsageError) die(e.message);
147
161
  throw e;
148
162
  }
149
- return callTool(parsedArgs.tool, parsedArgs.args);
163
+ // BOT-1571: an explicit --pin beats the derived default pin (see api.mjs).
164
+ return callTool(parsedArgs.tool, parsedArgs.args, { tenant: parsedArgs.pin });
150
165
  }
151
166
 
152
167
  async function cmdToolHelp(args) {
@@ -280,6 +295,15 @@ ${bold("USAGE")}
280
295
  botbuddy login [--no-browser] [--tenant <slug>]
281
296
  [--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
282
297
 
298
+ ${bold("TOKEN SCOPE")}
299
+ By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
300
+ token that is not bound to a tenant and reaches every tenant you belong to —
301
+ each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
302
+ exchanges it for a per-session agent token. No per-tenant ${cyan("profile setup")} is
303
+ needed (that path is now CI-carrier only).
304
+ ${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
305
+ tenant (the MCP-session model); use it only when you want that guarantee.
306
+
283
307
  ${bold("HOW IT WORKS")}
284
308
  Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
285
309
  your default browser at the BotBuddy authorization page, and waits for you to
@@ -293,9 +317,10 @@ ${bold("OPTIONS")}
293
317
  in a browser on THIS machine yourself. The callback listener
294
318
  still runs on this machine's localhost.
295
319
  --tenant <slug>
296
- Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
297
- your account belongs to more than one. Must be a lowercase slug;
298
- membership is still enforced by the server.
320
+ Mint a token SEALED to one tenant (e.g. ${cyan("supply-guard")}); the
321
+ sign-in page shows it as requested by the CLI. Must be a lowercase
322
+ slug; membership is still enforced by the server. Without it the
323
+ token is a user token that reaches all your tenants.
299
324
  --caller <name>
300
325
  Show WHO requested this login on the authorization screen, so
301
326
  the human approving it can confirm the request came from the
@@ -325,8 +350,8 @@ ${bold("NOTES")}
325
350
  reach the listener and login will time out.
326
351
 
327
352
  ${bold("RECOVERY")}
328
- If ${cyan("botbuddy profile setup <profile>")} reports an authentication error,
329
- run ${cyan("botbuddy login")} first, then re-run profile setup.`);
353
+ If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
354
+ unattended CI host (no browser), use ${cyan("botbuddy carrier setup <profile>")}.`);
330
355
  }
331
356
 
332
357
  async function cmdStart(args) {
@@ -382,27 +407,38 @@ const STATUS_PROBE_TIMEOUT_MS = 4000;
382
407
  // so a future opaque-token size change can't reintroduce the false rejection.
383
408
  const looksLikeOwnerToken = (t) => typeof t === "string" && /^mcp_at_[0-9a-f]{32,}$/.test(t);
384
409
 
385
- async function logServerStatus(call, auth, log) {
386
- let res;
387
- try {
388
- res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS) });
389
- } catch {
390
- log(` server: ${red("unreachable")}`);
391
- return;
392
- }
393
- if (res?.ok && !res.isError) {
394
- const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
395
- log(` server: ${green("authenticated")} (${slug})`);
396
- return;
410
+ async function logServerStatus(call, auth, log, { tenant = null, tenants = null } = {}) {
411
+ // BOT-1571: a USER token is refused unpinned (MCP_TENANT_PIN_REQUIRED), so the
412
+ // probe carries a pin the token is known to reach when one is stored. Codex
413
+ // round 9 (P2): user-token membership is evaluated PER REQUEST, so a tenant
414
+ // cached at login may be stale — probe EACH recorded tenant until one
415
+ // authenticates before reporting the token rejected.
416
+ const pins = Array.isArray(tenants) && tenants.length ? tenants : [tenant];
417
+ let lastRes = null;
418
+ for (const pin of pins) {
419
+ let res;
420
+ try {
421
+ res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS), ...(pin ? { tenant: pin } : {}) });
422
+ } catch {
423
+ log(` server: ${red("unreachable")}`);
424
+ return;
425
+ }
426
+ if (res?.ok && !res.isError) {
427
+ const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
428
+ log(` server: ${green("authenticated")} (${slug})`);
429
+ return;
430
+ }
431
+ if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
432
+ log(` server: ${red("unreachable")}`);
433
+ return;
434
+ }
435
+ lastRes = res; // a rejection for this pin — try the next recorded tenant
397
436
  }
437
+ const res = lastRes;
398
438
  if (res?.ok && res.isError) {
399
439
  log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
400
440
  return;
401
441
  }
402
- if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
403
- log(` server: ${red("unreachable")}`);
404
- return;
405
- }
406
442
  log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
407
443
  }
408
444
 
@@ -413,6 +449,8 @@ export async function cmdStatus({
413
449
  callToolJson: call = callToolJson,
414
450
  log = (line) => console.log(line),
415
451
  now = () => Date.now(),
452
+ hostname = () => os.hostname(),
453
+ env = process.env,
416
454
  } = {}) {
417
455
  const cfg = getCfg();
418
456
  const owner = await resolveOwner({ getConfig: getCfg });
@@ -430,6 +468,29 @@ export async function cmdStatus({
430
468
  }
431
469
  if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
432
470
  if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
471
+ // BOT-1571/BOT-1574: what kind of token this is. A `user` token is the
472
+ // per-machine CLIENT KEY (bb_cli_): host-bound at login, reaching every
473
+ // listed tenant (each request pins one). A `tenant` token is sealed to one.
474
+ // A pre-1571 login has no recorded scope and prints nothing rather than a
475
+ // guess.
476
+ const ownerTenants = Array.isArray(owner.tenants) ? owner.tenants : [];
477
+ if (ownerValid && owner.tenantMode === "user") {
478
+ const memberships = ownerTenants.length ? ownerTenants.join(", ") : dim("none recorded");
479
+ log(` ${cyan("Client key (bb_cli_)")} · host ${dim(hostname())} · memberships: ${memberships}`);
480
+ // BOT-1574 AC1: the per-tenant Keychain agent-key slots (BOT-1558) are
481
+ // retired for interactive use — the client key replaces them. If any are
482
+ // still exported, they are IGNORED; say so once so the operator can drop
483
+ // them. The per-session token var $BOTBUDDY_AGENT_KEY (no middle segment)
484
+ // is the live wait credential and must never be flagged here.
485
+ const ignoredSlots = Object.keys(env ?? {})
486
+ .filter((k) => /^BOTBUDDY_[A-Z0-9]+_AGENT_KEY$/.test(k))
487
+ .sort();
488
+ if (ignoredSlots.length) {
489
+ log(` ${dim("!")} ignoring ${ignoredSlots.join(", ")} — botbuddy login's client key is used instead (per-tenant agent-key slots are retired for interactive use).`);
490
+ }
491
+ } else if (ownerValid && owner.tenantMode === "tenant" && owner.tenantId) {
492
+ log(` Token scope: ${cyan("tenant")} (${owner.tenantId})`);
493
+ }
433
494
  if (ownerValid && owner.expiresAt) {
434
495
  const remaining = owner.expiresAt - now();
435
496
  if (remaining <= 0) {
@@ -464,6 +525,11 @@ export async function cmdStatus({
464
525
  call,
465
526
  fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
466
527
  log,
528
+ // Pin only for a user token probed as itself; an agent key or a sealed
529
+ // token resolves its own tenant. Probe EVERY recorded tenant (round 9 P2):
530
+ // membership is per-request, so the first cached tenant may be stale while
531
+ // another is still valid.
532
+ !fallbackKey && owner.tenantMode === "user" ? { tenants: ownerTenants } : {},
467
533
  );
468
534
  return;
469
535
  }
@@ -530,38 +596,322 @@ function cmdHeartbeat(args) {
530
596
  return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
531
597
  }
532
598
 
533
- async function cmdProfile(args) {
599
+ // BOT-1574 AC6: the carrier bootstrap that `profile setup --unattended` and
600
+ // `carrier setup` share. Never gated on a TTY — the caller decides whether the
601
+ // interactive guard applies before reaching here.
602
+ async function runCarrierSetup(profile, { log, setExitCode, bootstrap }) {
603
+ try {
604
+ const receipt = await bootstrap(profile, { call: callToolJson });
605
+ log(JSON.stringify(receipt));
606
+ } catch (error) {
607
+ const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
608
+ log(JSON.stringify({
609
+ schema_version: 1,
610
+ outcome: "error",
611
+ error: code,
612
+ recovery: profileBootstrapRecovery(code, profile),
613
+ }));
614
+ setExitCode(3);
615
+ }
616
+ }
617
+
618
+ export async function cmdProfile(args, {
619
+ isTTY = Boolean(process.stdout.isTTY),
620
+ log = (line) => console.log(line),
621
+ setExitCode = (n) => { process.exitCode = n; },
622
+ bootstrap = bootstrapProfile,
623
+ } = {}) {
534
624
  if (args[0] === "--help" || args[0] === "-h") {
535
- console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>
625
+ console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]
536
626
 
537
- setup <profile> Mint/reconnect and securely store a tenant-bound agent key
538
- env <profile> Print shell exports that load the stored agent key`);
627
+ setup <profile> Carrier-only (CI): mint/reconnect and store a tenant-bound
628
+ carrier key. Retired for interactive use run ${cyan("botbuddy login")}
629
+ on a workstation. On a TTY this refuses unless --unattended.
630
+ env <profile> Print shell exports that load the stored carrier key
631
+
632
+ Interactive operators: ${cyan("botbuddy login")} installs the per-machine client key.
633
+ CI hosts: ${cyan("botbuddy carrier setup <profile>")} (alias of setup --unattended).`);
539
634
  return;
540
635
  }
541
636
  if (args[0] === "env" && args[1] && args.length === 2) {
542
637
  try {
543
- console.log(profileShellRefresh(args[1]));
638
+ log(profileShellRefresh(args[1]));
544
639
  return;
545
640
  } catch {
546
641
  die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
547
642
  }
548
643
  }
549
- if (args[0] !== "setup" || !args[1] || args.length > 2) {
550
- die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>");
644
+ const unattended = args.includes("--unattended");
645
+ const positionals = args.filter((a) => !a.startsWith("-"));
646
+ if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
647
+ die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]");
551
648
  }
552
- try {
553
- const receipt = await bootstrapProfile(args[1], { call: callToolJson });
554
- console.log(JSON.stringify(receipt));
555
- } catch (error) {
556
- const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
557
- console.log(JSON.stringify({
649
+ const profile = positionals[1];
650
+ // BOT-1574 AC6: interactive setup is retired. A human on a TTY who did not ask
651
+ // for the unattended carrier path is redirected to `botbuddy login` (which
652
+ // installs the machine client key) instead of minting a per-tenant slot. A
653
+ // non-TTY caller (CI) or an explicit --unattended keeps the carrier path.
654
+ if (isTTY && !unattended) {
655
+ log(JSON.stringify({
558
656
  schema_version: 1,
559
657
  outcome: "error",
560
- error: code,
561
- recovery: profileBootstrapRecovery(code, args[1]),
658
+ error: "interactive_use_retired",
659
+ recovery: `Interactive profile setup is retired. Run \`botbuddy login\` to install this machine's client key (bb_cli_) — it reaches every tenant you belong to, no per-tenant slot needed. For an unattended CI carrier, re-run with --unattended or use \`botbuddy carrier setup ${profile}\`.`,
660
+ }));
661
+ setExitCode(4);
662
+ return;
663
+ }
664
+ await runCarrierSetup(profile, { log, setExitCode, bootstrap });
665
+ }
666
+
667
+ // BOT-1574 AC6: `botbuddy carrier setup <profile>` — the explicit CI-carrier
668
+ // alias of `profile setup --unattended`. Being the named carrier command, it is
669
+ // never subject to the interactive-TTY guard.
670
+ export async function cmdCarrier(args, {
671
+ log = (line) => console.log(line),
672
+ setExitCode = (n) => { process.exitCode = n; },
673
+ bootstrap = bootstrapProfile,
674
+ } = {}) {
675
+ if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
676
+ console.log(`Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>
677
+
678
+ Mint/reconnect and store an unattended CI carrier key (alias of
679
+ ${cyan("botbuddy profile setup <profile> --unattended")}). Interactive operators use
680
+ ${cyan("botbuddy login")} instead.`);
681
+ return;
682
+ }
683
+ const positionals = args.filter((a) => !a.startsWith("-"));
684
+ if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
685
+ die("Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>");
686
+ }
687
+ await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
688
+ }
689
+
690
+ // ─── BOT-1607: `botbuddy mcp` — the tier-2 bb_mcp_ config key ────────────────
691
+
692
+ function mcpHelp(log) {
693
+ log(`Usage: botbuddy mcp <setup|revoke|status> [options]
694
+
695
+ setup [--env <NAME>] [--tenant <slug>] [--label <label>] [--expiry-days <n>] [--force]
696
+ Mint a bb_mcp_ MCP config key (authenticated by your
697
+ ${cyan("botbuddy login")} owner/client credential), store it in the
698
+ Keychain under ${dim("BOTBUDDY_MCP_KEY")} (or --env <NAME>), and print
699
+ the .mcp.json / .codex/config.toml snippets that reference it.
700
+ Refuses an occupied Keychain slot unless --force (revoke the
701
+ old key first — --force leaves it live server-side).
702
+ revoke <agent_id> [--tenant <slug>] [--env <NAME>]
703
+ Revoke ONLY that MCP key (returned by setup). Your login and
704
+ live agent sessions keep working. Multi-tenant owners must
705
+ pin the tenant the key was minted for with --tenant. Pass
706
+ --env <NAME> and the receipt's cleanup command names that
707
+ slot (revoke never deletes local Keychain items itself).
708
+ status [--env <NAME>]
709
+ Report which env var holds the MCP config key (never prints
710
+ the secret). ${dim("BOTBUDDY_BB_AGENT_KEY")} is honoured as a deprecated
711
+ alias for one release.
712
+
713
+ Present the key from .mcp.json instead of reusing an agent session token — it is
714
+ independently revocable, so a leaked config credential rotates without a re-login.`);
715
+ }
716
+
717
+ // Actionable recovery for an mcp setup/revoke failure code.
718
+ export function mcpRecovery(code) {
719
+ switch (code) {
720
+ case "not_authenticated":
721
+ return "run `botbuddy login` to install this machine's client key, then re-run `botbuddy mcp setup`.";
722
+ case "owner_required":
723
+ return "minting requires your owner/client credential — run `botbuddy login` (an agent or MCP key cannot mint a key).";
724
+ case "tenant_pin_required":
725
+ return "you belong to more than one tenant — pin the tenant the key lives in: `botbuddy mcp setup --tenant <slug>` (or `botbuddy mcp revoke <agent_id> --tenant <slug>`).";
726
+ case "mcp_key_store_failed":
727
+ return "the key was minted but not stored — unlock your login keychain (`security unlock-keychain`) and re-run `botbuddy mcp setup`.";
728
+ case "invalid_env_var":
729
+ return "--env must be an UPPER_SNAKE_CASE variable name, e.g. BOTBUDDY_MCP_KEY_WORK.";
730
+ case "reserved_env_var":
731
+ return "that --env name belongs to another BotBuddy credential (it would clobber that Keychain slot) — pick a distinct MCP var like BOTBUDDY_MCP_KEY_WORK.";
732
+ case "env_var_in_use":
733
+ return "that Keychain slot already holds a key — revoke the old server key (`botbuddy mcp revoke <agent_id>`) if it is still live, then clear the local slot with `security delete-generic-password -s <NAME>`; or use a different --env, or pass --force to replace it in place.";
734
+ case "keychain_read_failed":
735
+ return "couldn't read the Keychain to check the slot (locked/unavailable) — unlock it (`security unlock-keychain`) and retry, or pass --force to skip the check.";
736
+ case "not_an_mcp_key":
737
+ return "that agent_id is not a bb_mcp_ key — pass the agent_id `botbuddy mcp setup` returned.";
738
+ case "not_found":
739
+ return "no MCP key with that agent_id — check the id from `botbuddy mcp setup`.";
740
+ case "forbidden":
741
+ return "that key is not in the pinned tenant — pin the tenant it was minted for with `--tenant <slug>` (and you must be a member).";
742
+ case "tenant_forbidden":
743
+ return "you are not a member of the pinned tenant — pin one you belong to with `--tenant <slug>` (re-login won't fix a tenant mismatch).";
744
+ case "invalid_label":
745
+ return "--label requires a value, e.g. --label laptop.";
746
+ case "unexpected_arg":
747
+ return "botbuddy mcp setup takes no positional arguments — pass options like --env <NAME> / --tenant <slug> / --label <label>.";
748
+ default:
749
+ return "run `botbuddy login`, then re-run the command.";
750
+ }
751
+ }
752
+
753
+ export function parseMcpSetupArgs(args) {
754
+ const out = { envVar: DEFAULT_MCP_ENV_VAR, tenant: null, label: null, expiryDays: null, force: false };
755
+ for (let i = 1; i < args.length; i++) {
756
+ const arg = args[i];
757
+ const take = (flag) => (arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : args[++i]);
758
+ if (arg === "--force" || arg === "-f") { out.force = true; continue; }
759
+ if (arg === "--env" || arg.startsWith("--env=")) {
760
+ // Codex P2: a bare `--env` (no value) must NOT silently fall back to the
761
+ // default slot and mint there — distinguish a missing value from a missing
762
+ // option.
763
+ const val = take("--env");
764
+ if (val === undefined || val === "") throw new McpKeyError("invalid_env_var", { detail: "--env requires a variable name, e.g. BOTBUDDY_MCP_KEY_WORK" });
765
+ out.envVar = resolveEnvVarName(val);
766
+ continue;
767
+ }
768
+ if (arg === "--tenant" || arg.startsWith("--tenant=")) {
769
+ const value = take("--tenant");
770
+ if (typeof value !== "string" || !TENANT_SLUG_RE.test(value)) die("error: --tenant must be a lowercase slug");
771
+ out.tenant = value;
772
+ continue;
773
+ }
774
+ if (arg === "--label" || arg.startsWith("--label=")) {
775
+ // Codex P2: a bare `--label` (no value) must not silently mint an unlabeled
776
+ // key — a missing value is a usage error, like --env.
777
+ const val = take("--label");
778
+ if (val === undefined || val === "") throw new McpKeyError("invalid_label", { detail: "--label requires a value" });
779
+ out.label = val;
780
+ continue;
781
+ }
782
+ if (arg === "--expiry-days" || arg.startsWith("--expiry-days=")) {
783
+ const value = Number(take("--expiry-days"));
784
+ if (!Number.isInteger(value) || value <= 0) die("error: --expiry-days must be a positive integer");
785
+ out.expiryDays = value;
786
+ continue;
787
+ }
788
+ if (arg.startsWith("-")) die(`Unknown mcp setup option: ${arg}. Run ${cyan("botbuddy mcp --help")}.`);
789
+ // Codex P2: a stray positional (`mcp setup laptop`, or a misspelled option
790
+ // value) must be a usage error, not silently ignored while a live credential
791
+ // is minted with defaults. mcp setup takes options only.
792
+ throw new McpKeyError("unexpected_arg", { detail: arg });
793
+ }
794
+ return out;
795
+ }
796
+
797
+ // BOT-1607 (Codex P2): `mcp status` accepts the same `--env <NAME>` an install
798
+ // used, so a custom install reports correctly. Returns the requested var name
799
+ // (the default when the flag is absent); a bad name throws invalid_env_var.
800
+ export function parseMcpStatusEnv(args) {
801
+ for (let i = 1; i < args.length; i++) {
802
+ const arg = args[i];
803
+ if (arg === "--env" || arg.startsWith("--env=")) {
804
+ const raw = arg.startsWith("--env=") ? arg.slice("--env=".length) : args[++i];
805
+ if (raw === undefined || raw === "") throw new McpKeyError("invalid_env_var", { detail: "--env requires a variable name, e.g. BOTBUDDY_MCP_KEY_WORK" });
806
+ // Explicit selector → inspect exactly this var (canonical or custom).
807
+ // Read-only: the deprecated alias BOTBUDDY_BB_AGENT_KEY is allowed here.
808
+ return { envVar: resolveEnvVarName(raw, { forWrite: false }), explicit: true };
809
+ }
810
+ if (arg.startsWith("-")) die(`Unknown mcp status option: ${arg}. Run ${cyan("botbuddy mcp --help")}.`);
811
+ }
812
+ return { envVar: DEFAULT_MCP_ENV_VAR, explicit: false };
813
+ }
814
+
815
+ export async function cmdMcp(args, {
816
+ log = (line) => console.log(line),
817
+ setExitCode = (n) => { process.exitCode = n; },
818
+ setup = setupMcpKey,
819
+ revoke = revokeMcpKey,
820
+ resolveConfigKey = resolveMcpConfigKey,
821
+ env = process.env,
822
+ } = {}) {
823
+ if (args[0] === "--help" || args[0] === "-h") return mcpHelp(log);
824
+ const sub = args.find((a) => !a.startsWith("-"));
825
+ if (!sub) return mcpHelp(log);
826
+
827
+ if (sub === "setup") {
828
+ try {
829
+ // Parse inside the try so a bad --env (missing value / reserved name) becomes
830
+ // an error receipt rather than an uncaught throw (Codex P2).
831
+ const opts = parseMcpSetupArgs(args);
832
+ log(JSON.stringify(await setup({ envVar: opts.envVar, tenant: opts.tenant, label: opts.label, expiryDays: opts.expiryDays, force: opts.force })));
833
+ } catch (error) {
834
+ const code = (error instanceof McpKeyError || error instanceof McpKeyStoreError) ? error.code : "mint_failed";
835
+ const extra = {};
836
+ if (error instanceof McpKeyStoreError) {
837
+ extra.detail = error.message;
838
+ // An un-rolled-back mint left a live orphan — name it so the operator
839
+ // can `botbuddy mcp revoke <id>` rather than pile up unusable keys.
840
+ if (!error.rolledBack && error.agentId) extra.orphan_agent_id = error.agentId;
841
+ }
842
+ log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, ...extra, recovery: mcpRecovery(code) }));
843
+ setExitCode(3);
844
+ }
845
+ return;
846
+ }
847
+
848
+ if (sub === "revoke") {
849
+ // The server binds revocation to the pinned tenant (BOT-1607 Codex P1), so a
850
+ // multi-tenant owner must pin the tenant the key was minted for. --env names
851
+ // the local Keychain slot to clear after a successful revoke (Codex P2). Parse
852
+ // flag-aware so a flag value is not mistaken for an extra positional.
853
+ let agentId = null, tenant = null, envVar = null, extraPositionals = 0;
854
+ try {
855
+ for (let i = 1; i < args.length; i++) {
856
+ const arg = args[i];
857
+ if (arg === "--tenant" || arg.startsWith("--tenant=")) {
858
+ tenant = arg.startsWith("--tenant=") ? arg.slice("--tenant=".length) : args[++i];
859
+ if (typeof tenant !== "string" || !TENANT_SLUG_RE.test(tenant)) die("error: --tenant must be a lowercase slug");
860
+ continue;
861
+ }
862
+ if (arg === "--env" || arg.startsWith("--env=")) {
863
+ const raw = arg.startsWith("--env=") ? arg.slice("--env=".length) : args[++i];
864
+ if (raw === undefined || raw === "") throw new McpKeyError("invalid_env_var", { detail: "--env requires a variable name, e.g. BOTBUDDY_MCP_KEY_WORK" });
865
+ envVar = resolveEnvVarName(raw);
866
+ continue;
867
+ }
868
+ if (arg.startsWith("-")) die(`Unknown mcp revoke option: ${arg}. Run ${cyan("botbuddy mcp --help")}.`);
869
+ if (agentId == null) agentId = arg; else extraPositionals++;
870
+ }
871
+ } catch (error) {
872
+ const code = error instanceof McpKeyError ? error.code : "revoke_failed";
873
+ log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
874
+ setExitCode(3);
875
+ return;
876
+ }
877
+ if (!agentId || extraPositionals > 0) die("Usage: botbuddy mcp revoke <agent_id> [--tenant <slug>] [--env <NAME>]");
878
+ try {
879
+ log(JSON.stringify(await revoke({ agentId, tenant, envVar })));
880
+ } catch (error) {
881
+ const code = error instanceof McpKeyError ? error.code : "revoke_failed";
882
+ log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
883
+ setExitCode(3);
884
+ }
885
+ return;
886
+ }
887
+
888
+ if (sub === "status") {
889
+ let requested;
890
+ try {
891
+ requested = parseMcpStatusEnv(args);
892
+ } catch (error) {
893
+ const code = error instanceof McpKeyError ? error.code : "invalid_env_var";
894
+ log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
895
+ setExitCode(3);
896
+ return;
897
+ }
898
+ const resolved = resolveConfigKey(env, { envVar: requested.envVar, explicit: requested.explicit });
899
+ if (!resolved) {
900
+ log(JSON.stringify({ schema_version: 1, outcome: "status", configured: false, env_var: requested.envVar }));
901
+ return;
902
+ }
903
+ log(JSON.stringify({
904
+ schema_version: 1,
905
+ outcome: "status",
906
+ configured: true,
907
+ env_var: resolved.envVar,
908
+ deprecated: resolved.deprecated,
909
+ ...(resolved.notice ? { notice: resolved.notice } : {}),
562
910
  }));
563
- process.exitCode = 3;
911
+ return;
564
912
  }
913
+
914
+ die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
565
915
  }
566
916
 
567
917
  function cmdLock(args) {
@@ -17,6 +17,9 @@ export const CREDENTIAL_PREFIXES = [
17
17
  { prefix: "mcp_at_", kind: "oauth", label: "OAuth owner token" },
18
18
  { prefix: "bb_pat_", kind: "pat", label: "personal access token" },
19
19
  { prefix: "bb_cli_", kind: "cli", label: "client key" },
20
+ // BOT-1607: the tier-2 MCP config key a .mcp.json presents (independently
21
+ // revocable; minted by `botbuddy mcp`). Unambiguous by prefix.
22
+ { prefix: "bb_mcp_", kind: "mcp", label: "MCP key" },
20
23
  // BOT-1573/1582 transition: bb_agent_ is now BOTH the per-session agent token
21
24
  // (BOT-1582 renamed it from bb_sess_) AND the older shared service carrier, so
22
25
  // the prefix ALONE can no longer tell them apart. Classification is display
package/src/discovery.mjs CHANGED
@@ -30,10 +30,23 @@ export function parseCallArgs(argv) {
30
30
 
31
31
  let args = {};
32
32
  let sawJson = false;
33
+ // BOT-1571: `--pin <slug>` pins the CONNECTION tenant (the ?tenant= query),
34
+ // which a user token needs on every request. Deliberately not `--tenant`:
35
+ // several tools take a `tenant` / `tenant_id` argument of their own.
36
+ let pin = null;
33
37
 
34
38
  for (let i = 0; i < rest.length; i++) {
35
39
  const token = rest[i];
36
40
 
41
+ if (token === "--pin") {
42
+ const raw = rest[++i];
43
+ if (raw === undefined || raw.startsWith("--") || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw)) {
44
+ throw new CallUsageError("--pin requires a lowercase tenant slug, e.g. --pin supply-guard.");
45
+ }
46
+ pin = raw;
47
+ continue;
48
+ }
49
+
37
50
  if (token === "--json" || token === "-j") {
38
51
  const raw = rest[++i];
39
52
  if (raw === undefined) throw new CallUsageError("--json requires a JSON object argument.");
@@ -73,7 +86,7 @@ export function parseCallArgs(argv) {
73
86
  );
74
87
  }
75
88
 
76
- return { tool, args, usedJson: sawJson };
89
+ return { tool, args, usedJson: sawJson, pin };
77
90
  }
78
91
 
79
92
  /**