@botbuddy/cli 1.22.0 → 1.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -1,7 +1,53 @@
1
1
  import { getConfig, SERVER_URL } from "./config.mjs";
2
2
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
3
+ import { findProfileName, getAgentProfile } from "./wait-profile.mjs";
3
4
  import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
4
5
 
6
+ // Same slug shape the server accepts on `?tenant=`.
7
+ const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
+
9
+ // BOT-1571 (Codex P1): a plain `botbuddy login` mints a USER token, which the
10
+ // server refuses unpinned (MCP_TENANT_PIN_REQUIRED). Generic commands
11
+ // (`botbuddy call`, resources) therefore derive a default pin:
12
+ // 1. BOTBUDDY_TENANT (explicit; malformed → usage error, never sent),
13
+ // 2. the repo's .botbuddy-agent.json profile tenant (walks up from cwd),
14
+ // 3. the token's sole reachable tenant,
15
+ // 4. none — the server's error names the fix.
16
+ // A sealed (tenant-mode) or pre-1571 token gets NO derived pin: the server
17
+ // resolves its own tenant, and a differing pin would be a hard conflict
18
+ // (a pin can confirm a sealed tenant, never repoint it). BOTBUDDY_TENANT is
19
+ // always honoured — it is the operator's explicit statement.
20
+ export async function resolveDefaultTenantPin({
21
+ getConfig: getCfg = getConfig,
22
+ env = process.env,
23
+ cwd = process.cwd(),
24
+ findProfile = findProfileName,
25
+ profileFor = getAgentProfile,
26
+ } = {}) {
27
+ const explicit = typeof env.BOTBUDDY_TENANT === "string" ? env.BOTBUDDY_TENANT.trim() : "";
28
+ if (explicit) {
29
+ if (!TENANT_PIN_RE.test(explicit)) throw new Error("BOTBUDDY_TENANT must be a lowercase tenant slug");
30
+ return explicit;
31
+ }
32
+ const cfg = getCfg() ?? {};
33
+ if (cfg.token_tenant_mode !== "user") return null;
34
+ let profileName = null;
35
+ try { profileName = await findProfile(cwd); } catch { profileName = null; }
36
+ const profileTenant = profileName ? profileFor(profileName)?.tenant ?? null : null;
37
+ if (profileTenant) return profileTenant;
38
+ const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
39
+ return tenants.length === 1 ? tenants[0] : null;
40
+ }
41
+
42
+ // Endpoint for a generic call: explicit `tenant` beats the derived pin.
43
+ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
44
+ let pin = tenant;
45
+ if (!pin) {
46
+ try { pin = await resolvePin(); } catch (err) { die(err.message); }
47
+ }
48
+ return toolEndpoint(pin);
49
+ }
50
+
5
51
  // BOT-1520: auth headers come from the Keychain, never a plaintext config.json
6
52
  // secret. The owner OAuth token (from `botbuddy login`) is preferred; the
7
53
  // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
@@ -23,8 +69,8 @@ async function authHeader() {
23
69
  die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
24
70
  }
25
71
 
26
- export async function callTool(toolName, args = {}) {
27
- const headers = { "Content-Type": "application/json", ...(await authHeader()) };
72
+ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
73
+ const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
28
74
  const body = {
29
75
  jsonrpc: "2.0",
30
76
  id: 1,
@@ -32,16 +78,22 @@ export async function callTool(toolName, args = {}) {
32
78
  params: { name: toolName, arguments: args },
33
79
  };
34
80
 
35
- const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
81
+ const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
36
82
  const data = await res.json();
37
83
 
38
- if (data.error?.message) die(`Server error: ${data.error.message}`);
84
+ if (data.error?.message) {
85
+ const code = data.error?.data?.code;
86
+ const hint = code === "MCP_TENANT_PIN_REQUIRED"
87
+ ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
88
+ : "";
89
+ die(`Server error: ${data.error.message}${hint}`);
90
+ }
39
91
 
40
92
  const text = data.result?.content?.map((c) => c.text).join("\n");
41
93
  if (text) {
42
- console.log(prettyJson(text));
94
+ log(prettyJson(text));
43
95
  } else {
44
- console.log(JSON.stringify(data, null, 2));
96
+ log(JSON.stringify(data, null, 2));
45
97
  }
46
98
  return data;
47
99
  }
@@ -84,17 +136,26 @@ function toolEndpoint(tenant) {
84
136
  return url.toString();
85
137
  }
86
138
 
87
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null } = {}) {
139
+ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
88
140
  let auth = pinnedAuth;
89
141
  if (!auth) {
90
142
  const resolved = await resolveCallAuth();
91
143
  if (resolved.error) return resolved.error;
92
144
  auth = resolved.auth;
93
145
  }
146
+ // BOT-1571 (Codex P1, round 2): `botbuddy stack` and every other
147
+ // callToolJson caller that passes no explicit tenant would send a USER token
148
+ // unpinned → MCP_TENANT_PIN_REQUIRED. Derive the default pin, but only for
149
+ // an OWNER bearer — an agent key is already tenant-bound and a differing
150
+ // derived pin would be a spurious conflict.
151
+ let pin = tenant;
152
+ if (!pin && auth && Object.prototype.hasOwnProperty.call(auth, "Authorization")) {
153
+ try { pin = await resolvePin(); } catch (err) { return { ok: false, status: null, error: `usage: ${err?.message ?? err}` }; }
154
+ }
94
155
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
95
156
  let res;
96
157
  try {
97
- res = await fetchImpl(toolEndpoint(tenant), {
158
+ res = await fetchImpl(toolEndpoint(pin), {
98
159
  method: "POST",
99
160
  headers: { "Content-Type": "application/json", ...auth },
100
161
  body: JSON.stringify(body),
@@ -127,23 +188,23 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
127
188
  return { ok: true, data, isError: payload.result?.isError === true };
128
189
  }
129
190
 
130
- export async function listResources() {
131
- const headers = { "Content-Type": "application/json", ...(await authHeader()) };
191
+ export async function listResources({ fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
192
+ const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
132
193
  const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
133
- const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
194
+ const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
134
195
  const data = await res.json();
135
- console.log(JSON.stringify(data.result?.resources ?? data.result ?? data, null, 2));
196
+ log(JSON.stringify(data.result?.resources ?? data.result ?? data, null, 2));
136
197
  }
137
198
 
138
- export async function readResource(uri) {
139
- const headers = { "Content-Type": "application/json", ...(await authHeader()) };
199
+ export async function readResource(uri, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
200
+ const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
140
201
  const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
141
- const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
202
+ const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
142
203
  const data = await res.json();
143
204
  const text = data.result?.contents?.[0]?.text;
144
205
  if (text) {
145
- console.log(prettyJson(text));
206
+ log(prettyJson(text));
146
207
  } else {
147
- console.log(JSON.stringify(data.result ?? data, null, 2));
208
+ log(JSON.stringify(data.result ?? data, null, 2));
148
209
  }
149
210
  }
package/src/auth.mjs CHANGED
@@ -170,8 +170,14 @@ export async function doLogin(options = {}, deps = {}) {
170
170
  // BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
171
171
  // propagates from here, so login exits non-zero and the success line below
172
172
  // is never printed for a credential that did not verifiably land.
173
+ // BOT-1571: record the token's scope. A server that predates tenant modes
174
+ // omits `tenant_mode`; such a token is sealed (or legacy), never assumed
175
+ // to be a user token.
176
+ const tenantMode = tokenData.tenant_mode === "user" ? "user" : "tenant";
177
+ const tenantId = typeof tokenData.tenant_id === "string" && tokenData.tenant_id ? tokenData.tenant_id : null;
178
+ const tenants = Array.isArray(tokenData.tenants) ? tokenData.tenants.filter((t) => typeof t === "string" && t) : [];
173
179
  const { storedInKeychain } = await persist(
174
- { token: tokenData.access_token, expiresAt, clientId },
180
+ { token: tokenData.access_token, expiresAt, clientId, tenantMode, tenantId, tenants },
175
181
  { getConfig, saveConfig, warn: errorLog },
176
182
  );
177
183
 
@@ -180,6 +186,11 @@ export async function doLogin(options = {}, deps = {}) {
180
186
  storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
181
187
  }.`,
182
188
  );
189
+ if (tenantMode === "user") {
190
+ log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy profile setup")} pins its own.`);
191
+ } else if (tenantId) {
192
+ log(` Token scope: ${cyan("tenant")} — sealed to ${cyan(tenantId)}. Run ${cyan("botbuddy login")} without ${dim("--tenant")} for a token that reaches all your tenants.`);
193
+ }
183
194
  return { clientId, redirectUri };
184
195
  } finally {
185
196
  // AC-11/AC-13: always release the socket so re-running login starts clean.
@@ -25,9 +25,19 @@ import {
25
25
  } from "./agent-credential-store.mjs";
26
26
  import { resolveAgentProfile } from "./wait-profile.mjs";
27
27
 
28
- // Dedicated Keychain service for the owner OAuth token. Distinct from the
29
- // per-profile agent-key services (BOTBUDDY_BB_AGENT_KEY / BOTBUDDY_SG_AGENT_KEY)
30
- // so the human owner session and the machine agent identity never collide.
28
+ // BOT-1574: the per-machine CLIENT KEY. `botbuddy login` mints a user-mode OAuth
29
+ // token (BOT-1571) — tenant-agnostic, pins one tenant per request against the
30
+ // owner's memberships and that token IS the machine's client credential. It is
31
+ // stored under ONE Keychain item per machine, distinct from the per-profile
32
+ // agent-key services (BOTBUDDY_BB_AGENT_KEY / BOTBUDDY_SG_AGENT_KEY) and from the
33
+ // per-session token (bb_sess_), so a setup credential is never mistaken for an
34
+ // agent. (The wire format stays `mcp_at_`; "client key" is the kind, not a new
35
+ // prefix — the OAuth token format is out of scope for BOT-1574.)
36
+ export const CLIENT_KEY_SERVICE = "BOTBUDDY_CLIENT_KEY";
37
+
38
+ // The legacy service the same token was stored under before BOT-1574. Still read
39
+ // (so an already-logged-in user keeps working across the upgrade without
40
+ // re-login) and cleared on logout; `login` always writes CLIENT_KEY_SERVICE now.
31
41
  export const OWNER_TOKEN_SERVICE = "BOTBUDDY_OWNER_TOKEN";
32
42
 
33
43
  // Config keys that must never carry secret material at rest.
@@ -67,11 +77,11 @@ export async function migrateConfigSecrets(config, {
67
77
  exists,
68
78
  // `createOnly`: the migration write must not overwrite a token another process
69
79
  // wrote after our empty-slot read (BOT-1569 Codex P2 TOCTOU).
70
- keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value, { createOnly: true }),
80
+ keychainWrite = (value) => writeKeychainSecret(CLIENT_KEY_SERVICE, value, { createOnly: true }),
71
81
  // `strict`: a transient READ failure throws (fail closed) instead of reading as
72
82
  // an empty slot, so the migration never overwrites a live stored token because
73
83
  // the lookup momentarily failed (BOT-1569 Codex P2).
74
- keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
84
+ keychainRead = () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
75
85
  warn = (message) => console.error(message),
76
86
  } = {}) {
77
87
  const out = { ...(config ?? {}) };
@@ -167,13 +177,13 @@ export async function migrateConfigSecrets(config, {
167
177
  // the token itself goes to the Keychain on darwin, or (with a warning) stays in
168
178
  // the 0600 config.json where no Keychain exists.
169
179
  export async function persistOwnerToken(
170
- { token, expiresAt, clientId },
180
+ { token, expiresAt, clientId, tenantMode, tenantId, tenants },
171
181
  {
172
182
  platform = process.platform,
173
183
  exists,
174
184
  getConfig,
175
185
  saveConfig,
176
- keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value),
186
+ keychainWrite = (value) => writeKeychainSecret(CLIENT_KEY_SERVICE, value),
177
187
  warn = (message) => console.error(message),
178
188
  } = {},
179
189
  ) {
@@ -187,6 +197,16 @@ export async function persistOwnerToken(
187
197
  const next = { ...base };
188
198
  if (clientId !== undefined) next.client_id = clientId;
189
199
  if (expiresAt !== undefined) next.token_expires_at = expiresAt;
200
+ // BOT-1571: the token's SCOPE (non-secret). `user` = not bound to a tenant,
201
+ // pins one per request; `tenant` = sealed to token_tenant_id. Recorded from
202
+ // the /token response so `status` can describe the credential and probe it
203
+ // with a pin it is known to reach. Always replaced together, never merged
204
+ // with a previous login's scope.
205
+ if (tenantMode !== undefined) {
206
+ next.token_tenant_mode = tenantMode;
207
+ next.token_tenant_id = tenantId ?? null;
208
+ next.token_tenants = Array.isArray(tenants) ? tenants.filter((t) => typeof t === "string" && t) : [];
209
+ }
190
210
 
191
211
  if (keychain) {
192
212
  await keychainWrite(token);
@@ -209,25 +229,38 @@ export async function resolveOwnerToken({
209
229
  platform = process.platform,
210
230
  exists,
211
231
  getConfig,
212
- keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE),
232
+ keychainRead = () => readKeychainSecret(CLIENT_KEY_SERVICE),
233
+ // BOT-1574 migration: a machine logged in before 1574 has the token under the
234
+ // legacy OWNER_TOKEN service. Read it as a fallback so an upgrade needs no
235
+ // re-login; `login` rewrites it to CLIENT_KEY, and `logout` clears both.
236
+ keychainReadLegacy = () => readKeychainSecret(OWNER_TOKEN_SERVICE),
213
237
  } = {}) {
214
238
  const cfg = getConfig() ?? {};
215
239
  const expiresAt = typeof cfg.token_expires_at === "number" ? cfg.token_expires_at : null;
216
240
  let token = null;
217
241
  if (keychainAvailable(platform, exists)) {
218
242
  token = await keychainRead();
243
+ if (!token) token = await keychainReadLegacy();
219
244
  } else if (typeof cfg.access_token === "string" && cfg.access_token) {
220
245
  token = cfg.access_token;
221
246
  }
222
247
  if (!token) return null;
223
- return { token, expiresAt };
248
+ const tenantMode = cfg.token_tenant_mode === "user" || cfg.token_tenant_mode === "tenant" ? cfg.token_tenant_mode : null;
249
+ const tenantId = typeof cfg.token_tenant_id === "string" && cfg.token_tenant_id ? cfg.token_tenant_id : null;
250
+ const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
251
+ return { token, expiresAt, tenantMode, tenantId, tenants };
224
252
  }
225
253
 
226
- // Remove the owner OAuth token from every store (`botbuddy logout`).
254
+ // Remove the client key from every store (`botbuddy logout`). Clears BOTH the
255
+ // current CLIENT_KEY item and the legacy OWNER_TOKEN item so a pre-1574 token
256
+ // left behind by the read-both migration is never orphaned in the Keychain.
227
257
  export async function clearOwnerToken({
228
258
  platform = process.platform,
229
259
  exists,
230
- keychainDelete = () => deleteKeychainSecret(OWNER_TOKEN_SERVICE),
260
+ keychainDelete = async () => {
261
+ await deleteKeychainSecret(CLIENT_KEY_SERVICE);
262
+ await deleteKeychainSecret(OWNER_TOKEN_SERVICE);
263
+ },
231
264
  } = {}) {
232
265
  if (keychainAvailable(platform, exists)) {
233
266
  await keychainDelete();