@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.
@@ -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
+ }
@@ -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-1566: `login --tenant <slug>` preselects the tenant. /authorize forwards
93
- // it to the /mcp-auth picker as `requestedTenant`; membership is still
94
- // enforced server-side at authorize_complete this only preselects.
95
- if (tenant) url.searchParams.set("tenant", tenant);
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
- return tenant
215
- ? `run botbuddy login and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName} (if you are not a member of "${tenant}", ask an admin for access)`
216
- : `run botbuddy login and select the profile's tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
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
- --profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
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
- REQUIRED for any non-timer wait (a wait must name the work agent that armed it).
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
- else if (a === "--token") opts.token = optionValue();
195
- else if (a === "--session-token") opts.sessionToken = optionValue();
196
- else if (a === "--profile") opts.profile = optionValue();
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.