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