@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/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 ${cyan("profile setup")} is
305
- needed (that path is now CI-carrier only).
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), use ${cyan("botbuddy carrier setup <profile>")}.`);
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 profile setup <profile>` for agents) to re-establish credentials in the Keychain.",
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 mirrors profile-bootstrap.mjs's dependency
15
- // injection so the whole flow is unit-testable with an injected call / auth /
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 { keychainAvailable, readKeychainSecret, writeKeychainSecret } from "./agent-credential-store.mjs";
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 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";
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 Keychain slot (profileCredentialEnvironment) — never an MCP var
49
+ "BOTBUDDY_SG_AGENT_KEY", // retired Supply Guard profile slot — never an MCP var
49
50
  ]);
50
51
 
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.
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 } = {}) {
@@ -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 { createProfileCoordinator, createSessionTokenCoordinator } from "./coordinator.mjs";
5
+ import { createSessionTokenCoordinator } from "./coordinator.mjs";
6
6
  import { canonicalizeHostString } from "./host.mjs";
7
- import { resolveAgentProfile } from "../wait-profile.mjs";
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 [--profile <name>] [--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"); }
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 — no machine profile needed. It takes precedence over the
29
- // profile path; holder matching then rides on the server's owner_is_caller
30
- // (plus any local session/registered agent id). The profile path stays intact
31
- // for a session that has not adopted the token. $BOTBUDDY_AGENT_KEY is the norm
32
- // ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
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, selfAgentIds, callerId;
37
- if (sessionToken) {
38
- coordinator = deps.coordinator ?? createSessionTokenCoordinator({ token: sessionToken, fetchImpl: deps.fetch });
39
- if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
40
- selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
41
- callerId = coordinator.agentId ?? sessionAgentId ?? "session-token";
42
- } else {
43
- let profile, identity;
44
- try {
45
- profile = await (deps.resolveProfile ?? resolveAgentProfile)({ cwd: deps.cwd ?? process.cwd(), env, explicitProfile: deps.profile ?? null });
46
- identity = await (deps.readIdentity ?? readProfileIdentity)(profile.name);
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 = deps.coordinator ?? createProfileCoordinator({ profile, identity, fetchImpl: deps.fetch });
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] === "--profile" || 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 === "--profile" ? { ...deps, profile: args[1] } : flag === "--session-token" ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
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); }