@botbuddy/cli 1.22.0 → 1.23.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.23.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();
package/src/commands.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import { callTool, callToolJson, readResource } from "./api.mjs";
2
3
  import { doLogin } from "./auth.mjs";
3
4
  import { runBridge } from "./codex-bridge.mjs";
@@ -55,6 +56,7 @@ export async function run(argv, {
55
56
  case "wait": return runWait(args);
56
57
  case "pw": return runPw(args);
57
58
  case "profile": return cmdProfile(args);
59
+ case "carrier": return cmdCarrier(args);
58
60
  case "resources": return callTool("list_resources");
59
61
  case "agents": return callTool("list_agents");
60
62
  case "tasks": return readResource("botbuddy://tasks");
@@ -96,13 +98,16 @@ ${bold("AUTH")}
96
98
  Authenticate via OAuth (opens browser + localhost callback)
97
99
  logout Remove saved credentials
98
100
  status Show current auth status (local metadata + server check)
99
- profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
101
+ carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
102
+ (interactive operators use ${cyan("login")} — it installs the client key)
100
103
 
101
104
  ${bold("TOOLS")}
102
105
  help --tools List every BotBuddy tool
103
106
  help <tool> Show one tool's arguments
104
107
  call <tool> [--key value] Invoke any tool
105
108
  call <tool> --json '{...}' Invoke with a JSON arguments object
109
+ call <tool> --pin <slug> Pin the connection tenant (a user token needs one;
110
+ default: BOTBUDDY_TENANT → repo profile → sole tenant)
106
111
 
107
112
  ${bold("STACK LEASES")}
108
113
  stack up [options] Request a batch-scoped local stack lease; park if full; hold it active
@@ -146,7 +151,8 @@ async function cmdCall(args) {
146
151
  if (e instanceof CallUsageError) die(e.message);
147
152
  throw e;
148
153
  }
149
- return callTool(parsedArgs.tool, parsedArgs.args);
154
+ // BOT-1571: an explicit --pin beats the derived default pin (see api.mjs).
155
+ return callTool(parsedArgs.tool, parsedArgs.args, { tenant: parsedArgs.pin });
150
156
  }
151
157
 
152
158
  async function cmdToolHelp(args) {
@@ -280,6 +286,15 @@ ${bold("USAGE")}
280
286
  botbuddy login [--no-browser] [--tenant <slug>]
281
287
  [--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
282
288
 
289
+ ${bold("TOKEN SCOPE")}
290
+ By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
291
+ token that is not bound to a tenant and reaches every tenant you belong to —
292
+ each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
293
+ exchanges it for a per-session agent token. No per-tenant ${cyan("profile setup")} is
294
+ needed (that path is now CI-carrier only).
295
+ ${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
296
+ tenant (the MCP-session model); use it only when you want that guarantee.
297
+
283
298
  ${bold("HOW IT WORKS")}
284
299
  Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
285
300
  your default browser at the BotBuddy authorization page, and waits for you to
@@ -293,9 +308,10 @@ ${bold("OPTIONS")}
293
308
  in a browser on THIS machine yourself. The callback listener
294
309
  still runs on this machine's localhost.
295
310
  --tenant <slug>
296
- Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
297
- your account belongs to more than one. Must be a lowercase slug;
298
- membership is still enforced by the server.
311
+ Mint a token SEALED to one tenant (e.g. ${cyan("supply-guard")}); the
312
+ sign-in page shows it as requested by the CLI. Must be a lowercase
313
+ slug; membership is still enforced by the server. Without it the
314
+ token is a user token that reaches all your tenants.
299
315
  --caller <name>
300
316
  Show WHO requested this login on the authorization screen, so
301
317
  the human approving it can confirm the request came from the
@@ -325,8 +341,8 @@ ${bold("NOTES")}
325
341
  reach the listener and login will time out.
326
342
 
327
343
  ${bold("RECOVERY")}
328
- If ${cyan("botbuddy profile setup <profile>")} reports an authentication error,
329
- run ${cyan("botbuddy login")} first, then re-run profile setup.`);
344
+ If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
345
+ unattended CI host (no browser), use ${cyan("botbuddy carrier setup <profile>")}.`);
330
346
  }
331
347
 
332
348
  async function cmdStart(args) {
@@ -382,27 +398,38 @@ const STATUS_PROBE_TIMEOUT_MS = 4000;
382
398
  // so a future opaque-token size change can't reintroduce the false rejection.
383
399
  const looksLikeOwnerToken = (t) => typeof t === "string" && /^mcp_at_[0-9a-f]{32,}$/.test(t);
384
400
 
385
- async function logServerStatus(call, auth, log) {
386
- let res;
387
- try {
388
- res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS) });
389
- } catch {
390
- log(` server: ${red("unreachable")}`);
391
- return;
392
- }
393
- if (res?.ok && !res.isError) {
394
- const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
395
- log(` server: ${green("authenticated")} (${slug})`);
396
- return;
401
+ async function logServerStatus(call, auth, log, { tenant = null, tenants = null } = {}) {
402
+ // BOT-1571: a USER token is refused unpinned (MCP_TENANT_PIN_REQUIRED), so the
403
+ // probe carries a pin the token is known to reach when one is stored. Codex
404
+ // round 9 (P2): user-token membership is evaluated PER REQUEST, so a tenant
405
+ // cached at login may be stale — probe EACH recorded tenant until one
406
+ // authenticates before reporting the token rejected.
407
+ const pins = Array.isArray(tenants) && tenants.length ? tenants : [tenant];
408
+ let lastRes = null;
409
+ for (const pin of pins) {
410
+ let res;
411
+ try {
412
+ res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS), ...(pin ? { tenant: pin } : {}) });
413
+ } catch {
414
+ log(` server: ${red("unreachable")}`);
415
+ return;
416
+ }
417
+ if (res?.ok && !res.isError) {
418
+ const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
419
+ log(` server: ${green("authenticated")} (${slug})`);
420
+ return;
421
+ }
422
+ if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
423
+ log(` server: ${red("unreachable")}`);
424
+ return;
425
+ }
426
+ lastRes = res; // a rejection for this pin — try the next recorded tenant
397
427
  }
428
+ const res = lastRes;
398
429
  if (res?.ok && res.isError) {
399
430
  log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
400
431
  return;
401
432
  }
402
- if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
403
- log(` server: ${red("unreachable")}`);
404
- return;
405
- }
406
433
  log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
407
434
  }
408
435
 
@@ -413,6 +440,8 @@ export async function cmdStatus({
413
440
  callToolJson: call = callToolJson,
414
441
  log = (line) => console.log(line),
415
442
  now = () => Date.now(),
443
+ hostname = () => os.hostname(),
444
+ env = process.env,
416
445
  } = {}) {
417
446
  const cfg = getCfg();
418
447
  const owner = await resolveOwner({ getConfig: getCfg });
@@ -430,6 +459,29 @@ export async function cmdStatus({
430
459
  }
431
460
  if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
432
461
  if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
462
+ // BOT-1571/BOT-1574: what kind of token this is. A `user` token is the
463
+ // per-machine CLIENT KEY (bb_cli_): host-bound at login, reaching every
464
+ // listed tenant (each request pins one). A `tenant` token is sealed to one.
465
+ // A pre-1571 login has no recorded scope and prints nothing rather than a
466
+ // guess.
467
+ const ownerTenants = Array.isArray(owner.tenants) ? owner.tenants : [];
468
+ if (ownerValid && owner.tenantMode === "user") {
469
+ const memberships = ownerTenants.length ? ownerTenants.join(", ") : dim("none recorded");
470
+ log(` ${cyan("Client key (bb_cli_)")} · host ${dim(hostname())} · memberships: ${memberships}`);
471
+ // BOT-1574 AC1: the per-tenant Keychain agent-key slots (BOT-1558) are
472
+ // retired for interactive use — the client key replaces them. If any are
473
+ // still exported, they are IGNORED; say so once so the operator can drop
474
+ // them. The per-session token var $BOTBUDDY_AGENT_KEY (no middle segment)
475
+ // is the live wait credential and must never be flagged here.
476
+ const ignoredSlots = Object.keys(env ?? {})
477
+ .filter((k) => /^BOTBUDDY_[A-Z0-9]+_AGENT_KEY$/.test(k))
478
+ .sort();
479
+ if (ignoredSlots.length) {
480
+ log(` ${dim("!")} ignoring ${ignoredSlots.join(", ")} — botbuddy login's client key is used instead (per-tenant agent-key slots are retired for interactive use).`);
481
+ }
482
+ } else if (ownerValid && owner.tenantMode === "tenant" && owner.tenantId) {
483
+ log(` Token scope: ${cyan("tenant")} (${owner.tenantId})`);
484
+ }
433
485
  if (ownerValid && owner.expiresAt) {
434
486
  const remaining = owner.expiresAt - now();
435
487
  if (remaining <= 0) {
@@ -464,6 +516,11 @@ export async function cmdStatus({
464
516
  call,
465
517
  fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
466
518
  log,
519
+ // Pin only for a user token probed as itself; an agent key or a sealed
520
+ // token resolves its own tenant. Probe EVERY recorded tenant (round 9 P2):
521
+ // membership is per-request, so the first cached tenant may be stale while
522
+ // another is still valid.
523
+ !fallbackKey && owner.tenantMode === "user" ? { tenants: ownerTenants } : {},
467
524
  );
468
525
  return;
469
526
  }
@@ -530,38 +587,95 @@ function cmdHeartbeat(args) {
530
587
  return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
531
588
  }
532
589
 
533
- async function cmdProfile(args) {
590
+ // BOT-1574 AC6: the carrier bootstrap that `profile setup --unattended` and
591
+ // `carrier setup` share. Never gated on a TTY — the caller decides whether the
592
+ // interactive guard applies before reaching here.
593
+ async function runCarrierSetup(profile, { log, setExitCode, bootstrap }) {
594
+ try {
595
+ const receipt = await bootstrap(profile, { call: callToolJson });
596
+ log(JSON.stringify(receipt));
597
+ } catch (error) {
598
+ const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
599
+ log(JSON.stringify({
600
+ schema_version: 1,
601
+ outcome: "error",
602
+ error: code,
603
+ recovery: profileBootstrapRecovery(code, profile),
604
+ }));
605
+ setExitCode(3);
606
+ }
607
+ }
608
+
609
+ export async function cmdProfile(args, {
610
+ isTTY = Boolean(process.stdout.isTTY),
611
+ log = (line) => console.log(line),
612
+ setExitCode = (n) => { process.exitCode = n; },
613
+ bootstrap = bootstrapProfile,
614
+ } = {}) {
534
615
  if (args[0] === "--help" || args[0] === "-h") {
535
- console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>
616
+ console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]
617
+
618
+ setup <profile> Carrier-only (CI): mint/reconnect and store a tenant-bound
619
+ carrier key. Retired for interactive use — run ${cyan("botbuddy login")}
620
+ on a workstation. On a TTY this refuses unless --unattended.
621
+ env <profile> Print shell exports that load the stored carrier key
536
622
 
537
- setup <profile> Mint/reconnect and securely store a tenant-bound agent key
538
- env <profile> Print shell exports that load the stored agent key`);
623
+ Interactive operators: ${cyan("botbuddy login")} installs the per-machine client key.
624
+ CI hosts: ${cyan("botbuddy carrier setup <profile>")} (alias of setup --unattended).`);
539
625
  return;
540
626
  }
541
627
  if (args[0] === "env" && args[1] && args.length === 2) {
542
628
  try {
543
- console.log(profileShellRefresh(args[1]));
629
+ log(profileShellRefresh(args[1]));
544
630
  return;
545
631
  } catch {
546
632
  die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
547
633
  }
548
634
  }
549
- if (args[0] !== "setup" || !args[1] || args.length > 2) {
550
- die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>");
635
+ const unattended = args.includes("--unattended");
636
+ const positionals = args.filter((a) => !a.startsWith("-"));
637
+ if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
638
+ die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]");
551
639
  }
552
- try {
553
- const receipt = await bootstrapProfile(args[1], { call: callToolJson });
554
- console.log(JSON.stringify(receipt));
555
- } catch (error) {
556
- const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
557
- console.log(JSON.stringify({
640
+ const profile = positionals[1];
641
+ // BOT-1574 AC6: interactive setup is retired. A human on a TTY who did not ask
642
+ // for the unattended carrier path is redirected to `botbuddy login` (which
643
+ // installs the machine client key) instead of minting a per-tenant slot. A
644
+ // non-TTY caller (CI) or an explicit --unattended keeps the carrier path.
645
+ if (isTTY && !unattended) {
646
+ log(JSON.stringify({
558
647
  schema_version: 1,
559
648
  outcome: "error",
560
- error: code,
561
- recovery: profileBootstrapRecovery(code, args[1]),
649
+ error: "interactive_use_retired",
650
+ 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}\`.`,
562
651
  }));
563
- process.exitCode = 3;
652
+ setExitCode(4);
653
+ return;
654
+ }
655
+ await runCarrierSetup(profile, { log, setExitCode, bootstrap });
656
+ }
657
+
658
+ // BOT-1574 AC6: `botbuddy carrier setup <profile>` — the explicit CI-carrier
659
+ // alias of `profile setup --unattended`. Being the named carrier command, it is
660
+ // never subject to the interactive-TTY guard.
661
+ export async function cmdCarrier(args, {
662
+ log = (line) => console.log(line),
663
+ setExitCode = (n) => { process.exitCode = n; },
664
+ bootstrap = bootstrapProfile,
665
+ } = {}) {
666
+ if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
667
+ console.log(`Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>
668
+
669
+ Mint/reconnect and store an unattended CI carrier key (alias of
670
+ ${cyan("botbuddy profile setup <profile> --unattended")}). Interactive operators use
671
+ ${cyan("botbuddy login")} instead.`);
672
+ return;
673
+ }
674
+ const positionals = args.filter((a) => !a.startsWith("-"));
675
+ if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
676
+ die("Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>");
564
677
  }
678
+ await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
565
679
  }
566
680
 
567
681
  function cmdLock(args) {
package/src/discovery.mjs CHANGED
@@ -30,10 +30,23 @@ export function parseCallArgs(argv) {
30
30
 
31
31
  let args = {};
32
32
  let sawJson = false;
33
+ // BOT-1571: `--pin <slug>` pins the CONNECTION tenant (the ?tenant= query),
34
+ // which a user token needs on every request. Deliberately not `--tenant`:
35
+ // several tools take a `tenant` / `tenant_id` argument of their own.
36
+ let pin = null;
33
37
 
34
38
  for (let i = 0; i < rest.length; i++) {
35
39
  const token = rest[i];
36
40
 
41
+ if (token === "--pin") {
42
+ const raw = rest[++i];
43
+ if (raw === undefined || raw.startsWith("--") || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw)) {
44
+ throw new CallUsageError("--pin requires a lowercase tenant slug, e.g. --pin supply-guard.");
45
+ }
46
+ pin = raw;
47
+ continue;
48
+ }
49
+
37
50
  if (token === "--json" || token === "-j") {
38
51
  const raw = rest[++i];
39
52
  if (raw === undefined) throw new CallUsageError("--json requires a JSON object argument.");
@@ -73,7 +86,7 @@ export function parseCallArgs(argv) {
73
86
  );
74
87
  }
75
88
 
76
- return { tool, args, usedJson: sawJson };
89
+ return { tool, args, usedJson: sawJson, pin };
77
90
  }
78
91
 
79
92
  /**
@@ -89,10 +89,18 @@ export function buildAuthorizeUrl({
89
89
  url.searchParams.set("state", state);
90
90
  url.searchParams.set("code_challenge", codeChallenge);
91
91
  url.searchParams.set("code_challenge_method", "S256");
92
- // BOT-1566: `login --tenant <slug>` preselects the tenant. /authorize forwards
93
- // it to the /mcp-auth picker as `requestedTenant`; membership is still
94
- // enforced server-side at authorize_complete this only preselects.
95
- if (tenant) url.searchParams.set("tenant", tenant);
92
+ // BOT-1571: two OAuth modes. A plain `login` asks for a USER token
93
+ // (`tenant_mode=user`): not bound to any tenant, so one login serves every
94
+ // `profile setup`, each of which pins its own tenant per request. `login
95
+ // --tenant <slug>` asks for a TENANT token SEALED to that slug
96
+ // (`tenant_mode=tenant` + `tenant=` — membership is still enforced
97
+ // server-side at authorize_complete).
98
+ if (tenant) {
99
+ url.searchParams.set("tenant_mode", "tenant");
100
+ url.searchParams.set("tenant", tenant);
101
+ } else {
102
+ url.searchParams.set("tenant_mode", "user");
103
+ }
96
104
  // BOT-1580: display-only caller identity, so the human approving the login can
97
105
  // check it against the agent session they are actually running. These are
98
106
  // UNVERIFIED hints — /authorize only forwards them to the consent screen; they
@@ -210,10 +210,23 @@ export function profileBootstrapRecovery(code, profileName) {
210
210
  // tenant-bound token can't be repointed) or the owner isn't a member of the
211
211
  // profile's tenant. The fix is to authenticate INTO the profile's tenant —
212
212
  // not the bare login loop, which relogs into the same wrong tenant.
213
+ // BOT-1571: a plain `botbuddy login` (no --tenant) now mints a USER token
214
+ // that reaches every tenant the owner belongs to, so the fix is that — not
215
+ // "log in again and pick this tenant", which is exactly the per-tenant
216
+ // re-login friction BOT-1571 removes.
217
+ // BOT-1571 (Codex follow-up): MCP_TENANT_MEMBERSHIP_REQUIRED conflates two
218
+ // causes and the CLI cannot tell them apart from the code alone — the owner
219
+ // is not a member of the tenant, OR a `--tenant`-sealed login is pinned to a
220
+ // different one. Lead with the membership case and say re-login will NOT fix
221
+ // it (a user token still can't reach a tenant you don't belong to), so a
222
+ // non-member is not sent through a no-op `botbuddy login` loop; offer the
223
+ // re-login only for the genuinely-sealed case.
213
224
  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}`;
225
+ const forTenant = tenant ? ` tenant "${tenant}"` : " this profile's tenant";
226
+ const memberHint = tenant
227
+ ? `if you are not a member of "${tenant}", ask an admin to add you — a re-login will not help`
228
+ : "if you are not a member of it, ask an admin to add you — a re-login will not help";
229
+ return `your login is not authorized for${forTenant}: ${memberHint}. If instead your login is sealed to a different tenant (you signed in with --tenant), run botbuddy login (without --tenant) to get a user token that reaches all your tenants, then re-run: botbuddy profile setup ${profileName}`;
217
230
  }
218
231
  if (code === "profile_tenant_unverified") {
219
232
  // whoami could not confirm the bound tenant, so registration was refused
package/src/wait.mjs CHANGED
@@ -120,12 +120,23 @@ OPTIONS
120
120
  --receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
121
121
  --heartbeat keep this agent session alive while waiting (so it is not reaped)
122
122
  --url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
123
- --profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
123
+ --agent-key <token> the bb_agent_ session token to authenticate this wait — a one-liner
124
+ equivalent to exporting $BOTBUDDY_AGENT_KEY (which is the default).
125
+ Only accepts a session token; a client/profile key is refused.
126
+ (--session-token is the one-release legacy alias.)
124
127
  --session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
125
- REQUIRED for any non-timer wait (a wait must name the work agent that armed it).
126
- --token <key> explicit agent key override; otherwise the profile-specific env is used
128
+ Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
127
129
  --help show this help
128
130
 
131
+ AUTH
132
+ A wait authenticates from the bb_agent_ session token (minted by register_agent):
133
+ export $BOTBUDDY_AGENT_KEY, or pass it inline with --agent-key for a one-liner
134
+ ($BOTBUDDY_SESSION_TOKEN / --session-token still accepted for one release).
135
+ --token and --profile are RETIRED (BOT-1574): they were untyped and could carry a
136
+ machine credential; use --agent-key, which only accepts a session token. The
137
+ per-machine client key (botbuddy login) is a setup credential, never a wait
138
+ credential.
139
+
129
140
  OUTPUT
130
141
  Exactly one JSON receipt line on stdout at exit, with client semver and
131
142
  protocol identity. Diagnostics go to stderr.
@@ -191,9 +202,19 @@ function parseArgv(argv) {
191
202
  else if (a === "--since") opts.since = optionValue();
192
203
  else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
193
204
  else if (a === "--url") opts.url = optionValue();
194
- else if (a === "--token") opts.token = optionValue();
195
- else if (a === "--session-token") opts.sessionToken = optionValue();
196
- else if (a === "--profile") opts.profile = optionValue();
205
+ // BOT-1574: --token and --profile are RETIRED. A wait authenticates from
206
+ // $BOTBUDDY_SESSION_TOKEN (minted by register_agent) alone; the per-machine
207
+ // client key (`botbuddy login`) is never a wait credential. Consume any
208
+ // following value so it is not mis-parsed as a condition, then fail fast in
209
+ // runWait with a migration hint (AC3).
210
+ else if (a === "--token" || a === "--profile") { opts.retiredFlag ??= a; optionValue(); }
211
+ // BOT-1574: --agent-key is the typed one-liner for the wait credential — it
212
+ // carries the bb_agent_ session token (equivalent to exporting
213
+ // $BOTBUDDY_AGENT_KEY), and NOTHING else: a client key / profile key is the
214
+ // wrong shape and is refused invalid_session_token, so it can never smuggle a
215
+ // machine credential onto a wait. --session-token is the one-release legacy
216
+ // alias for the same value.
217
+ else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
197
218
  else if (a === "--session-id") opts.sessionId = optionValue();
198
219
  else if (a.startsWith("--")) opts.unknown = a;
199
220
  else opts.conditions.push(a);
@@ -836,6 +857,23 @@ export async function runWait(argv) {
836
857
  process.stdout.write(HELP);
837
858
  process.exit(0);
838
859
  }
860
+ // BOT-1574 (AC3): --token / --profile are retired. Emit the migration hint and
861
+ // a typed `unknown_option` receipt, then exit 4 — before any relay call.
862
+ if (opts.retiredFlag) {
863
+ process.stderr.write(
864
+ `botbuddy wait: ${opts.retiredFlag} is retired (it could carry a machine credential) — a wait authenticates from the bb_agent_ session token. `
865
+ + "Pass it typed with --agent-key <token>, or export $BOTBUDDY_AGENT_KEY (from register_agent), then: botbuddy wait '<condition>'. "
866
+ + "The per-machine client key (botbuddy login) is never a wait credential.\n",
867
+ );
868
+ emitReceipt({
869
+ schema_version: 1,
870
+ outcome: "error",
871
+ error: "unknown_option",
872
+ option: opts.retiredFlag,
873
+ recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
874
+ });
875
+ process.exit(EXIT.INVALID);
876
+ }
839
877
  if (opts.unknown) {
840
878
  process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
841
879
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
@@ -1045,6 +1083,22 @@ export async function runWait(argv) {
1045
1083
  process.exit(EXIT.INVALID);
1046
1084
  }
1047
1085
  if (err && err.auth) {
1086
+ // BOT-1574 (AC4): the relay refused a wait armed by the per-machine client
1087
+ // key (or any owner/setup credential). The fix is never profile setup —
1088
+ // register a work agent and export its session token. Handle it before the
1089
+ // profile-recovery branches, which would dereference an absent agentProfile.
1090
+ if (err.errorCode === "client_key_cannot_wait") {
1091
+ process.stderr.write(
1092
+ "botbuddy wait: a client key (botbuddy login) cannot arm a wait — register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
1093
+ );
1094
+ emitReceipt({
1095
+ schema_version: 1,
1096
+ outcome: "error",
1097
+ error: "client_key_cannot_wait",
1098
+ recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
1099
+ });
1100
+ process.exit(EXIT.AUTH);
1101
+ }
1048
1102
  // BOT-1572: a session-token failure (revoked/expired/rotated, or the relay
1049
1103
  // refusing to honour the token) is fixed by RE-REGISTERING, not by profile
1050
1104
  // setup. Lead with that, and never touch the (absent) agentProfile.