@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/package.json +1 -1
- package/src/api.mjs +78 -17
- package/src/auth.mjs +12 -1
- package/src/cli-credentials.mjs +44 -11
- package/src/commands.mjs +389 -39
- package/src/credential-kinds.mjs +3 -0
- package/src/discovery.mjs +14 -1
- package/src/mcp-key.mjs +385 -0
- package/src/oauth-loopback.mjs +12 -4
- package/src/profile-bootstrap.mjs +16 -3
- package/src/wait.mjs +60 -6
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
|
+
}
|
package/src/oauth-loopback.mjs
CHANGED
|
@@ -89,10 +89,18 @@ export function buildAuthorizeUrl({
|
|
|
89
89
|
url.searchParams.set("state", state);
|
|
90
90
|
url.searchParams.set("code_challenge", codeChallenge);
|
|
91
91
|
url.searchParams.set("code_challenge_method", "S256");
|
|
92
|
-
// BOT-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
92
|
+
// BOT-1571: two OAuth modes. A plain `login` asks for a USER token
|
|
93
|
+
// (`tenant_mode=user`): not bound to any tenant, so one login serves every
|
|
94
|
+
// `profile setup`, each of which pins its own tenant per request. `login
|
|
95
|
+
// --tenant <slug>` asks for a TENANT token SEALED to that slug
|
|
96
|
+
// (`tenant_mode=tenant` + `tenant=` — membership is still enforced
|
|
97
|
+
// server-side at authorize_complete).
|
|
98
|
+
if (tenant) {
|
|
99
|
+
url.searchParams.set("tenant_mode", "tenant");
|
|
100
|
+
url.searchParams.set("tenant", tenant);
|
|
101
|
+
} else {
|
|
102
|
+
url.searchParams.set("tenant_mode", "user");
|
|
103
|
+
}
|
|
96
104
|
// BOT-1580: display-only caller identity, so the human approving the login can
|
|
97
105
|
// check it against the agent session they are actually running. These are
|
|
98
106
|
// UNVERIFIED hints — /authorize only forwards them to the consent screen; they
|
|
@@ -210,10 +210,23 @@ export function profileBootstrapRecovery(code, profileName) {
|
|
|
210
210
|
// tenant-bound token can't be repointed) or the owner isn't a member of the
|
|
211
211
|
// profile's tenant. The fix is to authenticate INTO the profile's tenant —
|
|
212
212
|
// not the bare login loop, which relogs into the same wrong tenant.
|
|
213
|
+
// BOT-1571: a plain `botbuddy login` (no --tenant) now mints a USER token
|
|
214
|
+
// that reaches every tenant the owner belongs to, so the fix is that — not
|
|
215
|
+
// "log in again and pick this tenant", which is exactly the per-tenant
|
|
216
|
+
// re-login friction BOT-1571 removes.
|
|
217
|
+
// BOT-1571 (Codex follow-up): MCP_TENANT_MEMBERSHIP_REQUIRED conflates two
|
|
218
|
+
// causes and the CLI cannot tell them apart from the code alone — the owner
|
|
219
|
+
// is not a member of the tenant, OR a `--tenant`-sealed login is pinned to a
|
|
220
|
+
// different one. Lead with the membership case and say re-login will NOT fix
|
|
221
|
+
// it (a user token still can't reach a tenant you don't belong to), so a
|
|
222
|
+
// non-member is not sent through a no-op `botbuddy login` loop; offer the
|
|
223
|
+
// re-login only for the genuinely-sealed case.
|
|
213
224
|
const tenant = getAgentProfile(profileName)?.tenant;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
const forTenant = tenant ? ` tenant "${tenant}"` : " this profile's tenant";
|
|
226
|
+
const memberHint = tenant
|
|
227
|
+
? `if you are not a member of "${tenant}", ask an admin to add you — a re-login will not help`
|
|
228
|
+
: "if you are not a member of it, ask an admin to add you — a re-login will not help";
|
|
229
|
+
return `your login is not authorized for${forTenant}: ${memberHint}. If instead your login is sealed to a different tenant (you signed in with --tenant), run botbuddy login (without --tenant) to get a user token that reaches all your tenants, then re-run: botbuddy profile setup ${profileName}`;
|
|
217
230
|
}
|
|
218
231
|
if (code === "profile_tenant_unverified") {
|
|
219
232
|
// whoami could not confirm the bound tenant, so registration was refused
|
package/src/wait.mjs
CHANGED
|
@@ -120,12 +120,23 @@ OPTIONS
|
|
|
120
120
|
--receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
|
|
121
121
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
122
122
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
123
|
-
--
|
|
123
|
+
--agent-key <token> the bb_agent_ session token to authenticate this wait — a one-liner
|
|
124
|
+
equivalent to exporting $BOTBUDDY_AGENT_KEY (which is the default).
|
|
125
|
+
Only accepts a session token; a client/profile key is refused.
|
|
126
|
+
(--session-token is the one-release legacy alias.)
|
|
124
127
|
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
|
|
125
|
-
|
|
126
|
-
--token <key> explicit agent key override; otherwise the profile-specific env is used
|
|
128
|
+
Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
|
|
127
129
|
--help show this help
|
|
128
130
|
|
|
131
|
+
AUTH
|
|
132
|
+
A wait authenticates from the bb_agent_ session token (minted by register_agent):
|
|
133
|
+
export $BOTBUDDY_AGENT_KEY, or pass it inline with --agent-key for a one-liner
|
|
134
|
+
($BOTBUDDY_SESSION_TOKEN / --session-token still accepted for one release).
|
|
135
|
+
--token and --profile are RETIRED (BOT-1574): they were untyped and could carry a
|
|
136
|
+
machine credential; use --agent-key, which only accepts a session token. The
|
|
137
|
+
per-machine client key (botbuddy login) is a setup credential, never a wait
|
|
138
|
+
credential.
|
|
139
|
+
|
|
129
140
|
OUTPUT
|
|
130
141
|
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
131
142
|
protocol identity. Diagnostics go to stderr.
|
|
@@ -191,9 +202,19 @@ function parseArgv(argv) {
|
|
|
191
202
|
else if (a === "--since") opts.since = optionValue();
|
|
192
203
|
else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
|
|
193
204
|
else if (a === "--url") opts.url = optionValue();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
// BOT-1574: --token and --profile are RETIRED. A wait authenticates from
|
|
206
|
+
// $BOTBUDDY_SESSION_TOKEN (minted by register_agent) alone; the per-machine
|
|
207
|
+
// client key (`botbuddy login`) is never a wait credential. Consume any
|
|
208
|
+
// following value so it is not mis-parsed as a condition, then fail fast in
|
|
209
|
+
// runWait with a migration hint (AC3).
|
|
210
|
+
else if (a === "--token" || a === "--profile") { opts.retiredFlag ??= a; optionValue(); }
|
|
211
|
+
// BOT-1574: --agent-key is the typed one-liner for the wait credential — it
|
|
212
|
+
// carries the bb_agent_ session token (equivalent to exporting
|
|
213
|
+
// $BOTBUDDY_AGENT_KEY), and NOTHING else: a client key / profile key is the
|
|
214
|
+
// wrong shape and is refused invalid_session_token, so it can never smuggle a
|
|
215
|
+
// machine credential onto a wait. --session-token is the one-release legacy
|
|
216
|
+
// alias for the same value.
|
|
217
|
+
else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
|
|
197
218
|
else if (a === "--session-id") opts.sessionId = optionValue();
|
|
198
219
|
else if (a.startsWith("--")) opts.unknown = a;
|
|
199
220
|
else opts.conditions.push(a);
|
|
@@ -836,6 +857,23 @@ export async function runWait(argv) {
|
|
|
836
857
|
process.stdout.write(HELP);
|
|
837
858
|
process.exit(0);
|
|
838
859
|
}
|
|
860
|
+
// BOT-1574 (AC3): --token / --profile are retired. Emit the migration hint and
|
|
861
|
+
// a typed `unknown_option` receipt, then exit 4 — before any relay call.
|
|
862
|
+
if (opts.retiredFlag) {
|
|
863
|
+
process.stderr.write(
|
|
864
|
+
`botbuddy wait: ${opts.retiredFlag} is retired (it could carry a machine credential) — a wait authenticates from the bb_agent_ session token. `
|
|
865
|
+
+ "Pass it typed with --agent-key <token>, or export $BOTBUDDY_AGENT_KEY (from register_agent), then: botbuddy wait '<condition>'. "
|
|
866
|
+
+ "The per-machine client key (botbuddy login) is never a wait credential.\n",
|
|
867
|
+
);
|
|
868
|
+
emitReceipt({
|
|
869
|
+
schema_version: 1,
|
|
870
|
+
outcome: "error",
|
|
871
|
+
error: "unknown_option",
|
|
872
|
+
option: opts.retiredFlag,
|
|
873
|
+
recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
|
|
874
|
+
});
|
|
875
|
+
process.exit(EXIT.INVALID);
|
|
876
|
+
}
|
|
839
877
|
if (opts.unknown) {
|
|
840
878
|
process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
|
|
841
879
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
|
|
@@ -1045,6 +1083,22 @@ export async function runWait(argv) {
|
|
|
1045
1083
|
process.exit(EXIT.INVALID);
|
|
1046
1084
|
}
|
|
1047
1085
|
if (err && err.auth) {
|
|
1086
|
+
// BOT-1574 (AC4): the relay refused a wait armed by the per-machine client
|
|
1087
|
+
// key (or any owner/setup credential). The fix is never profile setup —
|
|
1088
|
+
// register a work agent and export its session token. Handle it before the
|
|
1089
|
+
// profile-recovery branches, which would dereference an absent agentProfile.
|
|
1090
|
+
if (err.errorCode === "client_key_cannot_wait") {
|
|
1091
|
+
process.stderr.write(
|
|
1092
|
+
"botbuddy wait: a client key (botbuddy login) cannot arm a wait — register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
|
|
1093
|
+
);
|
|
1094
|
+
emitReceipt({
|
|
1095
|
+
schema_version: 1,
|
|
1096
|
+
outcome: "error",
|
|
1097
|
+
error: "client_key_cannot_wait",
|
|
1098
|
+
recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
|
|
1099
|
+
});
|
|
1100
|
+
process.exit(EXIT.AUTH);
|
|
1101
|
+
}
|
|
1048
1102
|
// BOT-1572: a session-token failure (revoked/expired/rotated, or the relay
|
|
1049
1103
|
// refusing to honour the token) is fixed by RE-REGISTERING, not by profile
|
|
1050
1104
|
// setup. Lead with that, and never touch the (absent) agentProfile.
|