@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 +1 -1
- package/src/commands.mjs +236 -0
- package/src/credential-kinds.mjs +3 -0
- package/src/mcp-key.mjs +385 -0
package/package.json
CHANGED
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] });
|
package/src/credential-kinds.mjs
CHANGED
|
@@ -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/mcp-key.mjs
ADDED
|
@@ -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
|
+
}
|