@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/package.json +1 -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 +55 -139
- 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/stack.mjs +3 -3
- package/src/wait-profile.mjs +128 -79
- package/src/wait.mjs +88 -226
- package/src/profile-bootstrap.mjs +0 -252
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); }
|
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
|
|
357
|
-
// (`botbuddy
|
|
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
|
|
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.
|
package/src/wait-profile.mjs
CHANGED
|
@@ -1,116 +1,165 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, parse } from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
explicitTenant = null,
|
|
108
|
+
explicitEnv = null,
|
|
59
109
|
explicitToken = null,
|
|
60
|
-
|
|
110
|
+
readBinding = readAgentBinding,
|
|
111
|
+
configuredTenants = defaultConfiguredTenants,
|
|
112
|
+
readCredential = defaultReadCredential,
|
|
61
113
|
} = {}) {
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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 = "
|
|
140
|
+
err.code = "agent_binding_required";
|
|
68
141
|
throw err;
|
|
69
142
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|