@botbuddy/cli 1.9.1 → 1.12.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.9.1",
3
+ "version": "1.12.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -53,25 +53,48 @@ export async function callTool(toolName, args = {}) {
53
53
  // * { ok:true, data } — tool result JSON (data.success may still be false)
54
54
  // * { ok:false, auth:true } — not authenticated / token expired
55
55
  // * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
56
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal } = {}) {
56
+ // BOT-1487: resolve the auth header ONCE so a caller that issues several tool
57
+ // calls (e.g. `profile setup` does whoami → register_agent) can pin the SAME
58
+ // credential across all of them. Without this, each callToolJson independently
59
+ // re-reads the owner token, so a concurrent `botbuddy login` could swap the
60
+ // bound tenant mid-sequence (a TOCTOU that would register in the wrong tenant).
61
+ // Returns { auth } with the header object, or { error } with the structured
62
+ // not-authenticated / token-expired receipt to return verbatim.
63
+ export async function resolveCallAuth() {
57
64
  const owner = await resolveOwnerToken({ getConfig });
58
- let auth;
59
65
  if (owner && !(owner.expiresAt && Date.now() >= owner.expiresAt)) {
60
- auth = { Authorization: `Bearer ${owner.token}` };
61
- } else {
62
- const agentKey = await resolveAgentKey();
63
- if (agentKey) {
64
- auth = { "x-agent-api-key": agentKey };
65
- } else if (owner) {
66
- return { ok: false, auth: true, error: "token_expired" };
67
- } else {
68
- return { ok: false, auth: true, error: "not_authenticated" };
69
- }
66
+ return { auth: { Authorization: `Bearer ${owner.token}` } };
67
+ }
68
+ const agentKey = await resolveAgentKey();
69
+ if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
70
+ if (owner) return { error: { ok: false, auth: true, error: "token_expired" } };
71
+ return { error: { ok: false, auth: true, error: "not_authenticated" } };
72
+ }
73
+
74
+ // BOT-1561: a multi-membership owner credential is tenant-ambiguous without an
75
+ // explicit pin — the server (mcpSessionIdentity, BOT-1521) fails closed, so
76
+ // stateless REST tools/call like whoami return no tenant_id. `tenant` pins the
77
+ // call to a specific tenant by appending ?tenant=<slug> to the endpoint (the
78
+ // same mechanism as the documented BOTBUDDY_SERVER_URL override), letting the
79
+ // server resolve/confirm exactly that tenant.
80
+ function toolEndpoint(tenant) {
81
+ if (!tenant) return SERVER_URL;
82
+ const url = new URL(SERVER_URL);
83
+ url.searchParams.set("tenant", tenant);
84
+ return url.toString();
85
+ }
86
+
87
+ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null } = {}) {
88
+ let auth = pinnedAuth;
89
+ if (!auth) {
90
+ const resolved = await resolveCallAuth();
91
+ if (resolved.error) return resolved.error;
92
+ auth = resolved.auth;
70
93
  }
71
94
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
72
95
  let res;
73
96
  try {
74
- res = await fetchImpl(SERVER_URL, {
97
+ res = await fetchImpl(toolEndpoint(tenant), {
75
98
  method: "POST",
76
99
  headers: { "Content-Type": "application/json", ...auth },
77
100
  body: JSON.stringify(body),
@@ -80,7 +103,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
80
103
  } catch (err) {
81
104
  return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
82
105
  }
83
- if (res.status === 401 || res.status === 403) return { ok: false, auth: true, status: res.status, error: "unauthorized" };
106
+ if (res.status === 401 || res.status === 403) {
107
+ // BOT-1561: a 401/403 can still carry a structured MCP error code in its
108
+ // JSON-RPC body (e.g. MCP_TENANT_MEMBERSHIP_REQUIRED from a connection
109
+ // tenant-pin refusal — index.ts serializes it as
110
+ // { error: { code: -32002, data: { code } } }). Surface that `code` so a
111
+ // caller can tell a tenant refusal apart from a plain credential failure.
112
+ // Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
113
+ let code = null;
114
+ try {
115
+ const body = await res.json();
116
+ const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
117
+ if (typeof raw === "string" && raw) code = raw;
118
+ } catch { /* opaque body */ }
119
+ return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
120
+ }
84
121
  let payload;
85
122
  try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
86
123
  if (payload.error?.message) return { ok: false, status: res.status, error: payload.error.message };
@@ -2,7 +2,7 @@ import { hostname } from "node:os";
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
4
  import { ensureProfileCredentialBackend, readProfileIdentity, readProfileRetryIdentity, recordProfileRetryIdentity, installProfileCredential, profileCredentialEnvironment, ProfileCredentialStoreError } from "./agent-credential-store.mjs";
5
- import { callToolJson } from "./api.mjs";
5
+ import { callToolJson, resolveCallAuth } from "./api.mjs";
6
6
  import { latestPublicCliCommand } from "./public-invocation.mjs";
7
7
 
8
8
  const PROFILES = Object.freeze({
@@ -27,6 +27,15 @@ export function defaultProfileAgentName(profile) {
27
27
  return `${profile}-${host || "host"}-${suffix}`;
28
28
  }
29
29
 
30
+ // A structured MCP error code can arrive on a tool result body (`data.code`) or,
31
+ // for a 401/403, on the top-level auth receipt (`code`) that callToolJson lifts
32
+ // out of the JSON-RPC error body (BOT-1561). Read whichever is present.
33
+ function structuredCode(response) {
34
+ if (typeof response?.data?.code === "string") return response.data.code;
35
+ if (typeof response?.code === "string") return response.code;
36
+ return null;
37
+ }
38
+
30
39
  function registrationCredential(data) {
31
40
  return typeof data?.agent_api_key === "string"
32
41
  ? data.agent_api_key
@@ -38,6 +47,7 @@ function registrationCredential(data) {
38
47
  export async function bootstrapProfile(profileName, {
39
48
  name = null,
40
49
  call = callToolJson,
50
+ resolveAuth = resolveCallAuth,
41
51
  readIdentity = readProfileIdentity,
42
52
  readRetryIdentity = readProfileRetryIdentity,
43
53
  recordRetryIdentity = recordProfileRetryIdentity,
@@ -55,14 +65,83 @@ export async function bootstrapProfile(profileName, {
55
65
  // cannot loop forever on stale metadata.
56
66
  const reusableIdentity = existing?.tenant === profile.tenant ? existing.agentId : retryIdentity?.agentId ?? null;
57
67
  const agentName = name ?? (existing?.tenant === profile.tenant ? existing.name : retryIdentity?.name ?? defaultProfileAgentName(profileName));
68
+ // BOT-1487: tenancy is auth-bound after BOT-1416 — register_agent selects the
69
+ // tenant from the OAuth session / agent key and rejects a runtime tenant_id
70
+ // with MCP_TENANT_CONTEXT_IS_AUTH_ONLY. So we no longer send tenant_id; the
71
+ // returned tenant_id is attested against the profile's tenant below instead.
72
+ //
73
+ // register_agent is a MUTATING call (it persists the agent, its credential
74
+ // hash, and tenant membership). If the session is logged into another tenant,
75
+ // registering first and rejecting the response afterwards would leave a stray,
76
+ // unreachable agent in the wrong tenant on every attempt (names are randomized
77
+ // and the wrong-tenant path records no retry identity). So resolve the bound
78
+ // tenant with the read-only `whoami` FIRST and gate registration on it.
79
+ //
80
+ // Fail CLOSED (AGENTS.md — no fallbacks; non-determinism is worse than
81
+ // failure): the mutating register_agent runs ONLY on a positively attested
82
+ // tenant that matches the profile. A missing/erroring/malformed whoami never
83
+ // silently proceeds, because the later attestation cannot undo an agent that
84
+ // registration already persisted in the wrong tenant.
85
+ //
86
+ // Snapshot the credential ONCE and pin it to both whoami and register_agent.
87
+ // Otherwise each call re-reads the owner token, so a concurrent `botbuddy
88
+ // login` could swap the bound tenant between the attesting whoami and the
89
+ // mutating register (a TOCTOU that would register in the second tenant).
90
+ const pinned = await resolveAuth();
91
+ if (pinned?.error) throw new ProfileBootstrapError("profile_agent_required");
92
+ const auth = pinned?.auth ?? null;
93
+ // BOT-1561: a multi-tenant owner's OAuth credential is tenant-ambiguous, so an
94
+ // unpinned whoami returns no tenant_id and BOT-1487's gate throws
95
+ // profile_tenant_unverified. Each profile already knows its tenant, so pin it
96
+ // on BOTH calls: whoami resolves/confirms exactly that tenant (the server
97
+ // refuses a non-member owner → profile_credential_wrong_tenant below), and
98
+ // register_agent mints in the pinned tenant. Preserves BOT-1487's fail-closed
99
+ // + single-credential pinning guarantees.
100
+ const tenant = profile.tenant;
101
+ const identity = await call("whoami", {}, { auth, tenant });
102
+ const identityCode = structuredCode(identity);
103
+ // BOT-1561: the server refuses the pin with MCP_TENANT_MEMBERSHIP_REQUIRED
104
+ // (HTTP 403) when the credential cannot register in the profile's tenant —
105
+ // either the owner is not a member, or a tenant-bound login is sealed to a
106
+ // DIFFERENT tenant (resolveHintedTenant never repoints a bound credential).
107
+ // callToolJson now preserves that code from the 403 body; map it to the
108
+ // wrong-tenant outcome so setup reports the accurate, actionable failure
109
+ // instead of the misleading profile_tenant_unverified the swallowed 403
110
+ // previously produced. register_agent never runs.
111
+ if (identityCode === "MCP_TENANT_MEMBERSHIP_REQUIRED") {
112
+ throw new ProfileBootstrapError("profile_credential_wrong_tenant");
113
+ }
114
+ if (identityCode === "MCP_TENANT_SELECTION_REQUIRED" || identityCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY") {
115
+ throw new ProfileBootstrapError("profile_tenant_selection_required");
116
+ }
117
+ const boundTenant = typeof identity?.data?.tenant_id === "string" ? identity.data.tenant_id : null;
118
+ if (!boundTenant) {
119
+ // whoami did not positively attest a tenant (transport/server error or a
120
+ // response without a string tenant_id) — refuse rather than register blind.
121
+ throw new ProfileBootstrapError("profile_tenant_unverified");
122
+ }
123
+ if (boundTenant !== profile.tenant) {
124
+ throw new ProfileBootstrapError("profile_credential_wrong_tenant");
125
+ }
58
126
  const args = {
59
127
  name: agentName,
60
128
  type: "codex",
61
- tenant_id: profile.tenant,
62
129
  ...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
63
130
  };
64
- const response = await call("register_agent", args);
131
+ const response = await call("register_agent", args, { auth, tenant });
65
132
  const data = response?.data;
133
+ const responseCode = structuredCode(response);
134
+ // Defense-in-depth: whoami above already gated on the tenant, so register_agent
135
+ // should not return a tenant-context error here — but if the contract drifts,
136
+ // surface its own actionable recovery, not the misleading
137
+ // `profile_agent_required` → `botbuddy login` loop that keeps failing because
138
+ // login itself is fine.
139
+ if (responseCode === "MCP_TENANT_MEMBERSHIP_REQUIRED") {
140
+ throw new ProfileBootstrapError("profile_credential_wrong_tenant");
141
+ }
142
+ if (responseCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY" || responseCode === "MCP_TENANT_SELECTION_REQUIRED") {
143
+ throw new ProfileBootstrapError("profile_tenant_selection_required");
144
+ }
66
145
  if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
67
146
  throw new ProfileBootstrapError("profile_agent_required");
68
147
  }
@@ -126,6 +205,30 @@ export function profileBootstrapRecovery(code, profileName) {
126
205
  if (code === "profile_credential_persist_failed") {
127
206
  return `ensure ~/.botbuddy is writable and no other setup is running, then re-run: botbuddy profile setup ${profileName}`;
128
207
  }
208
+ if (code === "profile_credential_wrong_tenant") {
209
+ // BOT-1561: reached when the login is authorized for a different tenant (a
210
+ // tenant-bound token can't be repointed) or the owner isn't a member of the
211
+ // profile's tenant. The fix is to authenticate INTO the profile's tenant —
212
+ // not the bare login loop, which relogs into the same wrong tenant.
213
+ const tenant = getAgentProfile(profileName)?.tenant;
214
+ return tenant
215
+ ? `run botbuddy login and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName} (if you are not a member of "${tenant}", ask an admin for access)`
216
+ : `run botbuddy login and select the profile's tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
217
+ }
218
+ if (code === "profile_tenant_unverified") {
219
+ // whoami could not confirm the bound tenant, so registration was refused
220
+ // before any mutation. Usually transient — retry; login only if it persists.
221
+ return `could not confirm your tenant with the server — retry: botbuddy profile setup ${profileName} (if it persists, run botbuddy login, then re-run setup)`;
222
+ }
223
+ if (code === "profile_tenant_selection_required") {
224
+ // Tenancy is chosen at MCP authentication (BOT-1416), not passed to
225
+ // register_agent. Point the user at selecting the profile's tenant when
226
+ // they sign in — never the bare login loop that implies login is broken.
227
+ const tenant = getAgentProfile(profileName)?.tenant;
228
+ return tenant
229
+ ? `sign in and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName}`
230
+ : `sign in and select the matching tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
231
+ }
129
232
  return `botbuddy login && botbuddy profile setup ${profileName}`;
130
233
  }
131
234
 
@@ -1 +0,0 @@
1
- {"schema_version":1,"source_version":"1.9.0","source_identity":"70b96a360d9dd5bd637dc6378a4cae6542cb0a508990f28d26c842946e6fb694"}