@botbuddy/cli 1.25.0 → 1.27.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 +2 -1
- package/src/agent-credential-store.mjs +11 -181
- package/src/api.mjs +8 -8
- package/src/auth.mjs +1 -1
- package/src/cli-credentials.mjs +11 -6
- package/src/codex-bridge.mjs +3 -3
- package/src/commands.mjs +94 -140
- package/src/config.mjs +1 -1
- package/src/mcp-key.mjs +23 -22
- package/src/pw/coordinator.mjs +0 -5
- package/src/pw/run.mjs +32 -32
- package/src/run.mjs +165 -10
- package/src/stack.mjs +3 -3
- package/src/telemetry-config.mjs +109 -0
- package/src/telemetry-delivery.mjs +40 -0
- package/src/telemetry-outbox.mjs +327 -0
- package/src/wait-profile.mjs +128 -79
- package/src/wait.mjs +88 -226
- package/src/profile-bootstrap.mjs +0 -252
package/src/commands.mjs
CHANGED
|
@@ -14,17 +14,20 @@ import { cmdTest } from "./test-lane.mjs";
|
|
|
14
14
|
import { runWait } from "./wait.mjs";
|
|
15
15
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
16
16
|
import { VERSION } from "./version.mjs";
|
|
17
|
-
import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
|
|
18
17
|
import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFAULT_MCP_ENV_VAR, McpKeyError, McpKeyStoreError } from "./mcp-key.mjs";
|
|
18
|
+
import { readAgentBinding } from "./wait-profile.mjs";
|
|
19
19
|
import { runPw } from "./pw/run.mjs";
|
|
20
20
|
import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
|
|
21
|
+
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
22
|
+
import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
|
|
23
|
+
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
21
24
|
|
|
22
25
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
23
26
|
// invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
|
|
24
27
|
// must not pay for a registry round-trip.
|
|
25
28
|
export function shouldCheckForUpdates(argv) {
|
|
26
29
|
const [command] = argv;
|
|
27
|
-
if (command === "wait") return false;
|
|
30
|
+
if (command === "wait" || command === "telemetry") return false;
|
|
28
31
|
if (argv.includes("--json")) return false;
|
|
29
32
|
return true;
|
|
30
33
|
}
|
|
@@ -53,11 +56,10 @@ export async function run(argv, {
|
|
|
53
56
|
case "stack": return cmdStack(args);
|
|
54
57
|
case "docker": return cmdDocker(args);
|
|
55
58
|
case "run": return cmdRun(args);
|
|
59
|
+
case "telemetry": return cmdTelemetry(args);
|
|
56
60
|
case "test": return cmdTest(args);
|
|
57
61
|
case "wait": return runWait(args);
|
|
58
62
|
case "pw": return runPw(args);
|
|
59
|
-
case "profile": return cmdProfile(args);
|
|
60
|
-
case "carrier": return cmdCarrier(args);
|
|
61
63
|
case "mcp": return cmdMcp(args);
|
|
62
64
|
case "resources": return callTool("list_resources");
|
|
63
65
|
case "agents": return callTool("list_agents");
|
|
@@ -82,6 +84,36 @@ export async function run(argv, {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
async function cmdTelemetry(args) {
|
|
88
|
+
const subcommand = args[0] ?? "help";
|
|
89
|
+
if (!new Set(["status", "replay", "doctor", "help", "--help"]).has(subcommand) || args.length > 1) {
|
|
90
|
+
die("Usage: botbuddy telemetry <status|replay|doctor>");
|
|
91
|
+
}
|
|
92
|
+
if (subcommand === "help" || subcommand === "--help") {
|
|
93
|
+
console.log("botbuddy telemetry status\nbotbuddy telemetry replay\nbotbuddy telemetry doctor");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let location;
|
|
97
|
+
try { location = await loadTelemetryLocation(process.cwd()); }
|
|
98
|
+
catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
99
|
+
const outbox = await createTelemetryOutbox({ tenant: location.tenant, repository: location.repository, producerVersion: VERSION });
|
|
100
|
+
if (subcommand === "replay") {
|
|
101
|
+
let identity;
|
|
102
|
+
try {
|
|
103
|
+
identity = await loadTelemetryIdentity(process.cwd());
|
|
104
|
+
await attestTelemetryCredential(identity, outbox);
|
|
105
|
+
} catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
106
|
+
await outbox.importLegacyTestRunReceipts(".botbuddy/test-runs");
|
|
107
|
+
const receipt = await outbox.replay((event) => deliverExecutionEvent(event, { credential: identity.credential }));
|
|
108
|
+
console.log(JSON.stringify(receipt));
|
|
109
|
+
return receipt.remaining === 0 ? 0 : 5;
|
|
110
|
+
}
|
|
111
|
+
const status = await outbox.status();
|
|
112
|
+
console.log(JSON.stringify(status));
|
|
113
|
+
if (subcommand === "doctor") return status.queued_count === 0 && !status.last_error_class ? 0 : 5;
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
function cmdHelp() {
|
|
86
118
|
console.log(`${bold("bb")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
|
|
87
119
|
|
|
@@ -102,8 +134,6 @@ ${bold("AUTH")}
|
|
|
102
134
|
Authenticate via OAuth (opens browser + localhost callback)
|
|
103
135
|
logout Remove saved credentials
|
|
104
136
|
status Show current auth status (local metadata + server check)
|
|
105
|
-
carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
|
|
106
|
-
(interactive operators use ${cyan("login")} — it installs the client key)
|
|
107
137
|
|
|
108
138
|
${bold("MCP CONFIG KEY")}
|
|
109
139
|
mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
|
|
@@ -111,6 +141,7 @@ ${bold("MCP CONFIG KEY")}
|
|
|
111
141
|
revocable; stored under ${dim("BOTBUDDY_MCP_KEY")})
|
|
112
142
|
mcp revoke <agent_id> Revoke only that MCP key (login + sessions unaffected)
|
|
113
143
|
mcp status Which env var holds the MCP key (never prints it)
|
|
144
|
+
mcp env Print the shell loader for the MCP key ($BOTBUDDY_MCP_KEY)
|
|
114
145
|
|
|
115
146
|
${bold("TOOLS")}
|
|
116
147
|
help --tools List every BotBuddy tool
|
|
@@ -135,6 +166,10 @@ ${bold("DOCKER HYGIENE")}
|
|
|
135
166
|
${bold("DURABLE WORKLOADS")}
|
|
136
167
|
run --session-id <id> --environment <env> -- <command>
|
|
137
168
|
Launch a receipt-bearing command under a detached owner
|
|
169
|
+
run --foreground --kind <kind> --environment <env> -- <command>
|
|
170
|
+
Run locally with a durable lifecycle outbox
|
|
171
|
+
telemetry <status|replay|doctor>
|
|
172
|
+
Inspect, replay, or gate durable lifecycle delivery
|
|
138
173
|
|
|
139
174
|
${bold("AGENT WAITS")}
|
|
140
175
|
wait [--any] <condition>... [options]
|
|
@@ -301,8 +336,9 @@ ${bold("TOKEN SCOPE")}
|
|
|
301
336
|
By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
|
|
302
337
|
token that is not bound to a tenant and reaches every tenant you belong to —
|
|
303
338
|
each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
|
|
304
|
-
exchanges it for a per-session agent token. No per-tenant
|
|
305
|
-
|
|
339
|
+
exchanges it for a per-session agent token. No per-tenant credential is needed
|
|
340
|
+
for normal use — mint a ${bold("bb_mcp_")} key per tenant (${cyan("botbuddy mcp setup")}) only
|
|
341
|
+
when a repo binds a distinct ${dim("mcp_env")}.
|
|
306
342
|
${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
|
|
307
343
|
tenant (the MCP-session model); use it only when you want that guarantee.
|
|
308
344
|
|
|
@@ -353,7 +389,8 @@ ${bold("NOTES")}
|
|
|
353
389
|
|
|
354
390
|
${bold("RECOVERY")}
|
|
355
391
|
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
356
|
-
unattended CI host (no browser),
|
|
392
|
+
unattended CI host (no browser), mint a key on a machine with a browser
|
|
393
|
+
(${cyan("botbuddy mcp setup")}) and export it as ${cyan("$BOTBUDDY_MCP_KEY")} on the CI host.`);
|
|
357
394
|
}
|
|
358
395
|
|
|
359
396
|
async function cmdStart(args) {
|
|
@@ -556,143 +593,14 @@ async function cmdLogout() {
|
|
|
556
593
|
console.log(`${green("✓")} Logged out. Credentials removed.`);
|
|
557
594
|
}
|
|
558
595
|
|
|
559
|
-
async function cmdAgentAuth(args) {
|
|
560
|
-
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
561
|
-
console.log(`Usage: botbuddy auth <login|status|logout> [--profile <name>] [--token <key>]
|
|
562
|
-
|
|
563
|
-
auth login Store a tenant-bound agent credential in the OS keyring. On macOS,
|
|
564
|
-
it imports the existing launchd credential once when no --token or
|
|
565
|
-
profile-specific environment key is supplied.
|
|
566
|
-
auth status Show whether the profile has a stored key (never prints it).
|
|
567
|
-
auth logout Remove the profile's stored key.`);
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
let options;
|
|
571
|
-
try {
|
|
572
|
-
options = parseAgentAuthArgs(args);
|
|
573
|
-
} catch (error) {
|
|
574
|
-
die(error.message);
|
|
575
|
-
}
|
|
576
|
-
const profile = options.profile || await findProfileName(process.cwd());
|
|
577
|
-
if (!profile || !getProfileDefinition(profile)) {
|
|
578
|
-
die("No supported BotBuddy agent profile found. Add .botbuddy-agent.json or pass --profile <name>.");
|
|
579
|
-
}
|
|
580
|
-
if (options.action === "login") {
|
|
581
|
-
const token = await resolveLoginToken({ profile, explicitToken: options.token });
|
|
582
|
-
const result = await loginAgentCredential({ profile, token });
|
|
583
|
-
console.log(`${green("✓")} Stored ${result.profile} (${result.tenant}) agent credential in the OS keyring.`);
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
if (options.action === "status") {
|
|
587
|
-
const result = await getAgentAuthStatus({ profile });
|
|
588
|
-
console.log(result.authenticated
|
|
589
|
-
? `${green("✓")} ${result.profile} (${result.tenant}) machine credential is stored in the OS keyring.`
|
|
590
|
-
: `${red("✗")} ${result.profile} (${result.tenant}) has no stored machine credential.`);
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
const result = await logoutAgentCredential({ profile });
|
|
594
|
-
console.log(`${green("✓")} ${result.profile} (${result.tenant}) machine credential ${result.removed ? "removed" : "was not present"}.`);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
596
|
function cmdHeartbeat(args) {
|
|
598
597
|
return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
|
|
599
598
|
}
|
|
600
599
|
|
|
601
|
-
// BOT-1574 AC6: the carrier bootstrap that `profile setup --unattended` and
|
|
602
|
-
// `carrier setup` share. Never gated on a TTY — the caller decides whether the
|
|
603
|
-
// interactive guard applies before reaching here.
|
|
604
|
-
async function runCarrierSetup(profile, { log, setExitCode, bootstrap }) {
|
|
605
|
-
try {
|
|
606
|
-
const receipt = await bootstrap(profile, { call: callToolJson });
|
|
607
|
-
log(JSON.stringify(receipt));
|
|
608
|
-
} catch (error) {
|
|
609
|
-
const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
|
|
610
|
-
log(JSON.stringify({
|
|
611
|
-
schema_version: 1,
|
|
612
|
-
outcome: "error",
|
|
613
|
-
error: code,
|
|
614
|
-
recovery: profileBootstrapRecovery(code, profile),
|
|
615
|
-
}));
|
|
616
|
-
setExitCode(3);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
export async function cmdProfile(args, {
|
|
621
|
-
isTTY = Boolean(process.stdout.isTTY),
|
|
622
|
-
log = (line) => console.log(line),
|
|
623
|
-
setExitCode = (n) => { process.exitCode = n; },
|
|
624
|
-
bootstrap = bootstrapProfile,
|
|
625
|
-
} = {}) {
|
|
626
|
-
if (args[0] === "--help" || args[0] === "-h") {
|
|
627
|
-
console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]
|
|
628
|
-
|
|
629
|
-
setup <profile> Carrier-only (CI): mint/reconnect and store a tenant-bound
|
|
630
|
-
carrier key. Retired for interactive use — run ${cyan("botbuddy login")}
|
|
631
|
-
on a workstation. On a TTY this refuses unless --unattended.
|
|
632
|
-
env <profile> Print shell exports that load the stored carrier key
|
|
633
|
-
|
|
634
|
-
Interactive operators: ${cyan("botbuddy login")} installs the per-machine client key.
|
|
635
|
-
CI hosts: ${cyan("botbuddy carrier setup <profile>")} (alias of setup --unattended).`);
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
if (args[0] === "env" && args[1] && args.length === 2) {
|
|
639
|
-
try {
|
|
640
|
-
log(profileShellRefresh(args[1]));
|
|
641
|
-
return;
|
|
642
|
-
} catch {
|
|
643
|
-
die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
const unattended = args.includes("--unattended");
|
|
647
|
-
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
648
|
-
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
649
|
-
die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]");
|
|
650
|
-
}
|
|
651
|
-
const profile = positionals[1];
|
|
652
|
-
// BOT-1574 AC6: interactive setup is retired. A human on a TTY who did not ask
|
|
653
|
-
// for the unattended carrier path is redirected to `botbuddy login` (which
|
|
654
|
-
// installs the machine client key) instead of minting a per-tenant slot. A
|
|
655
|
-
// non-TTY caller (CI) or an explicit --unattended keeps the carrier path.
|
|
656
|
-
if (isTTY && !unattended) {
|
|
657
|
-
log(JSON.stringify({
|
|
658
|
-
schema_version: 1,
|
|
659
|
-
outcome: "error",
|
|
660
|
-
error: "interactive_use_retired",
|
|
661
|
-
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}\`.`,
|
|
662
|
-
}));
|
|
663
|
-
setExitCode(4);
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
await runCarrierSetup(profile, { log, setExitCode, bootstrap });
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// BOT-1574 AC6: `botbuddy carrier setup <profile>` — the explicit CI-carrier
|
|
670
|
-
// alias of `profile setup --unattended`. Being the named carrier command, it is
|
|
671
|
-
// never subject to the interactive-TTY guard.
|
|
672
|
-
export async function cmdCarrier(args, {
|
|
673
|
-
log = (line) => console.log(line),
|
|
674
|
-
setExitCode = (n) => { process.exitCode = n; },
|
|
675
|
-
bootstrap = bootstrapProfile,
|
|
676
|
-
} = {}) {
|
|
677
|
-
if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
|
|
678
|
-
console.log(`Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>
|
|
679
|
-
|
|
680
|
-
Mint/reconnect and store an unattended CI carrier key (alias of
|
|
681
|
-
${cyan("botbuddy profile setup <profile> --unattended")}). Interactive operators use
|
|
682
|
-
${cyan("botbuddy login")} instead.`);
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
686
|
-
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
687
|
-
die("Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>");
|
|
688
|
-
}
|
|
689
|
-
await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
|
|
690
|
-
}
|
|
691
|
-
|
|
692
600
|
// ─── BOT-1607: `botbuddy mcp` — the tier-2 bb_mcp_ config key ────────────────
|
|
693
601
|
|
|
694
602
|
function mcpHelp(log) {
|
|
695
|
-
log(`Usage: botbuddy mcp <setup|revoke|status> [options]
|
|
603
|
+
log(`Usage: botbuddy mcp <setup|revoke|status|env> [options]
|
|
696
604
|
|
|
697
605
|
setup [--env <NAME>] [--tenant <slug>] [--label <label>] [--expiry-days <n>] [--force]
|
|
698
606
|
Mint a bb_mcp_ MCP config key (authenticated by your
|
|
@@ -711,6 +619,10 @@ function mcpHelp(log) {
|
|
|
711
619
|
Report which env var holds the MCP config key (never prints
|
|
712
620
|
the secret). ${dim("BOTBUDDY_BB_AGENT_KEY")} is honoured as a deprecated
|
|
713
621
|
alias for one release.
|
|
622
|
+
env [--env <NAME>]
|
|
623
|
+
Print the shell loader that reads the key from the Keychain
|
|
624
|
+
into ${dim("$BOTBUDDY_MCP_KEY")}: eval "$(botbuddy mcp env)" in a shell,
|
|
625
|
+
or in the desktop LaunchAgent env-install recipe.
|
|
714
626
|
|
|
715
627
|
Present the key from .mcp.json instead of reusing an agent session token — it is
|
|
716
628
|
independently revocable, so a leaked config credential rotates without a re-login.`);
|
|
@@ -821,11 +733,34 @@ export async function cmdMcp(args, {
|
|
|
821
733
|
revoke = revokeMcpKey,
|
|
822
734
|
resolveConfigKey = resolveMcpConfigKey,
|
|
823
735
|
env = process.env,
|
|
736
|
+
cwd = process.cwd(),
|
|
737
|
+
readBinding = readAgentBinding,
|
|
824
738
|
} = {}) {
|
|
825
739
|
if (args[0] === "--help" || args[0] === "-h") return mcpHelp(log);
|
|
826
740
|
const sub = args.find((a) => !a.startsWith("-"));
|
|
827
741
|
if (!sub) return mcpHelp(log);
|
|
828
742
|
|
|
743
|
+
// BOT-1608 Codex P2: `mcp status`/`mcp env` with no explicit --env must report
|
|
744
|
+
// and export the var the committed .botbuddy-agent.json actually binds (e.g.
|
|
745
|
+
// BOTBUDDY_MCP_KEY_SG), not the hard-coded default — otherwise the MCP client's
|
|
746
|
+
// configured slot is left unset and auth fails unless --env is redundantly
|
|
747
|
+
// passed. An explicit --env still wins; a malformed binding falls back to the
|
|
748
|
+
// default var (status/env are diagnostics, not the fail-closed wait path).
|
|
749
|
+
const resolveRequestedEnv = async (requested) => {
|
|
750
|
+
if (requested.explicit) return requested;
|
|
751
|
+
try {
|
|
752
|
+
const binding = await readBinding(cwd);
|
|
753
|
+
// A binding that names a NON-default var is an explicit selection of that
|
|
754
|
+
// var (declared in the file rather than on the flag) — so status inspects
|
|
755
|
+
// exactly it. A binding naming the default var stays non-explicit so the
|
|
756
|
+
// deprecated-alias fallback still applies.
|
|
757
|
+
if (binding?.mcpEnv && binding.mcpEnv !== DEFAULT_MCP_ENV_VAR) {
|
|
758
|
+
return { envVar: binding.mcpEnv, explicit: true };
|
|
759
|
+
}
|
|
760
|
+
} catch { /* malformed binding: keep the default var for diagnostics */ }
|
|
761
|
+
return requested;
|
|
762
|
+
};
|
|
763
|
+
|
|
829
764
|
if (sub === "setup") {
|
|
830
765
|
try {
|
|
831
766
|
// Parse inside the try so a bad --env (missing value / reserved name) becomes
|
|
@@ -890,7 +825,7 @@ export async function cmdMcp(args, {
|
|
|
890
825
|
if (sub === "status") {
|
|
891
826
|
let requested;
|
|
892
827
|
try {
|
|
893
|
-
requested = parseMcpStatusEnv(args);
|
|
828
|
+
requested = await resolveRequestedEnv(parseMcpStatusEnv(args));
|
|
894
829
|
} catch (error) {
|
|
895
830
|
const code = error instanceof McpKeyError ? error.code : "invalid_env_var";
|
|
896
831
|
log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
|
|
@@ -913,6 +848,25 @@ export async function cmdMcp(args, {
|
|
|
913
848
|
return;
|
|
914
849
|
}
|
|
915
850
|
|
|
851
|
+
if (sub === "env") {
|
|
852
|
+
// BOT-1608 (AC4): the "export the key" job folded out of the retired
|
|
853
|
+
// `profile env`. Prints the shell loader that reads the MCP key from the
|
|
854
|
+
// Keychain into $envVar so `eval "$(botbuddy mcp env)"` (and the desktop
|
|
855
|
+
// LaunchAgent recipe) load the credential the MCP client / bb-wait consume.
|
|
856
|
+
let requested;
|
|
857
|
+
try {
|
|
858
|
+
requested = await resolveRequestedEnv(parseMcpStatusEnv(args));
|
|
859
|
+
} catch (error) {
|
|
860
|
+
const code = error instanceof McpKeyError ? error.code : "invalid_env_var";
|
|
861
|
+
log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
|
|
862
|
+
setExitCode(3);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const envVar = requested.envVar;
|
|
866
|
+
log(`export ${envVar}="$(security find-generic-password -w -a "$USER" -s ${envVar})"`);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
916
870
|
die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
|
|
917
871
|
}
|
|
918
872
|
|
package/src/config.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function loadConfig({ platform = process.platform, warn = (m) => co
|
|
|
65
65
|
warn(
|
|
66
66
|
migrated
|
|
67
67
|
? "✓ Migrated the owner token from ~/.botbuddy/config.json into the macOS Keychain."
|
|
68
|
-
: "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy
|
|
68
|
+
: "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy mcp setup` for agents) to re-establish credentials in the Keychain.",
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/mcp-key.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// BOT-1607 — `botbuddy mcp`: mint / revoke the tier-2 `bb_mcp_` MCP config key.
|
|
2
2
|
//
|
|
3
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 —
|
|
4
|
+
// reused `bb_agent_` session token the retired `profile setup` planted in
|
|
5
|
+
// BOTBUDDY_BB_AGENT_KEY (BOT-1608). `botbuddy mcp setup` mints a `bb_mcp_` key —
|
|
6
6
|
// authenticated by the tier-1 owner/client credential (resolveCallAuth: the
|
|
7
7
|
// owner OAuth/client key from `botbuddy login`) — stores it in the Keychain
|
|
8
8
|
// under a default-or-`--env` service, and prints the `.mcp.json` /
|
|
@@ -11,24 +11,25 @@
|
|
|
11
11
|
// re-login or agent disruption.
|
|
12
12
|
//
|
|
13
13
|
// The mint/revoke calls are the server tools mint_mcp_key / revoke_mcp_key
|
|
14
|
-
// (owner/client-gated). This module
|
|
15
|
-
//
|
|
16
|
-
// keychain.
|
|
14
|
+
// (owner/client-gated). This module uses dependency injection throughout so the
|
|
15
|
+
// whole flow is unit-testable with an injected call / auth / keychain.
|
|
17
16
|
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
keychainAvailable,
|
|
19
|
+
readKeychainSecret,
|
|
20
|
+
writeKeychainSecret,
|
|
21
|
+
DEFAULT_MCP_ENV_VAR,
|
|
22
|
+
LEGACY_MCP_ENV_VAR,
|
|
23
|
+
} from "./agent-credential-store.mjs";
|
|
19
24
|
import { callToolJson, resolveCallAuth } from "./api.mjs";
|
|
20
25
|
import { SERVER_URL } from "./config.mjs";
|
|
21
26
|
|
|
22
|
-
// The
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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";
|
|
27
|
+
// The canonical MCP env/Keychain var names live in agent-credential-store.mjs
|
|
28
|
+
// (the one module wait-profile.mjs and this file both import — no cycle). Re-export
|
|
29
|
+
// them so existing importers (commands.mjs) keep resolving them from here.
|
|
30
|
+
// DEFAULT_MCP_ENV_VAR — what `.mcp.json` references (BOT-1108 canonical set).
|
|
31
|
+
// LEGACY_MCP_ENV_VAR — the pre-1607/1608 var; a DEPRECATED read alias one release.
|
|
32
|
+
export { DEFAULT_MCP_ENV_VAR, LEGACY_MCP_ENV_VAR };
|
|
32
33
|
|
|
33
34
|
// A valid shell env-var / Keychain service name.
|
|
34
35
|
const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
@@ -45,14 +46,14 @@ const RESERVED_ENV_VARS = new Set([
|
|
|
45
46
|
"BOTBUDDY_CI_KEY", // bb_ci_ — CI key
|
|
46
47
|
"BOTBUDDY_TOKEN", // PAT / OAuth owner token
|
|
47
48
|
"BOTBUDDY_TEST_RUN_TOKEN", // publishable test-run token
|
|
48
|
-
"BOTBUDDY_SG_AGENT_KEY", // Supply Guard profile
|
|
49
|
+
"BOTBUDDY_SG_AGENT_KEY", // retired Supply Guard profile slot — never an MCP var
|
|
49
50
|
]);
|
|
50
51
|
|
|
51
|
-
// BOTBUDDY_BB_AGENT_KEY is
|
|
52
|
-
// botbuddy profile Keychain slot
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
52
|
+
// BOTBUDDY_BB_AGENT_KEY is the deprecated MCP READ alias (LEGACY_MCP_ENV_VAR). It
|
|
53
|
+
// was also the retired botbuddy profile Keychain slot, so `mcp status`/`env`
|
|
54
|
+
// (read-only) may still name it, but `mcp setup`/`revoke` must NOT write or delete
|
|
55
|
+
// it — an operator mid-migration may still have a live value there (Codex P2). It
|
|
56
|
+
// is not in RESERVED_ENV_VARS so status/env can still resolve the legacy alias.
|
|
56
57
|
|
|
57
58
|
export class McpKeyError extends Error {
|
|
58
59
|
constructor(code, { detail = null } = {}) {
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -23,8 +23,3 @@ export function createSessionTokenCoordinator({ token, fetchImpl = fetch } = {})
|
|
|
23
23
|
if (!token) return { kind: "unverified" };
|
|
24
24
|
return laneCoordinator("session_token", mcpCaller({ "x-agent-api-key": token }, fetchImpl), null);
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
export function createProfileCoordinator({ profile, identity, fetchImpl = fetch } = {}) {
|
|
28
|
-
if (!profile?.token || !identity?.agentId || identity.tenant !== profile.tenant) return { kind: "unverified" };
|
|
29
|
-
return laneCoordinator("profile", mcpCaller({ "x-agent-api-key": profile.token }, fetchImpl), identity.agentId);
|
|
30
|
-
}
|
package/src/pw/run.mjs
CHANGED
|
@@ -2,18 +2,17 @@ import os from "node:os";
|
|
|
2
2
|
import { planInvocation } from "./args.mjs";
|
|
3
3
|
import { actionTypeFromMethod, NAV } from "./readiness.mjs";
|
|
4
4
|
import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createSessionTokenCoordinator } from "./coordinator.mjs";
|
|
6
6
|
import { canonicalizeHostString } from "./host.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
7
|
+
import { resolveAgentBinding } from "../wait-profile.mjs";
|
|
9
8
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
10
9
|
import { VERSION } from "../version.mjs";
|
|
11
|
-
import { readAgentKeyEnv } from "../agent-key.mjs";
|
|
10
|
+
import { readAgentKeyEnv, AGENT_KEY_RE } from "../agent-key.mjs";
|
|
12
11
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
13
12
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
14
13
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
15
14
|
const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
|
|
16
|
-
function help(out) { out.write("Usage: pw [--
|
|
15
|
+
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
17
16
|
function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
|
|
18
17
|
// BOT-1488: the register_agent identity for this machine, persisted by
|
|
19
18
|
// `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
|
|
@@ -25,37 +24,38 @@ async function readRegisteredAgentId() { try { await loadConfig(); const id = ge
|
|
|
25
24
|
async function gate({ env, host, lane, deps }) {
|
|
26
25
|
if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
|
|
27
26
|
// BOT-1572/1582: a per-session `bb_agent_` token authenticates lock verification
|
|
28
|
-
// AS the session agent —
|
|
29
|
-
//
|
|
30
|
-
// (
|
|
31
|
-
//
|
|
32
|
-
//
|
|
27
|
+
// AS the session agent — the server resolves it, holder matching rides on
|
|
28
|
+
// owner_is_caller (plus any local session/registered agent id). $BOTBUDDY_AGENT_KEY
|
|
29
|
+
// is the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
|
|
30
|
+
//
|
|
31
|
+
// BOT-1608: the retired profile-identity path is gone (its `agent-profiles.json`
|
|
32
|
+
// store no longer exists). When no session token is exported, fall back to the
|
|
33
|
+
// worktree binding's mcp_env credential (the `bb_mcp_` key from `.mcp.json`) —
|
|
34
|
+
// it authenticates the status call the same way and matching still rides on the
|
|
35
|
+
// server's owner_is_caller.
|
|
33
36
|
const sessionToken = deps.sessionToken ?? readAgentKeyEnv(env);
|
|
37
|
+
if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
|
|
38
|
+
return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed (expected bb_agent_<64 hex>). Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
39
|
+
}
|
|
34
40
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
35
41
|
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
36
|
-
let coordinator
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} catch (error) {
|
|
48
|
-
return { allowed: false, message: `bb-pw: profile verification failed (${error.message}). Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
42
|
+
let coordinator = deps.coordinator ?? null;
|
|
43
|
+
if (!coordinator) {
|
|
44
|
+
let token = sessionToken;
|
|
45
|
+
if (!token) {
|
|
46
|
+
try {
|
|
47
|
+
const binding = await (deps.resolveBinding ?? resolveAgentBinding)({ cwd: deps.cwd ?? process.cwd(), env, explicitTenant: deps.tenant ?? null });
|
|
48
|
+
token = binding.token ?? null;
|
|
49
|
+
} catch { token = null; }
|
|
50
|
+
}
|
|
51
|
+
if (!token) {
|
|
52
|
+
return { allowed: false, message: "bb-pw: no BotBuddy credential — export $BOTBUDDY_AGENT_KEY (from register_agent) or your .mcp.json key ($BOTBUDDY_MCP_KEY), or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
49
53
|
}
|
|
50
|
-
coordinator =
|
|
51
|
-
if (!profile.token || !identity || identity.tenant !== profile.tenant || coordinator.kind !== "profile") return { allowed: false, message: "bb-pw: profile identity is missing or tenant-mismatched. Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
52
|
-
// The operator's own holder identities: the tenant-bound profile agent OR the
|
|
53
|
-
// arming session agent (--session-id / $BOTBUDDY_SESSION_ID / local register_agent
|
|
54
|
-
// id). A foreign operator's agent is in none of these, so the gate stays a real
|
|
55
|
-
// refusal (AC-3).
|
|
56
|
-
selfAgentIds = new Set([identity.agentId, sessionAgentId, registeredAgentId].filter(Boolean));
|
|
57
|
-
callerId = identity.agentId;
|
|
54
|
+
coordinator = createSessionTokenCoordinator({ token, fetchImpl: deps.fetch });
|
|
58
55
|
}
|
|
56
|
+
if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: BotBuddy credential is unusable. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
57
|
+
const selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
|
|
58
|
+
const callerId = coordinator.agentId ?? sessionAgentId ?? registeredAgentId ?? "botbuddy";
|
|
59
59
|
const laneName = `playwright_lane:${host}:${lane}`;
|
|
60
60
|
try {
|
|
61
61
|
const status = await coordinator.status({ host, slot: lane });
|
|
@@ -105,7 +105,7 @@ async function runPwInner(argv, deps = {}) {
|
|
|
105
105
|
// session identity alongside --session-id, so a token-armed session need not
|
|
106
106
|
// pass an id. Holder matching still rides on the server's owner_is_caller and
|
|
107
107
|
// the resolved agent ids (gate()).
|
|
108
|
-
while (args[0] === "--
|
|
108
|
+
while (args[0] === "--tenant" || args[0] === "--session-id" || args[0] === "--session-token") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--tenant" ? { ...deps, tenant: args[1] } : flag === "--session-token" ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
|
|
109
109
|
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
110
110
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
111
111
|
if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
|