@botbuddy/cli 1.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -15,6 +15,7 @@ import { runWait } from "./wait.mjs";
15
15
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
16
16
  import { VERSION } from "./version.mjs";
17
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";
18
19
  import { runPw } from "./pw/run.mjs";
19
20
  import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
20
21
 
@@ -57,6 +58,7 @@ export async function run(argv, {
57
58
  case "pw": return runPw(args);
58
59
  case "profile": return cmdProfile(args);
59
60
  case "carrier": return cmdCarrier(args);
61
+ case "mcp": return cmdMcp(args);
60
62
  case "resources": return callTool("list_resources");
61
63
  case "agents": return callTool("list_agents");
62
64
  case "tasks": return readResource("botbuddy://tasks");
@@ -101,6 +103,13 @@ ${bold("AUTH")}
101
103
  carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
102
104
  (interactive operators use ${cyan("login")} — it installs the client key)
103
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)
112
+
104
113
  ${bold("TOOLS")}
105
114
  help --tools List every BotBuddy tool
106
115
  help <tool> Show one tool's arguments
@@ -678,6 +687,233 @@ export async function cmdCarrier(args, {
678
687
  await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
679
688
  }
680
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 } : {}),
910
+ }));
911
+ return;
912
+ }
913
+
914
+ die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
915
+ }
916
+
681
917
  function cmdLock(args) {
682
918
  if (args.length < 2) die("Usage: botbuddy lock <resource_name> <type>");
683
919
  return callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] });
@@ -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
@@ -0,0 +1,385 @@
1
+ // BOT-1607 — `botbuddy mcp`: mint / revoke the tier-2 `bb_mcp_` MCP config key.
2
+ //
3
+ // A `.mcp.json` should present its OWN independently-revocable key, not the
4
+ // reused `bb_agent_` session token `profile setup` planted in
5
+ // BOTBUDDY_BB_AGENT_KEY. `botbuddy mcp setup` mints a `bb_mcp_` key —
6
+ // authenticated by the tier-1 owner/client credential (resolveCallAuth: the
7
+ // owner OAuth/client key from `botbuddy login`) — stores it in the Keychain
8
+ // under a default-or-`--env` service, and prints the `.mcp.json` /
9
+ // `.codex/config.toml` snippets that reference that var. `botbuddy mcp revoke`
10
+ // invalidates ONLY that key, so a leaked config credential rotates without a
11
+ // re-login or agent disruption.
12
+ //
13
+ // The mint/revoke calls are the server tools mint_mcp_key / revoke_mcp_key
14
+ // (owner/client-gated). This module mirrors profile-bootstrap.mjs's dependency
15
+ // injection so the whole flow is unit-testable with an injected call / auth /
16
+ // keychain.
17
+
18
+ import { keychainAvailable, readKeychainSecret, writeKeychainSecret } from "./agent-credential-store.mjs";
19
+ import { callToolJson, resolveCallAuth } from "./api.mjs";
20
+ import { SERVER_URL } from "./config.mjs";
21
+
22
+ // The default env/Keychain var a `.mcp.json` references (BOT-1108 canonical set,
23
+ // aligned with src/components/credentials/SetupSnippet.tsx). `--env <NAME>`
24
+ // overrides it.
25
+ export const DEFAULT_MCP_ENV_VAR = "BOTBUDDY_MCP_KEY";
26
+
27
+ // BOT-1607 AC5: the pre-1607 var `profile setup` wrote the reused bb_agent_ session token into.
28
+ // A `.mcp.json` still referencing it keeps authenticating for one release (the
29
+ // server authenticates by hash regardless of which env var carried the key); the
30
+ // CLI recognises it as a DEPRECATED alias and tells the operator to migrate.
31
+ export const LEGACY_MCP_ENV_VAR = "BOTBUDDY_BB_AGENT_KEY";
32
+
33
+ // A valid shell env-var / Keychain service name.
34
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
35
+
36
+ // BOT-1607 (Codex P2): --env doubles as the macOS Keychain service name, so a
37
+ // name owned by ANOTHER BotBuddy credential kind would silently overwrite that
38
+ // credential's Keychain slot (e.g. BOTBUDDY_CLIENT_KEY is the bb_cli_ token
39
+ // `botbuddy login` installs). Refuse those; the MCP default var and its legacy
40
+ // alias are fine, as is any distinct custom name (BOTBUDDY_MCP_KEY_WORK, …).
41
+ const RESERVED_ENV_VARS = new Set([
42
+ "BOTBUDDY_CLIENT_KEY", // bb_cli_ — botbuddy login client key
43
+ "BOTBUDDY_AGENT_KEY", // bb_agent_ — per-session agent token
44
+ "BOTBUDDY_SVC_KEY", // bb_svc_ — legacy carrier
45
+ "BOTBUDDY_CI_KEY", // bb_ci_ — CI key
46
+ "BOTBUDDY_TOKEN", // PAT / OAuth owner token
47
+ "BOTBUDDY_TEST_RUN_TOKEN", // publishable test-run token
48
+ "BOTBUDDY_SG_AGENT_KEY", // Supply Guard profile Keychain slot (profileCredentialEnvironment) — never an MCP var
49
+ ]);
50
+
51
+ // BOTBUDDY_BB_AGENT_KEY is dual-purpose: the deprecated MCP READ alias AND the
52
+ // botbuddy profile Keychain slot (agent-credential-store profileCredentialEnvironment).
53
+ // A `mcp status` (read-only) may name it, but `mcp setup`/`revoke` must NOT write
54
+ // or delete it — that would clobber/destroy the profile client credential (Codex P2).
55
+ // It is not in RESERVED_ENV_VARS so status can still resolve the legacy alias.
56
+
57
+ export class McpKeyError extends Error {
58
+ constructor(code, { detail = null } = {}) {
59
+ super(detail ? `${code}: ${detail}` : code);
60
+ this.code = code;
61
+ this.detail = detail;
62
+ }
63
+ }
64
+
65
+ // A Keychain write failed AFTER minting succeeded. Distinct so the CLI never
66
+ // mislabels a storage failure as an auth failure (AC2: a read-back mismatch /
67
+ // locked keychain must fail loudly, never a silent success).
68
+ export class McpKeyStoreError extends Error {
69
+ constructor(message, { cause, agentId = null, rolledBack = false } = {}) {
70
+ super(message);
71
+ this.name = "McpKeyStoreError";
72
+ this.code = "mcp_key_store_failed";
73
+ // BOT-1607 (Codex P2): the minted key's id + whether the best-effort rollback
74
+ // revoked it, so the receipt can point the operator at a manual revoke when
75
+ // an orphan key survives.
76
+ this.agentId = agentId;
77
+ this.rolledBack = rolledBack;
78
+ if (cause !== undefined) this.cause = cause;
79
+ }
80
+ }
81
+
82
+ // A structured MCP error code on a tool-result body (`data.code`) or, for a
83
+ // 401/403, on the auth receipt (`code`) callToolJson lifts out (BOT-1561).
84
+ function structuredCode(response) {
85
+ if (typeof response?.data?.code === "string") return response.data.code;
86
+ if (typeof response?.code === "string") return response.code;
87
+ return null;
88
+ }
89
+
90
+ /** Validate an explicit `--env <NAME>`; the default needs no validation. */
91
+ export function resolveEnvVarName(explicit, { forWrite = true } = {}) {
92
+ if (explicit == null) return DEFAULT_MCP_ENV_VAR;
93
+ const name = String(explicit).trim();
94
+ if (!ENV_VAR_RE.test(name)) {
95
+ throw new McpKeyError("invalid_env_var", { detail: `${explicit} (use UPPER_SNAKE_CASE)` });
96
+ }
97
+ if (RESERVED_ENV_VARS.has(name)) {
98
+ throw new McpKeyError("reserved_env_var", { detail: `${name} is owned by another BotBuddy credential; choose a distinct name like BOTBUDDY_MCP_KEY_WORK` });
99
+ }
100
+ // Only a WRITE (setup) or DELETE (revoke) into the botbuddy profile slot is
101
+ // dangerous; a read-only status may still resolve the deprecated MCP alias.
102
+ if (forWrite && name === LEGACY_MCP_ENV_VAR) {
103
+ throw new McpKeyError("reserved_env_var", { detail: `${name} is the botbuddy profile Keychain slot — mint/revoke into a dedicated MCP var like ${DEFAULT_MCP_ENV_VAR}` });
104
+ }
105
+ return name;
106
+ }
107
+
108
+ /**
109
+ * The consumer snippets for the MCP key — the SAME shape the web UI renders
110
+ * (src/components/credentials/SetupSnippet.tsx): a `.mcp.json` block and a
111
+ * `.codex/config.toml` block that reference `$envVar` (never the raw secret), and
112
+ * the macOS Keychain store-once command. Kept in sync by tests on both sides.
113
+ */
114
+ export function mcpKeySnippets(envVar, { mcpUrl = SERVER_URL, loaderOnly = false } = {}) {
115
+ const mcpJson = JSON.stringify(
116
+ {
117
+ mcpServers: {
118
+ botbuddy: {
119
+ type: "http",
120
+ url: mcpUrl,
121
+ headers: { Authorization: `Bearer \${${envVar}}` },
122
+ },
123
+ },
124
+ },
125
+ null,
126
+ 2,
127
+ );
128
+ // Codex does NOT interpolate ${VAR} inside a `headers` table (unlike Claude's
129
+ // .mcp.json above) — a literal `Bearer ${BOTBUDDY_MCP_KEY}` would be sent. Its
130
+ // supported env-backed auth is `bearer_token_env_var = "<NAME>"` (the same
131
+ // setting .codex/config.toml uses for the botbuddy server), so emit that.
132
+ const codexToml = `[mcp_servers.botbuddy]\ntype = "http"\nurl = "${mcpUrl}"\nbearer_token_env_var = "${envVar}"`;
133
+ // Storing the secret and EXPORTING it are two separate steps (README.md /
134
+ // AGENTS.md). Both client configs above read $envVar at launch, so the snippet
135
+ // must emit the Keychain-to-environment loader too — otherwise the operator
136
+ // stores the key, then launches Claude/Codex with $envVar unset (Codex P1).
137
+ // Mirrors the web keychain tab (SetupSnippet.tsx). The service name IS the var.
138
+ const load = `export ${envVar}="$(security find-generic-password -w -a "$USER" -s ${envVar})"`;
139
+ // loaderOnly (Codex P2): after a successful `mcp setup`, the key is ALREADY in
140
+ // the Keychain and its plaintext is intentionally not echoed — so the receipt
141
+ // must NOT show a `security add-generic-password -w` store prompt (the user has
142
+ // nothing to paste and could overwrite the valid key with garbage). Emit only
143
+ // the loader. The store+load pair is for the minted_no_keychain path, where the
144
+ // raw key is returned for the operator to place themselves.
145
+ const keychain = loaderOnly
146
+ ? `# Already stored in your Keychain by \`botbuddy mcp setup\` — load it into each shell:\n${load}`
147
+ : `# 1) Store once — prompted; nothing hits shell history\n`
148
+ + `security add-generic-password -U -a "$USER" -s ${envVar} -T /usr/bin/security -w\n\n`
149
+ + `# 2) Every subsequent shell (and the MCP client it launches) reads it back\n${load}`;
150
+ return { mcp_json: mcpJson, codex_toml: codexToml, keychain };
151
+ }
152
+
153
+ /**
154
+ * BOT-1607 AC5: resolve the MCP config key from the environment. Prefers
155
+ * BOTBUDDY_MCP_KEY; falls back to the deprecated BOTBUDDY_BB_AGENT_KEY alias with
156
+ * a migration notice. Never logs the key itself — only which var carries it.
157
+ */
158
+ export function resolveMcpConfigKey(env = process.env, { envVar = null, explicit = false } = {}) {
159
+ const legacyNotice =
160
+ `${LEGACY_MCP_ENV_VAR} is a deprecated alias for ${DEFAULT_MCP_ENV_VAR} and will stop being read in a future release. `
161
+ + `Run \`botbuddy mcp setup\` to mint a dedicated ${DEFAULT_MCP_ENV_VAR} and update your .mcp.json.`;
162
+
163
+ // BOT-1607 (Codex P2): when `--env <NAME>` is EXPLICITLY supplied — a canonical
164
+ // name (default OR legacy alias) or a custom one — inspect exactly that
165
+ // variable, never the default-first fallback. Otherwise `status --env
166
+ // BOTBUDDY_BB_AGENT_KEY` (unset) could report BOTBUDDY_MCP_KEY, and vice versa.
167
+ if (explicit && envVar) {
168
+ const val = env?.[envVar];
169
+ if (typeof val === "string" && val) {
170
+ const deprecated = envVar === LEGACY_MCP_ENV_VAR;
171
+ return { envVar, key: val, deprecated, ...(deprecated ? { notice: legacyNotice } : {}) };
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // No explicit selector: default → legacy-alias precedence.
177
+ const primary = env?.[DEFAULT_MCP_ENV_VAR];
178
+ if (typeof primary === "string" && primary) {
179
+ return { envVar: DEFAULT_MCP_ENV_VAR, key: primary, deprecated: false };
180
+ }
181
+ const legacy = env?.[LEGACY_MCP_ENV_VAR];
182
+ if (typeof legacy === "string" && legacy) {
183
+ return { envVar: LEGACY_MCP_ENV_VAR, key: legacy, deprecated: true, notice: legacyNotice };
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // Map a mint_mcp_key server refusal code to a CLI error code the receipt reports.
189
+ function mapMintCode(code) {
190
+ if (code === "MCP_TENANT_PIN_REQUIRED" || code === "MCP_TENANT_SELECTION_REQUIRED") return "tenant_pin_required";
191
+ if (code === "MCP_TENANT_MEMBERSHIP_REQUIRED") return "tenant_forbidden";
192
+ if (code === "mcp_key_owner_required") return "owner_required";
193
+ if (code === "owner_auth_required") return "not_authenticated";
194
+ return code || "mint_failed";
195
+ }
196
+
197
+ /**
198
+ * Mint a `bb_mcp_` key, authenticated by the tier-1 owner/client credential, and
199
+ * store it in the Keychain under `envVar` (read-back verified). On a host without
200
+ * a Keychain (BOTBUDDY_NO_KEYCHAIN / non-darwin) it is NOT persisted — the raw
201
+ * key is returned so the caller can place it in a secret store (CI should prefer
202
+ * a `bb_ci_` key, not a planted `bb_mcp_`).
203
+ */
204
+ export async function setupMcpKey({
205
+ envVar = DEFAULT_MCP_ENV_VAR,
206
+ tenant = null,
207
+ label = null,
208
+ expiryDays = null,
209
+ force = false,
210
+ call = callToolJson,
211
+ resolveAuth = resolveCallAuth,
212
+ keychainAvailable: keychainAvail = keychainAvailable,
213
+ writeKeychain = writeKeychainSecret,
214
+ readKeychain = readKeychainSecret,
215
+ mcpUrl = SERVER_URL,
216
+ } = {}) {
217
+ const pinned = await resolveAuth();
218
+ if (pinned?.error) throw new McpKeyError("not_authenticated");
219
+ const auth = pinned?.auth ?? null;
220
+
221
+ // Codex P1/P2: refuse to mint into an OCCUPIED Keychain slot unless --force.
222
+ // Re-running setup would `security -U`-overwrite the local secret AFTER a
223
+ // second server credential is minted, silently orphaning the previous, still
224
+ // ACTIVE bb_mcp_ row (its plaintext + agent_id no longer on this machine).
225
+ // Check BEFORE minting so a collision creates no new credential — and fail
226
+ // CLOSED: a STRICT read distinguishes a genuinely-empty slot from a transient
227
+ // read failure (a swallowed failure would look empty and overwrite a live key).
228
+ if (!force && keychainAvail()) {
229
+ let existing = null;
230
+ try {
231
+ existing = await readKeychain(envVar, { strict: true });
232
+ } catch (error) {
233
+ throw new McpKeyError("keychain_read_failed", { detail: error?.message ?? String(error) });
234
+ }
235
+ if (typeof existing === "string" && existing) {
236
+ throw new McpKeyError("env_var_in_use", { detail: envVar });
237
+ }
238
+ }
239
+
240
+ const args = {
241
+ ...(label ? { label } : {}),
242
+ ...(expiryDays != null ? { expiry_days: expiryDays } : {}),
243
+ };
244
+ const response = await call("mint_mcp_key", args, { auth, ...(tenant ? { tenant } : {}) });
245
+
246
+ const code = structuredCode(response);
247
+ // A 401/403 that carries a STRUCTURED tenant code (pin/membership) is not a
248
+ // credential failure — the login is valid, the tenant is wrong. Map it so the
249
+ // recovery is accurate; only a code-less refusal is a true auth failure (Codex P2).
250
+ if (response?.auth || (response?.ok === false && response?.error === "unauthorized")) {
251
+ if (code) throw new McpKeyError(mapMintCode(code));
252
+ throw new McpKeyError("not_authenticated");
253
+ }
254
+ if (!response?.ok || response.isError || response.data?.ok === false) {
255
+ throw new McpKeyError(mapMintCode(code));
256
+ }
257
+ const key = response.data?.api_key;
258
+ const agentId = response.data?.agent_id;
259
+ const tenantId = response.data?.tenant_id ?? tenant ?? null;
260
+ if (typeof key !== "string" || !key.startsWith("bb_mcp_") || typeof agentId !== "string") {
261
+ throw new McpKeyError("mint_failed");
262
+ }
263
+
264
+ let stored = false;
265
+ if (keychainAvail()) {
266
+ try {
267
+ // createOnly when !force makes the write itself the atomic guard: if the
268
+ // slot filled between the preflight read and here, `security` (no -U) fails
269
+ // "already exists" and we roll the just-minted key back below (Codex P2).
270
+ await writeKeychain(envVar, key, { createOnly: !force });
271
+ stored = true;
272
+ } catch (error) {
273
+ // AC2: a read-back mismatch / locked Keychain must fail LOUDLY — the key
274
+ // was minted, so never report success with an unverified store.
275
+ // Codex P2: the server already persisted the key. An un-stored key is an
276
+ // orphan the operator can't see to revoke, and every retry would mint
277
+ // another live row — so roll it back (best-effort) before failing. If the
278
+ // rollback itself fails, surface the agent_id so it can be revoked by hand.
279
+ let rolledBack = false;
280
+ try {
281
+ const undo = await call("revoke_mcp_key", { agent_id: agentId }, { auth, ...(tenant ? { tenant } : {}) });
282
+ rolledBack = Boolean(undo?.ok && !undo.isError && undo.data?.ok !== false);
283
+ } catch {
284
+ // keep rolledBack=false — the message tells the operator to revoke it.
285
+ }
286
+ // Codex P2: a create-only write can fail its read-back verification AFTER
287
+ // the item was created, leaving a stale item the strict occupied-slot
288
+ // preflight would then reject on retry — so always spell out clearing it.
289
+ const clearHint = `if a stale item remains under ${envVar}, clear it first with \`security delete-generic-password -s ${envVar}\`, then `;
290
+ const tail = rolledBack
291
+ ? `the freshly minted key was rolled back — unlock the Keychain, ${clearHint}re-run \`botbuddy mcp setup\`.`
292
+ : `the minted key could NOT be rolled back — revoke it with \`botbuddy mcp revoke ${agentId} --env ${envVar}\`, ${clearHint}re-run setup.`;
293
+ throw new McpKeyStoreError(
294
+ `minted the MCP key but could not store it in the macOS Keychain under ${envVar}: ${error?.message ?? error}; ${tail}`,
295
+ { cause: error, agentId, rolledBack },
296
+ );
297
+ }
298
+ }
299
+
300
+ return {
301
+ schema_version: 1,
302
+ outcome: stored ? "installed" : "minted_no_keychain",
303
+ // Codex P1: storing ≠ exporting. When stored in the Keychain, the key is not
304
+ // yet in the environment the MCP client reads — spell out the loader so the
305
+ // operator doesn't launch Claude/Codex with $envVar unset.
306
+ ...(stored
307
+ ? { next_step: `Load it into your shell (and the MCP client it launches): export ${envVar}="$(security find-generic-password -w -a "$USER" -s ${envVar})" — see snippets.keychain for the store+load pair.` }
308
+ : {}),
309
+ env_var: envVar,
310
+ key_prefix: "bb_mcp_",
311
+ agent_id: agentId,
312
+ tenant_id: tenantId,
313
+ // Only surface the raw key when it could NOT be stored (headless/CI), so the
314
+ // operator can place it themselves. On a Keychain host it is never echoed.
315
+ ...(stored ? {} : { api_key: key }),
316
+ // When stored, the snippet is loader-only (no store prompt for a key the user
317
+ // no longer has); otherwise it carries the store+load pair (Codex P2).
318
+ snippets: mcpKeySnippets(envVar, { mcpUrl, loaderOnly: stored }),
319
+ };
320
+ }
321
+
322
+ // Map a revoke_mcp_key server refusal to a CLI error code.
323
+ function mapRevokeCode(code) {
324
+ if (code === "MCP_TENANT_PIN_REQUIRED" || code === "MCP_TENANT_SELECTION_REQUIRED") return "tenant_pin_required";
325
+ if (code === "MCP_TENANT_MEMBERSHIP_REQUIRED") return "tenant_forbidden";
326
+ if (code === "mcp_key_owner_required") return "owner_required";
327
+ if (code === "owner_auth_required") return "not_authenticated";
328
+ if (code === "mcp_key_wrong_kind") return "not_an_mcp_key";
329
+ if (code === "mcp_key_not_found") return "not_found";
330
+ if (code === "mcp_key_forbidden") return "forbidden";
331
+ return code || "revoke_failed";
332
+ }
333
+
334
+ /**
335
+ * Revoke a `bb_mcp_` key by its agent_id via the owner/client credential.
336
+ *
337
+ * The server binds revocation to the connection's authenticated tenant (BOT-1607
338
+ * Codex P1), so a multi-tenant owner MUST pin the tenant the key lives in — the
339
+ * same tenant `mcp setup --tenant` minted it for. Without `tenant` the call rides
340
+ * the repo profile's default pin and the server refuses a cross-tenant revoke,
341
+ * leaving the key live. Forward `--tenant` so `mcp revoke --tenant <slug>` works.
342
+ */
343
+ export async function revokeMcpKey({
344
+ agentId,
345
+ tenant = null,
346
+ envVar = null,
347
+ call = callToolJson,
348
+ resolveAuth = resolveCallAuth,
349
+ } = {}) {
350
+ if (typeof agentId !== "string" || !agentId.trim()) throw new McpKeyError("agent_id_required");
351
+ const id = agentId.trim();
352
+ const pinned = await resolveAuth();
353
+ if (pinned?.error) throw new McpKeyError("not_authenticated");
354
+ const auth = pinned?.auth ?? null;
355
+
356
+ const response = await call("revoke_mcp_key", { agent_id: id }, { auth, ...(tenant ? { tenant } : {}) });
357
+ const code = structuredCode(response);
358
+ // A structured tenant code (pin/membership) on a 401/403 is a tenant mismatch,
359
+ // not a credential failure — map it so recovery is accurate (Codex P2).
360
+ if (response?.auth || (response?.ok === false && response?.error === "unauthorized")) {
361
+ if (code) throw new McpKeyError(mapRevokeCode(code));
362
+ throw new McpKeyError("not_authenticated");
363
+ }
364
+ if (!response?.ok || response.isError || response.data?.ok === false) {
365
+ throw new McpKeyError(mapRevokeCode(code));
366
+ }
367
+
368
+ // The server key is now dead. The CLI does NOT delete the local Keychain slot
369
+ // itself (BOT-1607, Codex rounds 5–11): revoke is keyed by agent_id, which
370
+ // cannot be safely mapped to a machine Keychain slot — `--env` accepts any
371
+ // service name (a typo could name an unrelated secret), the probe/delete cannot
372
+ // be made atomic against a concurrent `mcp setup` (no OS compare-and-delete),
373
+ // and macOS offers no per-service cross-process lock. So we always hand back the
374
+ // exact, side-effect-free deletion command instead of deleting anything.
375
+ const slot = envVar || "<NAME>";
376
+ return {
377
+ schema_version: 1,
378
+ outcome: "revoked",
379
+ agent_id: id,
380
+ ...(envVar ? { env_var: envVar } : {}),
381
+ next_step:
382
+ `The server key is revoked. If a local Keychain slot still holds it, the next \`mcp setup\` will refuse that slot — `
383
+ + `clear it yourself with \`security delete-generic-password -s ${slot}\`.`,
384
+ };
385
+ }