@botbuddy/cli 1.33.11 → 1.34.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.33.11",
3
+ "version": "1.34.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -5,7 +5,7 @@ import { AGENT_KEY_RE } from "./agent-key.mjs";
5
5
  // BOT-1864: api.mjs ↔ agent-session.mjs is a deliberate import cycle — each side
6
6
  // only CALLS the other's functions at request time, never at module evaluation.
7
7
  import { resolveAgentSessionCredential } from "./agent-session.mjs";
8
- import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
8
+ import { die, red, cyan, dim, yellow, prettyJson } from "./utils.mjs";
9
9
 
10
10
  // Same slug shape the server accepts on `?tenant=`.
11
11
  const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -57,7 +57,7 @@ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenan
57
57
  // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
58
58
  // BOT-1864: the generic commands (`bb call`, resources, heartbeat/locks/tasks)
59
59
  // first honour this worktree's tier-3 session — see resolveCallAuth.
60
- async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {}) {
60
+ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth, exitCode = 1 } = {}) {
61
61
  const resolved = await resolveAuth({ toolName, preferSession: true });
62
62
  if (resolved.auth) {
63
63
  const expiresAt = resolved.ownerExpiresAt ?? null;
@@ -66,12 +66,15 @@ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {
66
66
  }
67
67
  return resolved;
68
68
  }
69
+ // BOT-1896: a missing/expired local credential is auth-class — callTool passes
70
+ // CALL_EXIT.AUTH so it never collapses into the generic exit 1.
71
+ const fail = (msg) => { console.error(`${red("✗")} ${msg}`); process.exit(exitCode); };
69
72
  const code = resolved.error?.error;
70
- if (code === "token_expired") die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
73
+ if (code === "token_expired") fail(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
71
74
  if (code === "session_token_malformed") {
72
- die(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
75
+ fail(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
73
76
  }
74
- die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
77
+ fail(`Not authenticated. Run: ${cyan("botbuddy login")}`);
75
78
  }
76
79
 
77
80
  // BOT-1864: a cached session the server rejects (revoked by a concurrent
@@ -96,39 +99,200 @@ async function requestEndpoint(resolved, { tenant, resolvePin }) {
96
99
  return SESSION_SOURCES.has(resolved?.source) ? toolEndpoint(tenant) : genericEndpoint({ tenant, resolvePin });
97
100
  }
98
101
 
99
- export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line) } = {}) {
102
+ // BOT-1896: `botbuddy call` (and every command riding callTool) exits with a
103
+ // code that tells a tenant gate WHY it failed: AUTH when BotBuddy refused the
104
+ // credential (fail closed), TRANSPORT when BotBuddy could not be reached or
105
+ // answered with a gateway/5xx failure (the only case that may degrade to a
106
+ // local-only lock). The values reuse `botbuddy stack`'s EXIT table (stack.mjs:
107
+ // AUTH 3, BACKEND 5) — restated rather than imported because stack.mjs imports
108
+ // this module; bot1896-call-auth-exit.test.mjs pins them equal.
109
+ // ERROR (1) is the generic failure: a reachable server said something other than
110
+ // "ok" or "auth" (a proxy 404, a 429 page, …). A gate must fail closed on it —
111
+ // only TRANSPORT licenses a local-only fallback.
112
+ export const CALL_EXIT = Object.freeze({ OK: 0, ERROR: 1, AUTH: 3, TRANSPORT: 5 });
113
+
114
+ // BOT-1896: mirror of the server's AUTH_CLASS_ERROR_CODES
115
+ // (supabase/functions/_shared/lib/authDenial.ts), used when an older server
116
+ // sends a typed code without `error_class`. Pinned equal by the same test.
117
+ export const CALL_AUTH_ERROR_CODES = new Set([
118
+ "scope_denied",
119
+ "session_token_revoked",
120
+ "session_token_expired",
121
+ "session_token_scope",
122
+ "credential_unknown",
123
+ "credential_missing",
124
+ "MCP_REAUTH_TENANT_REQUIRED",
125
+ "MCP_TENANT_PIN_REQUIRED",
126
+ "MCP_TENANT_SESSION_MISMATCH",
127
+ "MCP_TENANT_MEMBERSHIP_REQUIRED",
128
+ "MCP_TENANT_PIN_UNSUPPORTED",
129
+ ]);
130
+
131
+ // BOT-1896: the codes the server's 401 gates emit when a request authenticated
132
+ // as nothing — a dead/absent credential, never a tenant refusal.
133
+ const UNAUTHENTICATED_CODES = new Set(["credential_unknown", "credential_missing", "session_token_revoked", "session_token_expired"]);
134
+
135
+ function typedCodeOf(fields) {
136
+ if (!fields || typeof fields !== "object") return null;
137
+ const raw = fields.error_code ?? fields.code;
138
+ return typeof raw === "string" && raw ? raw : null;
139
+ }
140
+
141
+ function isAuthClass(fields) {
142
+ return Boolean(fields && typeof fields === "object" &&
143
+ (fields.error_class === "auth" || CALL_AUTH_ERROR_CODES.has(typedCodeOf(fields))));
144
+ }
145
+
146
+ /**
147
+ * BOT-1896: classify one tools/call exchange. Pure — no I/O.
148
+ * kind "ok" — a normal result (printed as before, exit 0)
149
+ * kind "tool_error" — a non-auth isError result (printed as before, exit 0)
150
+ * kind "server_error" — a non-auth JSON-RPC error (die as before, exit 1)
151
+ * kind "auth" — BotBuddy refused the credential → receipt, CALL_EXIT.AUTH
152
+ * kind "transport" — no HTTP response, or a 5xx → receipt, CALL_EXIT.TRANSPORT
153
+ * kind "http_error" — any other HTTP failure (non-JSON or error-less 4xx,
154
+ * a non-JSON 2xx) → receipt, CALL_EXIT.ERROR (fail closed)
155
+ * The receipt carries the server's `error_code` and typed fields UNCHANGED. A
156
+ * 401/403 is auth even with no typed code (an older server), never transport.
157
+ */
158
+ export function classifyCallResponse({ status = null, data = null, transportError = null, parseError = null } = {}) {
159
+ if (transportError) {
160
+ return {
161
+ kind: "transport",
162
+ receipt: { ok: false, error_class: "transport", error_code: "transport_error", http_status: null, message: String(transportError?.message ?? transportError) },
163
+ };
164
+ }
165
+ const authStatus = status === 401 || status === 403;
166
+ if (parseError || !data || typeof data !== "object") {
167
+ if (authStatus) {
168
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: "BotBuddy refused the credential (no typed body)." } };
169
+ }
170
+ // Codex P1 (PR #970): only a 5xx gateway page reads as transport. A reachable
171
+ // endpoint answering non-JSON with anything else (a proxy 404 from a wrong
172
+ // URL, a 429 rate-limit page) must fail closed, never license a fallback.
173
+ return typeof status === "number" && status >= 500
174
+ ? { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "invalid_response", http_status: status, message: `BotBuddy returned a non-JSON response (HTTP ${status}).` } }
175
+ : httpError(status, `BotBuddy returned a non-JSON response (HTTP ${status ?? "?"}).`);
176
+ }
177
+ const rpcError = data.error && typeof data.error === "object" ? data.error : null;
178
+ if (rpcError) {
179
+ const detail = rpcError.data && typeof rpcError.data === "object" ? rpcError.data : {};
180
+ if (isAuthClass(detail) || authStatus || rpcError.code === -32001) {
181
+ return {
182
+ kind: "auth",
183
+ receipt: { ok: false, ...detail, error_class: "auth", error_code: typedCodeOf(detail) ?? "unauthorized", http_status: status, message: rpcError.message ?? null },
184
+ };
185
+ }
186
+ if (typeof status === "number" && status >= 500) {
187
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: rpcError.message ?? null } };
188
+ }
189
+ return { kind: "server_error", receipt: null };
190
+ }
191
+ // A bare REST-style `{ error }` body (not JSON-RPC).
192
+ if (typeof data.error === "string") {
193
+ if (isAuthClass(data) || authStatus) {
194
+ return { kind: "auth", receipt: { ok: false, ...data, error_class: "auth", error_code: typedCodeOf(data) ?? "unauthorized", http_status: status, message: data.error } };
195
+ }
196
+ if (typeof status === "number" && status >= 500) {
197
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: data.error } };
198
+ }
199
+ return { kind: "server_error", receipt: null };
200
+ }
201
+ if (authStatus) {
202
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: null } };
203
+ }
204
+ if (typeof status === "number" && status >= 500) {
205
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: null } };
206
+ }
207
+ if (typeof status === "number" && status >= 400) {
208
+ return httpError(status, null);
209
+ }
210
+ if (data.result?.isError === true) {
211
+ const first = data.result?.content?.[0]?.text;
212
+ let envelope = null;
213
+ try { envelope = typeof first === "string" ? JSON.parse(first) : null; } catch { envelope = null; }
214
+ if (isAuthClass(envelope)) {
215
+ const { success: _success, ...fields } = envelope;
216
+ return { kind: "auth", receipt: { ok: false, ...fields, error_class: "auth", error_code: typedCodeOf(envelope), http_status: status } };
217
+ }
218
+ return { kind: "tool_error", receipt: null };
219
+ }
220
+ return { kind: "ok", receipt: null };
221
+ }
222
+
223
+ function httpError(status, message) {
224
+ return { kind: "http_error", receipt: { ok: false, error_class: "http", error_code: `http_${status ?? "unknown"}`, http_status: status, message } };
225
+ }
226
+
227
+ function authFailureLine(receipt) {
228
+ const what = receipt.error_code === "scope_denied" && receipt.tool && receipt.required_scope
229
+ ? `scope_denied: \`${receipt.tool}\` requires the \`${receipt.required_scope}\` scope${receipt.key_prefix ? ` (presented a ${receipt.key_prefix} key)` : ""}`
230
+ : receipt.error_code;
231
+ return `BotBuddy refused the credential (${what}).${receipt.remedy ? ` ${receipt.remedy}` : ""}`;
232
+ }
233
+
234
+ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
100
235
  const body = {
101
236
  jsonrpc: "2.0",
102
237
  id: 1,
103
238
  method: "tools/call",
104
239
  params: { name: toolName, arguments: args },
105
240
  };
241
+ // BOT-1896: never throw on the wire — a fetch rejection or a non-JSON body is
242
+ // returned as a classified transport failure instead of an uncaught exit 1.
106
243
  const send = async (resolved) => {
107
244
  const headers = { "Content-Type": "application/json", ...resolved.auth };
108
- const res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
109
- return { status: res.status, data: await res.json() };
245
+ let res;
246
+ try {
247
+ res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
248
+ } catch (transportError) {
249
+ return { status: null, data: null, transportError };
250
+ }
251
+ try {
252
+ return { status: res.status, data: await res.json() };
253
+ } catch (parseError) {
254
+ return { status: res.status, data: null, parseError };
255
+ }
110
256
  };
111
257
 
112
- const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth });
113
- let { status, data } = await send(first);
258
+ const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth, exitCode: CALL_EXIT.AUTH });
259
+ let exchange = await send(first);
114
260
  let staleSession = false;
115
- if (!auth && isRejectedCachedSessionResponse(first, status, data)) {
261
+ if (!auth && isRejectedCachedSessionResponse(first, exchange.status, exchange.data)) {
116
262
  const durable = await resolveAuth({ toolName, preferSession: false });
117
- if (durable.auth) ({ status, data } = await send(durable));
263
+ if (durable.auth) exchange = await send(durable);
118
264
  else staleSession = true;
119
265
  }
266
+ const { status, data } = exchange;
120
267
  const exportedRejected = !auth && (first.source === "env" || first.source === "flag")
121
268
  && (status === 401 || status === 403 || data?.error?.code === -32001);
122
269
 
270
+ const code = data?.error?.data?.code;
271
+ const hint = code === "MCP_TENANT_PIN_REQUIRED"
272
+ ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
273
+ : staleSession
274
+ ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
275
+ : exportedRejected
276
+ ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
277
+ : "";
278
+
279
+ const outcome = classifyCallResponse(exchange);
280
+ if (outcome.kind === "auth") {
281
+ log(JSON.stringify(outcome.receipt, null, 2));
282
+ errorLog(`${red("✗")} ${authFailureLine(outcome.receipt)}${hint}`);
283
+ return CALL_EXIT.AUTH;
284
+ }
285
+ if (outcome.kind === "transport") {
286
+ log(JSON.stringify(outcome.receipt, null, 2));
287
+ errorLog(`${red("✗")} BotBuddy unreachable (${outcome.receipt.error_code}${outcome.receipt.http_status ? `, HTTP ${outcome.receipt.http_status}` : ""}): ${outcome.receipt.message ?? ""}`);
288
+ return CALL_EXIT.TRANSPORT;
289
+ }
290
+ if (outcome.kind === "http_error") {
291
+ log(JSON.stringify(outcome.receipt, null, 2));
292
+ errorLog(`${red("✗")} BotBuddy answered HTTP ${outcome.receipt.http_status ?? "?"} (${outcome.receipt.error_code}); not treating this as unreachable.${hint}`);
293
+ return CALL_EXIT.ERROR;
294
+ }
123
295
  if (data.error?.message) {
124
- const code = data.error?.data?.code;
125
- const hint = code === "MCP_TENANT_PIN_REQUIRED"
126
- ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
127
- : staleSession
128
- ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
129
- : exportedRejected
130
- ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
131
- : "";
132
296
  die(`Server error: ${data.error.message}${hint}`);
133
297
  }
134
298
 
@@ -260,13 +424,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
260
424
  // { error: { code: -32002, data: { code } } }). Surface that `code` so a
261
425
  // caller can tell a tenant refusal apart from a plain credential failure.
262
426
  // Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
427
+ // BOT-1896: the unauthenticated 401 now carries a typed code too
428
+ // (credential_unknown / session_token_revoked / …). Those are exposed as
429
+ // `error_code` but NOT lifted into `code`, which callers (mcp-key,
430
+ // isRejectedCachedMcpSession) read as "a structured refusal other than a dead
431
+ // credential" — so a bad key keeps its not-authenticated / self-heal path.
263
432
  let code = null;
433
+ let errorCode = null;
264
434
  try {
265
435
  const body = await res.json();
266
436
  const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
267
- if (typeof raw === "string" && raw) code = raw;
437
+ if (typeof raw === "string" && raw && !UNAUTHENTICATED_CODES.has(raw)) code = raw;
438
+ const typed = body?.error?.data?.error_code ?? body?.error_code ?? null;
439
+ if (typeof typed === "string" && typed) errorCode = typed;
268
440
  } catch { /* opaque body */ }
269
- return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
441
+ return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}), ...(errorCode ? { error_code: errorCode } : {}) };
270
442
  }
271
443
  let payload;
272
444
  try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
package/src/stack.mjs CHANGED
@@ -27,7 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
27
27
  import { tmpdir } from "os";
28
28
  import { basename, dirname, join, relative, isAbsolute } from "path";
29
29
  import { randomUUID } from "crypto";
30
- import { callToolJson } from "./api.mjs";
30
+ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
@@ -696,6 +696,9 @@ export function stackPathRootRefusalNotice(code, message) {
696
696
  * reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
697
697
  */
698
698
  export function leaseRefusalExit(code) {
699
+ // BOT-1896: an auth-class refusal (scope_denied, a tenant mismatch, …) is the
700
+ // server refusing the credential — never BACKEND, which a gate reads as "down".
701
+ if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
699
702
  return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
700
703
  }
701
704