@botbuddy/cli 1.33.7 → 1.33.9

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/api.mjs +117 -26
  3. package/src/wait.mjs +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.7",
3
+ "version": "1.33.9",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  import { getConfig, SERVER_URL } from "./config.mjs";
2
2
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
3
3
  import { readAgentBinding } from "./wait-profile.mjs";
4
+ import { AGENT_KEY_RE } from "./agent-key.mjs";
5
+ // BOT-1864: api.mjs ↔ agent-session.mjs is a deliberate import cycle — each side
6
+ // only CALLS the other's functions at request time, never at module evaluation.
7
+ import { resolveAgentSessionCredential } from "./agent-session.mjs";
4
8
  import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
5
9
 
6
10
  // Same slug shape the server accepts on `?tenant=`.
@@ -51,41 +55,80 @@ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenan
51
55
  // BOT-1520: auth headers come from the Keychain, never a plaintext config.json
52
56
  // secret. The owner OAuth token (from `botbuddy login`) is preferred; the
53
57
  // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
54
- async function authHeader() {
55
- const owner = await resolveOwnerToken({ getConfig });
56
- if (owner) {
57
- if (owner.expiresAt && Date.now() >= owner.expiresAt) {
58
- const agentKey = await resolveAgentKey();
59
- if (agentKey) return { "x-agent-api-key": agentKey };
60
- die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
61
- }
62
- if (owner.expiresAt && owner.expiresAt - Date.now() < 5 * 60 * 1000) {
58
+ // BOT-1864: the generic commands (`bb call`, resources, heartbeat/locks/tasks)
59
+ // first honour this worktree's tier-3 session — see resolveCallAuth.
60
+ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {}) {
61
+ const resolved = await resolveAuth({ toolName, preferSession: true });
62
+ if (resolved.auth) {
63
+ const expiresAt = resolved.ownerExpiresAt ?? null;
64
+ if (expiresAt && expiresAt - Date.now() < 5 * 60 * 1000) {
63
65
  console.error(`${yellow("⚠")} Token expires in <5 minutes. Run ${cyan("botbuddy login")} soon.`);
64
66
  }
65
- return { Authorization: `Bearer ${owner.token}` };
67
+ return resolved;
68
+ }
69
+ const code = resolved.error?.error;
70
+ if (code === "token_expired") die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
71
+ 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.`);
66
73
  }
67
- const agentKey = await resolveAgentKey();
68
- if (agentKey) return { "x-agent-api-key": agentKey };
69
74
  die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
70
75
  }
71
76
 
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()) };
77
+ // BOT-1864: a cached session the server rejects (revoked by a concurrent
78
+ // re-register, or expired server-side while locally fresh) must not strand a
79
+ // `bb call` that worked before this change — retry ONCE with the durable
80
+ // owner/MCP-key credential. An exported/flag token belongs to the caller and is
81
+ // never silently swapped; a tenant refusal (-32002) is a typed verdict another
82
+ // credential cannot fix.
83
+ function isRejectedCachedSessionResponse(resolved, status, payload) {
84
+ if (resolved?.source !== "cache") return false;
85
+ const rpcCode = payload?.error?.code;
86
+ if (rpcCode === -32002) return false;
87
+ return status === 401 || status === 403 || rpcCode === -32001;
88
+ }
89
+
90
+ const SESSION_SOURCES = new Set(["cache", "env", "flag"]);
91
+
92
+ // A session token is tenant-bound: never DERIVE a pin for it (a differing
93
+ // derived pin would be a spurious conflict). An explicit --pin is still sent —
94
+ // the server lets a pin confirm the session's tenant, never repoint it.
95
+ async function requestEndpoint(resolved, { tenant, resolvePin }) {
96
+ return SESSION_SOURCES.has(resolved?.source) ? toolEndpoint(tenant) : genericEndpoint({ tenant, resolvePin });
97
+ }
98
+
99
+ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line) } = {}) {
74
100
  const body = {
75
101
  jsonrpc: "2.0",
76
102
  id: 1,
77
103
  method: "tools/call",
78
104
  params: { name: toolName, arguments: args },
79
105
  };
106
+ const send = async (resolved) => {
107
+ 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() };
110
+ };
80
111
 
81
- const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
82
- const data = await res.json();
112
+ const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth });
113
+ let { status, data } = await send(first);
114
+ let staleSession = false;
115
+ if (!auth && isRejectedCachedSessionResponse(first, status, data)) {
116
+ const durable = await resolveAuth({ toolName, preferSession: false });
117
+ if (durable.auth) ({ status, data } = await send(durable));
118
+ else staleSession = true;
119
+ }
120
+ const exportedRejected = !auth && (first.source === "env" || first.source === "flag")
121
+ && (status === 401 || status === 403 || data?.error?.code === -32001);
83
122
 
84
123
  if (data.error?.message) {
85
124
  const code = data.error?.data?.code;
86
125
  const hint = code === "MCP_TENANT_PIN_REQUIRED"
87
126
  ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
88
- : "";
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
+ : "";
89
132
  die(`Server error: ${data.error.message}${hint}`);
90
133
  }
91
134
 
@@ -112,17 +155,63 @@ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth =
112
155
  // bound tenant mid-sequence (a TOCTOU that would register in the wrong tenant).
113
156
  // Returns { auth } with the header object, or { error } with the structured
114
157
  // not-authenticated / token-expired receipt to return verbatim.
115
- export async function resolveCallAuth() {
116
- const owner = await resolveOwnerToken({ getConfig });
158
+ //
159
+ // BOT-1864: `preferSession` routes the call through the SAME shared resolver
160
+ // `bb wait` / `bb stack` use (resolveAgentSessionCredential: exported
161
+ // BOTBUDDY_AGENT_SESSION_TOKEN → this worktree's fresh 0600
162
+ // `.botbuddy/agent-state.json`) BEFORE the Keychain-backed owner/MCP-key path.
163
+ // That cache is the only credential a `user/<uid>` Background LaunchAgent (the
164
+ // BOT-1751 stack canary) can reach — the login Keychain is not. It never
165
+ // self-heals here: minting a session needs the Keychain client key (unreachable
166
+ // in exactly the job this exists for), and a register_agent side effect on every
167
+ // `bb call` would be wrong; a missing/stale cache falls through to the durable
168
+ // path exactly as before. Opt-in, because the programmatic callers (setup,
169
+ // bootstrap, mcp-key, telemetry attestation) deliberately act as the durable
170
+ // principal. Identity-minting tools the server refuses for a session token never
171
+ // ride it (SESSION_TOKEN_DENIED_TOOLS).
172
+ export async function resolveCallAuth({
173
+ toolName = null,
174
+ preferSession = false,
175
+ env = process.env,
176
+ cwd = process.cwd(),
177
+ resolveSession = (opts) => resolveAgentSessionCredential({ ...opts, selfHeal: async () => null }),
178
+ resolveOwner = () => resolveOwnerToken({ getConfig }),
179
+ resolveAgentKeyFn = resolveAgentKey,
180
+ } = {}) {
181
+ if (preferSession && !(toolName && SESSION_TOKEN_DENIED_TOOLS.has(toolName))) {
182
+ const session = await resolveSession({ argv: [], env, cwd });
183
+ if (session?.token) {
184
+ // Fail closed on a malformed token rather than silently switching to the
185
+ // owner principal (the stackAuthHeader rule, BOT-1750 R3).
186
+ if (typeof session.token !== "string" || !AGENT_KEY_RE.test(session.token)) {
187
+ return { error: { ok: false, auth: true, error: "session_token_malformed", source: session.source ?? null } };
188
+ }
189
+ return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null };
190
+ }
191
+ }
192
+ const owner = await resolveOwner();
117
193
  if (owner && !(owner.expiresAt && Date.now() >= owner.expiresAt)) {
118
- return { auth: { Authorization: `Bearer ${owner.token}` } };
194
+ return { auth: { Authorization: `Bearer ${owner.token}` }, source: "owner", ownerExpiresAt: owner.expiresAt ?? null };
119
195
  }
120
- const agentKey = await resolveAgentKey();
121
- if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
196
+ const agentKey = await resolveAgentKeyFn();
197
+ if (agentKey) return { auth: { "x-agent-api-key": agentKey }, source: "mcp_key" };
122
198
  if (owner) return { error: { ok: false, auth: true, error: "token_expired" } };
123
199
  return { error: { ok: false, auth: true, error: "not_authenticated" } };
124
200
  }
125
201
 
202
+ // BOT-1864: client mirror of the MCP server's SESSION_TOKEN_DENIED_TOOLS
203
+ // (supabase/functions/mcp-server/index.ts, BOT-1649 AC-4). The server refuses a
204
+ // session token for these, so `bb call <one of them>` keeps the durable
205
+ // credential. bot1864-call-session-auth.test.mjs pins the two lists equal.
206
+ export const SESSION_TOKEN_DENIED_TOOLS = new Set([
207
+ "register_agent",
208
+ "bootstrap_agent_session",
209
+ "create_service_account",
210
+ "mint_mcp_key",
211
+ "revoke_mcp_key",
212
+ "create_direct_chat",
213
+ ]);
214
+
126
215
  // BOT-1561: a multi-membership owner credential is tenant-ambiguous without an
127
216
  // explicit pin — the server (mcpSessionIdentity, BOT-1521) fails closed, so
128
217
  // stateless REST tools/call like whoami return no tenant_id. `tenant` pins the
@@ -189,17 +278,19 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
189
278
  }
190
279
 
191
280
  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()) };
281
+ const resolved = auth ? { auth, source: null } : await authHeader();
282
+ const headers = { "Content-Type": "application/json", ...resolved.auth };
193
283
  const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
194
- const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
284
+ const res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
195
285
  const data = await res.json();
196
286
  log(JSON.stringify(data.result?.resources ?? data.result ?? data, null, 2));
197
287
  }
198
288
 
199
289
  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()) };
290
+ const resolved = auth ? { auth, source: null } : await authHeader();
291
+ const headers = { "Content-Type": "application/json", ...resolved.auth };
201
292
  const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
202
- const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
293
+ const res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
203
294
  const data = await res.json();
204
295
  const text = data.result?.contents?.[0]?.text;
205
296
  if (text) {
package/src/wait.mjs CHANGED
@@ -356,6 +356,14 @@ async function registerWait(opts, conditions, deadlineIso) {
356
356
  // wake-capable run for. The wait could only ever expire, and no amount of
357
357
  // waiting repairs a typo — a hard stop, with the server's candidates.
358
358
  "unknown_workflow",
359
+ // BOT-1612: a `chat:channel=<id>` wait whose bound agent is not one of the
360
+ // channel's participants could only ever park to timeout (the emitter never
361
+ // scopes a message to a non-participant). The relay refuses it at arm time;
362
+ // `chat_participant_ambiguous` means two of the caller's own live-session
363
+ // agents are participants and --session-id must pick one. Both are
364
+ // caller-actionable hard stops, not the untracked live-only fallback.
365
+ "chat_not_participant",
366
+ "chat_participant_ambiguous",
359
367
  ]);
360
368
  if (INVALID_CONDITION_CODES.has(body.error)) {
361
369
  const err = new Error(body.detail || body.error);