@botbuddy/cli 1.25.0 → 1.26.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/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); }
package/src/stack.mjs CHANGED
@@ -353,8 +353,8 @@ export function parseSupabaseStatus(text) {
353
353
  // ── runtime (network / process) ──────────────────────────────────────────────
354
354
 
355
355
  // BOT-1520: source both auth headers from the Keychain — the owner OAuth token
356
- // (`botbuddy login`) is preferred, the tenant-bound agent key
357
- // (`botbuddy profile setup`) is the fallback. No plaintext config.json secret.
356
+ // (`botbuddy login`) is preferred, the tenant-bound MCP key
357
+ // (`botbuddy mcp setup`, BOT-1608) is the fallback. No plaintext config.json secret.
358
358
  export async function stackAuthHeader() {
359
359
  const agentKey = await resolveAgentKey();
360
360
  const owner = await resolveOwnerToken({ getConfig });
@@ -1132,7 +1132,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1132
1132
  }
1133
1133
 
1134
1134
  try {
1135
- if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
1135
+ if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
1136
1136
  // BOT-1585: request_stack_lease requires this machine's hardware id so the lease
1137
1137
  // dispatches to the machine the worktree was registered on. `stack run` builds its
1138
1138
  // own payload (separate from `cmdUp`), so it must send it too.
@@ -1,116 +1,165 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { dirname, join, parse } from "node:path";
3
- import { readProfileCredential } from "./agent-credential-store.mjs";
3
+ import {
4
+ readKeychainSecret,
5
+ keychainAvailable,
6
+ DEFAULT_MCP_ENV_VAR,
7
+ } from "./agent-credential-store.mjs";
8
+ import { getConfig } from "./config.mjs";
4
9
 
5
- // Public CLI profiles remain tenant-bound machine-principal contracts.
6
- export const PROFILE_FILE = ".botbuddy-agent.json";
10
+ // BOT-1608: the committed repo file that binds a worktree to a tenant and names
11
+ // the env/Keychain var carrying its MCP credential — DATA, not an indirection
12
+ // through a hard-coded profile name (the retired PROFILES map). It co-locates
13
+ // with `.mcp.json`, which already declares the tenant via `?tenant=`.
14
+ export const AGENT_BINDING_FILE = ".botbuddy-agent.json";
7
15
 
8
- const PROFILES = Object.freeze({
9
- "botbuddy-dev": Object.freeze({
10
- tenant: "botbuddy",
11
- tokenEnv: "BOTBUDDY_BB_AGENT_KEY",
12
- }),
13
- "supplyguard-dev": Object.freeze({
14
- tenant: "supply-guard",
15
- tokenEnv: "BOTBUDDY_SG_AGENT_KEY",
16
- }),
17
- });
16
+ // Same slug shape the server accepts on `?tenant=`, and a shell env-var name.
17
+ const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
18
19
 
19
- export function getAgentProfile(name) {
20
- return PROFILES[name] ?? null;
21
- }
22
-
23
- // BOT-1593: when no profile is named (no --profile, no .botbuddy-agent.json) but
24
- // EXACTLY ONE supported profile's credential env var is populated, that slot is the
25
- // unambiguous profile. Used only by best-effort setup-error telemetry to attribute a
26
- // `profile_required` failure that still has a usable env bearer — never for the wait
27
- // itself. Returns the profile name, or null when zero or more than one slot is set
28
- // (ambiguous no attribution). Reads env only; never touches the Keychain.
29
- export function findEnvProfile(env = process.env) {
30
- const populated = Object.entries(PROFILES).filter(
31
- ([, { tokenEnv }]) => typeof env[tokenEnv] === "string" && env[tokenEnv].length > 0,
32
- );
33
- return populated.length === 1 ? populated[0][0] : null;
20
+ // Parse a `.botbuddy-agent.json` body into { tenant, mcpEnv }. Fails CLOSED on a
21
+ // malformed file and on the retired `{ profile }` shape — the profile→tenant map
22
+ // is exactly what BOT-1608 deletes, so a `{ profile }` file cannot be migrated
23
+ // without re-introducing it. The error names the new shape (a one-time edit).
24
+ export function parseAgentBinding(raw) {
25
+ let config;
26
+ try {
27
+ config = JSON.parse(raw);
28
+ } catch {
29
+ const err = new Error(`${AGENT_BINDING_FILE} is not valid JSON`);
30
+ err.code = "invalid_binding";
31
+ throw err;
32
+ }
33
+ if (typeof config?.profile === "string" && config?.tenant == null) {
34
+ const err = new Error(
35
+ `${AGENT_BINDING_FILE} uses the retired { "profile" } shape — replace it with `
36
+ + `{ "schema_version": 1, "tenant": "<slug>", "mcp_env": "${DEFAULT_MCP_ENV_VAR}" } (BOT-1608)`,
37
+ );
38
+ err.code = "binding_migration_required";
39
+ throw err;
40
+ }
41
+ if (config?.schema_version !== 1 || typeof config?.tenant !== "string" || !TENANT_SLUG_RE.test(config.tenant)) {
42
+ const err = new Error(`${AGENT_BINDING_FILE} must declare schema_version 1 and a lowercase tenant slug`);
43
+ err.code = "invalid_binding";
44
+ throw err;
45
+ }
46
+ let mcpEnv = DEFAULT_MCP_ENV_VAR;
47
+ if (config.mcp_env != null) {
48
+ if (typeof config.mcp_env !== "string" || !ENV_VAR_RE.test(config.mcp_env)) {
49
+ const err = new Error(`${AGENT_BINDING_FILE} mcp_env must be an UPPER_SNAKE_CASE variable name`);
50
+ err.code = "invalid_binding";
51
+ throw err;
52
+ }
53
+ mcpEnv = config.mcp_env;
54
+ }
55
+ return { tenant: config.tenant, mcpEnv };
34
56
  }
35
57
 
36
- export async function findProfileName(cwd) {
58
+ // Read the committed `.botbuddy-agent.json`, walking up from cwd to the fs root.
59
+ // Returns { tenant, mcpEnv } or null when no file exists anywhere above cwd; a
60
+ // malformed / retired-shape file throws (see parseAgentBinding).
61
+ export async function readAgentBinding(cwd = process.cwd()) {
37
62
  let dir = cwd;
38
63
  const root = parse(dir).root;
39
64
  while (true) {
65
+ let raw = null;
40
66
  try {
41
- const raw = await readFile(join(dir, PROFILE_FILE), "utf8");
42
- const config = JSON.parse(raw);
43
- if (config?.schema_version !== 1 || typeof config?.profile !== "string") {
44
- throw new Error(`${PROFILE_FILE} must contain schema_version 1 and a profile`);
45
- }
46
- return config.profile;
67
+ raw = await readFile(join(dir, AGENT_BINDING_FILE), "utf8");
47
68
  } catch (err) {
48
69
  if (err?.code !== "ENOENT") throw err;
49
70
  }
71
+ if (raw != null) return parseAgentBinding(raw);
50
72
  if (dir === root) return null;
51
73
  dir = dirname(dir);
52
74
  }
53
75
  }
54
76
 
55
- export async function resolveAgentProfile({
77
+ // The tenants the current login credential can reach (from `botbuddy login`),
78
+ // used for the "sole configured tenant" resolution rung. Best-effort: an empty
79
+ // list (config not loaded, or a tenant-sealed token) simply skips that rung.
80
+ function defaultConfiguredTenants() {
81
+ const cfg = getConfig() ?? {};
82
+ return Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
83
+ }
84
+
85
+ // The credential for a resolved binding: the mcp_env var (loaded from the shell,
86
+ // or the Keychain item whose service name IS that var). Never reads the Keychain
87
+ // when it is unavailable (BOTBUDDY_NO_KEYCHAIN / non-darwin) — the env var is the
88
+ // sole source there.
89
+ async function defaultReadCredential(mcpEnv, env) {
90
+ const fromEnv = typeof env[mcpEnv] === "string" && env[mcpEnv] ? env[mcpEnv] : null;
91
+ if (fromEnv) return fromEnv;
92
+ if (!keychainAvailable()) return null;
93
+ return readKeychainSecret(mcpEnv);
94
+ }
95
+
96
+ // BOT-1604 AC9 / BOT-1608 resolution order for in-repo agent-context commands
97
+ // (`bb-wait`, `botbuddy pw`, …): explicit `--tenant`/`--env` → the committed
98
+ // `.botbuddy-agent.json` → the sole configured login tenant if exactly one →
99
+ // else FAIL CLOSED with an actionable error. Never a silent default.
100
+ //
101
+ // Returns { tenant, tokenEnv, token } — tokenEnv is the resolved mcp_env var and
102
+ // token the credential read from it (or an explicit override). `token` may be
103
+ // null (no credential exported yet); the caller decides whether that is fatal.
104
+ export async function resolveAgentBinding({
56
105
  cwd = process.cwd(),
57
106
  env = process.env,
58
- explicitProfile = null,
107
+ explicitTenant = null,
108
+ explicitEnv = null,
59
109
  explicitToken = null,
60
- readCredential = readProfileCredential,
110
+ readBinding = readAgentBinding,
111
+ configuredTenants = defaultConfiguredTenants,
112
+ readCredential = defaultReadCredential,
61
113
  } = {}) {
62
- const name = explicitProfile || await findProfileName(cwd);
63
- if (!name) {
114
+ let binding = null;
115
+ let bindingError = null;
116
+ try {
117
+ binding = await readBinding(cwd);
118
+ } catch (err) {
119
+ bindingError = err;
120
+ }
121
+
122
+ let tenant = explicitTenant || binding?.tenant || null;
123
+ // A malformed/retired committed file must NOT be silently bypassed by the
124
+ // sole-configured-tenant fallback (Codex P2): when readBinding threw and no
125
+ // explicit --tenant overrode it, skip the fallback so bindingError surfaces
126
+ // below. An explicit --tenant is still an intentional override.
127
+ if (!tenant && !bindingError) {
128
+ const tenants = configuredTenants();
129
+ if (tenants.length === 1) tenant = tenants[0];
130
+ }
131
+ if (!tenant) {
132
+ // A malformed committed file is the more specific failure — surface it so the
133
+ // operator fixes the file rather than chasing a generic "no binding" message.
134
+ if (bindingError) throw bindingError;
64
135
  const err = new Error(
65
- `no BotBuddy agent profile found (add ${PROFILE_FILE} or pass --profile)`,
136
+ `no BotBuddy tenant resolved commit a ${AGENT_BINDING_FILE} `
137
+ + `({"schema_version":1,"tenant":"<slug>","mcp_env":"${DEFAULT_MCP_ENV_VAR}"}) `
138
+ + "or pass --tenant <slug>",
66
139
  );
67
- err.code = "profile_required";
140
+ err.code = "agent_binding_required";
68
141
  throw err;
69
142
  }
70
- const profile = getAgentProfile(name);
71
- if (!profile) {
72
- const err = new Error(`unknown BotBuddy agent profile '${name}'`);
73
- err.code = "unknown_profile";
74
- throw err;
75
- }
76
- return {
77
- name,
78
- tenant: profile.tenant,
79
- tokenEnv: profile.tokenEnv,
80
- // A freshly bootstrapped credential must take effect even if a calling shell
81
- // still has a stale profile variable. `--token` remains the explicit escape
82
- // hatch for a one-off credential.
83
- token: explicitToken || await readCredential(name) || env[profile.tokenEnv] || null,
84
- };
143
+
144
+ const tokenEnv = explicitEnv || binding?.mcpEnv || DEFAULT_MCP_ENV_VAR;
145
+ const token = explicitToken || (await readCredential(tokenEnv, env)) || null;
146
+ return { tenant, tokenEnv, token };
85
147
  }
86
148
 
87
- export function withPrincipalReceipt(receipt, profile, registration = {}) {
88
- if (!profile) {
89
- // BOT-1572: a session-token wait has no profile, but the receipt must still
90
- // carry principal.session_id / agent_id / tenant_id derived by the relay from
91
- // the token. Only attach when the relay actually echoed a session identity.
92
- if (registration.sessionId || registration.agentId || registration.sessionTenant != null) {
93
- return {
94
- ...receipt,
95
- principal: {
96
- profile: null,
97
- tenant_id: registration.sessionTenant ?? null,
98
- agent_id: registration.agentId ?? null,
99
- session_id: registration.sessionId ?? null,
100
- session_agent_id: registration.sessionAgentId ?? null,
101
- },
102
- };
103
- }
104
- return receipt;
105
- }
149
+ // Attach the resolved principal to a wait receipt. With the profile bridge gone
150
+ // there is no profile name — the tenant comes from the binding (or the relay's
151
+ // session identity). Only attached when a principal identity is known.
152
+ export function withPrincipalReceipt(receipt, binding, registration = {}) {
153
+ const hasSession =
154
+ registration.sessionId || registration.agentId || registration.sessionTenant != null;
155
+ if (!binding && !hasSession) return receipt;
106
156
  return {
107
157
  ...receipt,
108
158
  principal: {
109
- profile: profile.name,
110
- tenant_id: registration.sessionTenant ?? profile.tenant,
159
+ tenant_id: registration.sessionTenant ?? binding?.tenant ?? null,
111
160
  agent_id: registration.agentId ?? null,
112
161
  // BOT-1467: the arming session's agent, when the relay attributed the wait
113
- // to it instead of the profile agent (null for an ordinary profile wait).
162
+ // to it (null for an ordinary binding wait).
114
163
  session_id: registration.sessionId ?? null,
115
164
  session_agent_id: registration.sessionAgentId ?? null,
116
165
  },