@botbuddy/cli 1.9.2 → 1.13.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.9.2",
3
+ "version": "1.13.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,17 +43,25 @@ function keychainService(profile) {
43
43
  // succeeded. It carries its own code so the CLI never mislabels a storage
44
44
  // failure as an auth failure (which would tell the user to re-`login`).
45
45
  export class ProfileCredentialStoreError extends Error {
46
- constructor(message, { cause } = {}) {
46
+ constructor(message, { cause, code = "profile_credential_store_failed" } = {}) {
47
47
  super(message);
48
48
  this.name = "ProfileCredentialStoreError";
49
- this.code = "profile_credential_store_failed";
49
+ this.code = code;
50
50
  if (cause !== undefined) this.cause = cause;
51
51
  }
52
52
  }
53
53
 
54
54
  function writePasswordPrompt(command, args, password, spawnProcess) {
55
55
  return new Promise((resolve, reject) => {
56
- const child = spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"] });
56
+ // BOT-1566: `security -w` (prompt mode) reads the secret with
57
+ // readpassphrase(3), which opens the CONTROLLING TERMINAL whenever the
58
+ // process has one — ignoring the piped stdin and silently storing whatever
59
+ // the human types at "password data for new item:". Spawning the child
60
+ // detached puts it in a new session with no controlling TTY, so
61
+ // readpassphrase falls back to stdin and the piped value is what lands.
62
+ // Headless (CI / agent harness) behaviour is unchanged: there was never a
63
+ // TTY to grab.
64
+ const child = spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"], detached: true });
57
65
  let stderr = "";
58
66
  child.stderr.on("data", (chunk) => { stderr += chunk; });
59
67
  child.once("error", reject);
@@ -74,7 +82,19 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
74
82
  // name, so both the profile agent-key store and the owner OAuth-token store
75
83
  // share the single implementation of the `security -w` double-read prompt and
76
84
  // the actionable error mapping — no second copy to drift.
77
- export async function writeKeychainSecret(service, token, { spawnProcess = spawn } = {}) {
85
+ //
86
+ // BOT-1566: every write is verified by reading the item back and comparing it
87
+ // byte-for-byte with what was written. `readSecret` is the seam (defaults to
88
+ // the real `security find-generic-password`); a mismatch — the incident shape
89
+ // where the Keychain held a typed password instead of the OAuth token — throws
90
+ // `keychain_readback_mismatch` so callers fail loudly instead of announcing a
91
+ // successful login. Only equality is asserted, never token shape: profile keys
92
+ // (`bb_agent_…`) are not JWTs.
93
+ export async function writeKeychainSecret(service, token, {
94
+ spawnProcess = spawn,
95
+ execFileImpl = execFileAsync,
96
+ readSecret = (svc) => readKeychainSecret(svc, { execFileImpl }),
97
+ } = {}) {
78
98
  if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
79
99
  try {
80
100
  await writePasswordPrompt(
@@ -95,6 +115,17 @@ export async function writeKeychainSecret(service, token, { spawnProcess = spawn
95
115
  }
96
116
  throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
97
117
  }
118
+ const stored = await readSecret(service);
119
+ if (stored !== token) {
120
+ // Never echo either value: the written token is a credential and the stored
121
+ // one may be a human's typed password.
122
+ throw new ProfileCredentialStoreError(
123
+ `macOS Keychain read-back verification failed for ${service}: the stored value differs from the credential written. `
124
+ + "If `security` prompted you for a password, delete the item (`security delete-generic-password -s "
125
+ + `${service}\`) and retry.`,
126
+ { code: "keychain_readback_mismatch" },
127
+ );
128
+ }
98
129
  }
99
130
 
100
131
  export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
@@ -117,10 +148,10 @@ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsy
117
148
  }
118
149
  }
119
150
 
120
- export async function writeKeychain(profile, token, { spawnProcess = spawn } = {}) {
151
+ export async function writeKeychain(profile, token, options = {}) {
121
152
  const service = keychainService(profile);
122
153
  if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
123
- return writeKeychainSecret(service, token, { spawnProcess });
154
+ return writeKeychainSecret(service, token, options);
124
155
  }
125
156
 
126
157
  async function readKeychain(profile) {
package/src/api.mjs CHANGED
@@ -71,7 +71,20 @@ export async function resolveCallAuth() {
71
71
  return { error: { ok: false, auth: true, error: "not_authenticated" } };
72
72
  }
73
73
 
74
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null } = {}) {
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 } = {}) {
75
88
  let auth = pinnedAuth;
76
89
  if (!auth) {
77
90
  const resolved = await resolveCallAuth();
@@ -81,7 +94,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
81
94
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
82
95
  let res;
83
96
  try {
84
- res = await fetchImpl(SERVER_URL, {
97
+ res = await fetchImpl(toolEndpoint(tenant), {
85
98
  method: "POST",
86
99
  headers: { "Content-Type": "application/json", ...auth },
87
100
  body: JSON.stringify(body),
@@ -90,7 +103,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
90
103
  } catch (err) {
91
104
  return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
92
105
  }
93
- 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
+ }
94
121
  let payload;
95
122
  try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
96
123
  if (payload.error?.message) return { ok: false, status: res.status, error: payload.error.message };
package/src/auth.mjs CHANGED
@@ -50,7 +50,7 @@ function callbackErrorMessage(err) {
50
50
 
51
51
  // AC-1..AC-13: orchestrate the loopback authorization-code flow.
52
52
  //
53
- // options: { noBrowser }
53
+ // options: { noBrowser, tenant }
54
54
  // deps (all injectable for tests): serverUrl, fetch, openBrowser, saveConfig,
55
55
  // getConfig, log, errorLog, timeoutMs, now.
56
56
  export async function doLogin(options = {}, deps = {}) {
@@ -67,6 +67,8 @@ export async function doLogin(options = {}, deps = {}) {
67
67
  persist = persistOwnerToken,
68
68
  } = deps;
69
69
  const noBrowser = Boolean(options.noBrowser);
70
+ // BOT-1566: optional tenant preselection (validated by the argument parser).
71
+ const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
70
72
 
71
73
  log(`${bold("BotBuddy OAuth Login")}\n`);
72
74
 
@@ -106,7 +108,7 @@ export async function doLogin(options = {}, deps = {}) {
106
108
 
107
109
  // AC-6/AC-7: build the authorization URL and always print it (headless users
108
110
  // open it themselves). The CLI never fetches /authorize.
109
- const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge });
111
+ const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant });
110
112
  log("");
111
113
  log(dim("→ Authorize BotBuddy in your browser:"));
112
114
  log(` ${cyan(authUrl)}`);
@@ -159,6 +161,9 @@ export async function doLogin(options = {}, deps = {}) {
159
161
  // BOT-1520: the durable secret goes to the Keychain (darwin); config.json
160
162
  // keeps only non-secret metadata. On a non-darwin host without a Keychain,
161
163
  // persistOwnerToken falls back to the 0600 config.json and warns.
164
+ // BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
165
+ // propagates from here, so login exits non-zero and the success line below
166
+ // is never printed for a credential that did not verifiably land.
162
167
  const { storedInKeychain } = await persist(
163
168
  { token: tokenData.access_token, expiresAt, clientId },
164
169
  { getConfig, saveConfig, warn: errorLog },
package/src/commands.mjs CHANGED
@@ -14,16 +14,33 @@ import { green, red, cyan, dim, bold, die } from "./utils.mjs";
14
14
  import { VERSION } from "./version.mjs";
15
15
  import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
16
16
  import { runPw } from "./pw/run.mjs";
17
+ import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
18
+
19
+ // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
20
+ // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
21
+ // must not pay for a registry round-trip.
22
+ export function shouldCheckForUpdates(argv) {
23
+ const [command] = argv;
24
+ if (command === "wait") return false;
25
+ if (argv.includes("--json")) return false;
26
+ return true;
27
+ }
17
28
 
18
- export async function run(argv) {
19
- loadConfig();
29
+ export async function run(argv, {
30
+ loadConfig: load = loadConfig,
31
+ warnStale = maybeWarnStale,
32
+ errorLog = (line) => console.error(line),
33
+ } = {}) {
34
+ load();
20
35
  const [command, ...args] = argv;
36
+ if (shouldCheckForUpdates(argv)) await warnStale({ version: VERSION });
21
37
 
22
38
  switch (command) {
23
39
  case "start": return cmdStart(args);
24
- case "login": return cmdLogin(args);
40
+ case "login": return cmdLogin(args, { errorLog });
25
41
  case "logout": return cmdLogout();
26
42
  case "status": return cmdStatus();
43
+ case "update": return cmdUpdate();
27
44
  // Agent-only commands (used by MCP agents, not humans)
28
45
  case "heartbeat": return cmdHeartbeat(args);
29
46
  case "lock": return cmdLock(args);
@@ -74,9 +91,10 @@ ${bold("OPTIONS")}
74
91
  --no-server Don't auto-start codex app-server
75
92
 
76
93
  ${bold("AUTH")}
77
- login [--no-browser] Authenticate via OAuth (opens browser + localhost callback)
94
+ login [--no-browser] [--tenant <slug>]
95
+ Authenticate via OAuth (opens browser + localhost callback)
78
96
  logout Remove saved credentials
79
- status Show current auth status
97
+ status Show current auth status (local metadata + server check)
80
98
  profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
81
99
 
82
100
  ${bold("TOOLS")}
@@ -112,6 +130,7 @@ ${bold("BROWSER LANES")}
112
130
 
113
131
  ${bold("OTHER")}
114
132
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
133
+ update Install the latest @botbuddy/cli in the background via npm
115
134
  help Show this help
116
135
  version Show version`);
117
136
  }
@@ -141,15 +160,57 @@ async function cmdToolHelp(args) {
141
160
  console.log(formatDiscovery(await res.json(), { tool }));
142
161
  }
143
162
 
163
+ // BOT-1566 C1: a tenant slug as the server accepts it on `?tenant=` — lowercase
164
+ // alphanumerics and hyphens, 2–63 chars, not starting with a hyphen.
165
+ export const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
166
+
167
+ export class LoginUsageError extends Error {
168
+ constructor(message, { exitCode = 1 } = {}) {
169
+ super(message);
170
+ this.name = "LoginUsageError";
171
+ this.exitCode = exitCode;
172
+ }
173
+ }
174
+
175
+ // Parse `botbuddy login` options. `--tenant <slug>` / `--tenant=<slug>`
176
+ // preselects the tenant on the OAuth picker; a malformed slug is a usage
177
+ // error with exit code 2 (never sent to the server).
178
+ export function parseLoginArgs(args) {
179
+ const out = { noBrowser: false, tenant: null, help: false };
180
+ for (let i = 0; i < args.length; i++) {
181
+ const arg = args[i];
182
+ if (arg === "--help" || arg === "-h") { out.help = true; continue; }
183
+ if (arg === "--no-browser") { out.noBrowser = true; continue; }
184
+ if (arg === "--tenant" || arg.startsWith("--tenant=")) {
185
+ const value = arg.startsWith("--tenant=") ? arg.slice("--tenant=".length) : args[++i];
186
+ if (typeof value !== "string" || !TENANT_SLUG_RE.test(value)) {
187
+ throw new LoginUsageError("error: --tenant must be a lowercase slug", { exitCode: 2 });
188
+ }
189
+ out.tenant = value;
190
+ continue;
191
+ }
192
+ if (arg.startsWith("-")) {
193
+ throw new LoginUsageError(`Unknown login option: ${arg}. Run ${cyan("botbuddy login --help")}.`);
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+
144
199
  // BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
145
- // waits for the localhost callback). Parse --no-browser and print focused help.
146
- async function cmdLogin(args) {
147
- if (args.includes("--help") || args.includes("-h")) return loginHelp();
148
- const noBrowser = args.includes("--no-browser");
149
- const unknown = args.find((a) => a.startsWith("-") && !["--no-browser", "--help", "-h"].includes(a));
150
- if (unknown) die(`Unknown login option: ${unknown}. Run ${cyan("botbuddy login --help")}.`);
200
+ // waits for the localhost callback). Parse --no-browser / --tenant and print
201
+ // focused help.
202
+ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {}) {
203
+ let options;
151
204
  try {
152
- await doLogin({ noBrowser });
205
+ options = parseLoginArgs(args);
206
+ } catch (err) {
207
+ if (!(err instanceof LoginUsageError)) throw err;
208
+ if (err.exitCode === 2) { errorLog(err.message); return 2; }
209
+ die(err.message);
210
+ }
211
+ if (options.help) return loginHelp();
212
+ try {
213
+ await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant });
153
214
  } catch (err) {
154
215
  die(err.message);
155
216
  }
@@ -159,7 +220,7 @@ function loginHelp() {
159
220
  console.log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
160
221
 
161
222
  ${bold("USAGE")}
162
- botbuddy login [--no-browser]
223
+ botbuddy login [--no-browser] [--tenant <slug>]
163
224
 
164
225
  ${bold("HOW IT WORKS")}
165
226
  Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
@@ -173,6 +234,10 @@ ${bold("OPTIONS")}
173
234
  --no-browser Don't launch a browser; print the authorization URL to open
174
235
  in a browser on THIS machine yourself. The callback listener
175
236
  still runs on this machine's localhost.
237
+ --tenant <slug>
238
+ Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
239
+ your account belongs to more than one. Must be a lowercase slug;
240
+ membership is still enforced by the server.
176
241
 
177
242
  ${bold("NOTES")}
178
243
  • The authorization URL is always printed so you can open it manually.
@@ -216,34 +281,92 @@ async function cmdStart(args) {
216
281
  return runBridge(args);
217
282
  }
218
283
 
219
- async function cmdStatus() {
220
- const cfg = getConfig();
221
- const owner = await resolveOwnerToken({ getConfig });
284
+ // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
285
+ // credential is actually accepted. `status` used to decode only local metadata,
286
+ // so a Keychain item holding a typed password (the BOT-1566 incident) was
287
+ // reported as "Authenticated". One extra line, never throws, exit code
288
+ // unchanged:
289
+ // server: authenticated (<tenant slug>) whoami resolved
290
+ // server: rejected (<error message>) the server refused the credential
291
+ // server: unreachable network / transport failure
292
+ //
293
+ // The probe is bounded by STATUS_PROBE_TIMEOUT_MS: a server that accepts the
294
+ // connection but stalls before headers must not hang this formerly local
295
+ // diagnostic — an aborted fetch returns a `transport:` error → `unreachable`
296
+ // (BOT-1566, Codex P2).
297
+ const STATUS_PROBE_TIMEOUT_MS = 4000;
298
+
299
+ async function logServerStatus(call, auth, log) {
300
+ let res;
301
+ try {
302
+ res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS) });
303
+ } catch {
304
+ log(` server: ${red("unreachable")}`);
305
+ return;
306
+ }
307
+ if (res?.ok && !res.isError) {
308
+ const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
309
+ log(` server: ${green("authenticated")} (${slug})`);
310
+ return;
311
+ }
312
+ if (res?.ok && res.isError) {
313
+ log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
314
+ return;
315
+ }
316
+ if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
317
+ log(` server: ${red("unreachable")}`);
318
+ return;
319
+ }
320
+ log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
321
+ }
322
+
323
+ export async function cmdStatus({
324
+ getConfig: getCfg = getConfig,
325
+ resolveOwnerToken: resolveOwner = resolveOwnerToken,
326
+ resolveAgentKey: resolveAgent = resolveAgentKey,
327
+ callToolJson: call = callToolJson,
328
+ log = (line) => console.log(line),
329
+ now = () => Date.now(),
330
+ } = {}) {
331
+ const cfg = getCfg();
332
+ const owner = await resolveOwner({ getConfig: getCfg });
222
333
  if (owner) {
223
- console.log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
224
- if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
225
- if (cfg.client_id) console.log(` Client: ${dim(cfg.client_id)}`);
334
+ log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
335
+ if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
336
+ if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
226
337
  if (owner.expiresAt) {
227
- const remaining = owner.expiresAt - Date.now();
338
+ const remaining = owner.expiresAt - now();
228
339
  if (remaining <= 0) {
229
- console.log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
340
+ log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
230
341
  } else {
231
342
  const mins = Math.round(remaining / 60000);
232
343
  const label = mins > 60 ? `${Math.round(mins / 60)}h ${mins % 60}m` : `${mins}m`;
233
- console.log(` Token expires in: ${dim(label)}`);
344
+ log(` Token expires in: ${dim(label)}`);
234
345
  }
235
346
  }
236
- console.log(` Config: ${dim(getConfigPath())}`);
347
+ log(` Config: ${dim(getConfigPath())}`);
348
+ // BOT-1566 (Codex P2): match resolveCallAuth — an expired owner token defers
349
+ // to a valid profile agent key, so probe the credential real commands would
350
+ // actually use instead of reporting a false `server: rejected` on the dead
351
+ // bearer while authenticated commands still succeed via the key.
352
+ const ownerExpired = owner.expiresAt && owner.expiresAt <= now();
353
+ const fallbackKey = ownerExpired ? await resolveAgent() : null;
354
+ await logServerStatus(
355
+ call,
356
+ fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
357
+ log,
358
+ );
237
359
  return;
238
360
  }
239
- const agentKey = await resolveAgentKey();
361
+ const agentKey = await resolveAgent();
240
362
  if (agentKey) {
241
- console.log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
242
- if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
363
+ log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
364
+ if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
365
+ await logServerStatus(call, { "x-agent-api-key": agentKey }, log);
243
366
  return;
244
367
  }
245
- console.log(`${red("✗")} Not authenticated`);
246
- console.log(` Run: ${cyan("botbuddy start")}`);
368
+ log(`${red("✗")} Not authenticated`);
369
+ log(` Run: ${cyan("botbuddy start")}`);
247
370
  }
248
371
 
249
372
  async function cmdLogout() {
@@ -46,7 +46,7 @@ export function generateState() {
46
46
 
47
47
  // AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
48
48
  // never fetches it. `redirectUri` must be the exact loopback callback we bound.
49
- export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock" }) {
49
+ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock", tenant = null }) {
50
50
  const url = new URL(`${serverUrl}/authorize`);
51
51
  url.searchParams.set("client_id", clientId);
52
52
  url.searchParams.set("redirect_uri", redirectUri);
@@ -55,6 +55,10 @@ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, cod
55
55
  url.searchParams.set("state", state);
56
56
  url.searchParams.set("code_challenge", codeChallenge);
57
57
  url.searchParams.set("code_challenge_method", "S256");
58
+ // BOT-1566: `login --tenant <slug>` preselects the tenant. /authorize forwards
59
+ // it to the /mcp-auth picker as `requestedTenant`; membership is still
60
+ // enforced server-side at authorize_complete — this only preselects.
61
+ if (tenant) url.searchParams.set("tenant", tenant);
58
62
  return url.toString();
59
63
  }
60
64
 
@@ -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
@@ -81,8 +90,27 @@ export async function bootstrapProfile(profileName, {
81
90
  const pinned = await resolveAuth();
82
91
  if (pinned?.error) throw new ProfileBootstrapError("profile_agent_required");
83
92
  const auth = pinned?.auth ?? null;
84
- const identity = await call("whoami", {}, { auth });
85
- const identityCode = typeof identity?.data?.code === "string" ? identity.data.code : 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
+ }
86
114
  if (identityCode === "MCP_TENANT_SELECTION_REQUIRED" || identityCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY") {
87
115
  throw new ProfileBootstrapError("profile_tenant_selection_required");
88
116
  }
@@ -100,14 +128,18 @@ export async function bootstrapProfile(profileName, {
100
128
  type: "codex",
101
129
  ...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
102
130
  };
103
- const response = await call("register_agent", args, { auth });
131
+ const response = await call("register_agent", args, { auth, tenant });
104
132
  const data = response?.data;
133
+ const responseCode = structuredCode(response);
105
134
  // Defense-in-depth: whoami above already gated on the tenant, so register_agent
106
135
  // should not return a tenant-context error here — but if the contract drifts,
107
136
  // surface its own actionable recovery, not the misleading
108
137
  // `profile_agent_required` → `botbuddy login` loop that keeps failing because
109
138
  // login itself is fine.
110
- if (data?.code === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY" || data?.code === "MCP_TENANT_SELECTION_REQUIRED") {
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") {
111
143
  throw new ProfileBootstrapError("profile_tenant_selection_required");
112
144
  }
113
145
  if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
@@ -173,6 +205,16 @@ export function profileBootstrapRecovery(code, profileName) {
173
205
  if (code === "profile_credential_persist_failed") {
174
206
  return `ensure ~/.botbuddy is writable and no other setup is running, then re-run: botbuddy profile setup ${profileName}`;
175
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
+ }
176
218
  if (code === "profile_tenant_unverified") {
177
219
  // whoami could not confirm the bound tenant, so registration was refused
178
220
  // before any mutation. Usually transient — retry; login only if it persists.
@@ -0,0 +1,127 @@
1
+ // BOT-1566 D: stale-CLI warning + `botbuddy update`.
2
+ //
3
+ // The CLI is published to npm; a human running an old build gets no signal
4
+ // that a fix already shipped (the BOT-1566 keychain bug was found on a CLI two
5
+ // releases behind). `maybeWarnStale` prints ONE line to stderr when npm's
6
+ // `latest` is newer than the running version. It is throttled through a 24h
7
+ // JSON cache at ~/.botbuddy/update-check.json, bounded by a 1500 ms registry
8
+ // timeout, and fail-open: any error (offline, registry down, unreadable cache)
9
+ // is silent. It never writes to stdout — JSON receipts own stdout.
10
+ //
11
+ // `cmdUpdate` deliberately does NOT update in-process (BOT-1499: a self-update
12
+ // that replaces its own running files can kill itself mid-install). It hands
13
+ // off to a detached, unref'd `npm install -g` and exits 0.
14
+
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { spawn } from "node:child_process";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ import { VERSION } from "./version.mjs";
21
+
22
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
+ export const NPM_LATEST_URL = "https://registry.npmjs.org/@botbuddy/cli/latest";
24
+ export const NPM_LATEST_TIMEOUT_MS = 1500;
25
+ export const UPDATE_COMMAND = "npm i -g @botbuddy/cli@latest";
26
+
27
+ export function defaultUpdateCheckCachePath(home = homedir()) {
28
+ return join(home, ".botbuddy", "update-check.json");
29
+ }
30
+
31
+ function parseSemver(value) {
32
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? "").trim());
33
+ if (!match) return null;
34
+ return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] ?? null };
35
+ }
36
+
37
+ // True when `latest` is strictly greater than `current`. Numeric per segment;
38
+ // a prerelease of the same core version ranks below the release. Unparseable
39
+ // input is never "newer" — a garbage registry answer must not nag.
40
+ export function isNewerVersion(latest, current) {
41
+ const a = parseSemver(latest);
42
+ const b = parseSemver(current);
43
+ if (!a || !b) return false;
44
+ for (let i = 0; i < 3; i++) {
45
+ if (a.parts[i] !== b.parts[i]) return a.parts[i] > b.parts[i];
46
+ }
47
+ if (a.prerelease && !b.prerelease) return false;
48
+ if (!a.prerelease && b.prerelease) return true;
49
+ return false;
50
+ }
51
+
52
+ export async function fetchLatestFromNpm({ fetchImpl = fetch, timeoutMs = NPM_LATEST_TIMEOUT_MS } = {}) {
53
+ const res = await fetchImpl(NPM_LATEST_URL, {
54
+ headers: { accept: "application/json" },
55
+ signal: AbortSignal.timeout(timeoutMs),
56
+ });
57
+ if (!res.ok) throw new Error(`npm registry responded ${res.status}`);
58
+ const body = await res.json();
59
+ if (typeof body?.version !== "string") throw new Error("npm registry response carried no version");
60
+ return body.version;
61
+ }
62
+
63
+ async function readCache(cachePath) {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
66
+ if (typeof parsed?.checkedAt !== "number") return null;
67
+ // `latest` is a version string, or null for a recorded-but-failed attempt
68
+ // (offline/registry-down) that still counts toward the 24h throttle.
69
+ if (parsed.latest !== null && typeof parsed.latest !== "string") return null;
70
+ return parsed;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ async function writeCache(cachePath, entry) {
77
+ await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
78
+ await writeFile(cachePath, JSON.stringify(entry), { mode: 0o600 });
79
+ }
80
+
81
+ export async function maybeWarnStale({
82
+ version = VERSION,
83
+ fetchLatest = fetchLatestFromNpm,
84
+ now = () => Date.now(),
85
+ cachePath = defaultUpdateCheckCachePath(),
86
+ stderr = process.stderr,
87
+ } = {}) {
88
+ try {
89
+ const cached = await readCache(cachePath);
90
+ let latest;
91
+ if (cached && now() - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
92
+ latest = cached.latest;
93
+ } else {
94
+ // BOT-1566 (Codex P2): run() awaits this for nearly every command, so a
95
+ // failed lookup (offline/registry-down/garbage) MUST still stamp the
96
+ // attempt — otherwise the throttle never engages and every invocation
97
+ // retries and can eat the full timeout. Retain any previously-known
98
+ // latest so we can still warn from it.
99
+ let fetched = null;
100
+ try {
101
+ fetched = await fetchLatest();
102
+ } catch {
103
+ fetched = null;
104
+ }
105
+ latest = typeof fetched === "string" ? fetched : (cached?.latest ?? null);
106
+ await writeCache(cachePath, { checkedAt: now(), latest });
107
+ }
108
+ if (typeof latest !== "string" || !isNewerVersion(latest, version)) return { warned: false, latest: latest ?? null };
109
+ stderr.write(`botbuddy: update available ${version} → ${latest}: ${UPDATE_COMMAND}\n`);
110
+ return { warned: true, latest };
111
+ } catch {
112
+ return { warned: false, latest: null };
113
+ }
114
+ }
115
+
116
+ // `botbuddy update` — hand off to npm and get out of the way.
117
+ export function cmdUpdate({ version = VERSION, spawnImpl = spawn, log = (line) => console.log(line), platform = process.platform } = {}) {
118
+ log(`current: ${version}`);
119
+ // BOT-1566 (Codex P2): on Windows npm is the `npm.cmd` shim, and a shell-free
120
+ // spawn cannot execute .cmd scripts (Node docs) — it would ENOENT after we
121
+ // already claimed the update started. Select the platform-correct binary.
122
+ const npmBin = platform === "win32" ? "npm.cmd" : "npm";
123
+ const child = spawnImpl(npmBin, ["install", "-g", "@botbuddy/cli@latest"], { stdio: "inherit", detached: true });
124
+ child.unref();
125
+ log("updating in background — re-run botbuddy --version to confirm");
126
+ return 0;
127
+ }
@@ -1 +0,0 @@
1
- {"schema_version":1,"source_version":"1.9.0","source_identity":"8bc02282f116b4857951c61a187ad7894c3153b36559b46cd171df24b0af98c4"}