@botbuddy/cli 1.33.8 → 1.33.10
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 +1 -1
- package/src/agent-doctor.mjs +33 -7
- package/src/agent-session.mjs +0 -0
- package/src/api.mjs +117 -26
- package/src/commands.mjs +2 -1
package/package.json
CHANGED
package/src/agent-doctor.mjs
CHANGED
|
@@ -67,7 +67,13 @@ function prerequisiteRecovery(report) {
|
|
|
67
67
|
* credential-store failure must point at unlocking the store — not `bb login` or a
|
|
68
68
|
* binding repair — matching prerequisiteRecovery's wording.
|
|
69
69
|
*/
|
|
70
|
-
function repairFailureRecovery(code) {
|
|
70
|
+
function repairFailureRecovery(code, error = null) {
|
|
71
|
+
// BOT-1891: a tier-2 MCP key the server rejected (revoked/rotated) is replaced by
|
|
72
|
+
// minting a new one and re-delivering it — not by `bb login`, which a Background
|
|
73
|
+
// job cannot run anyway. Name the variable, never the value.
|
|
74
|
+
if (code === "session_mint_unauthorized" && error?.mintedWith === "mcp_key") {
|
|
75
|
+
return `the tier-2 MCP key in ${error.mcpEnv ?? "the binding's mcp_env"} was rejected (revoked?); mint a new one, store it in Keychain under that name, then re-run \`bash scripts/install-stack-canary.sh\` (or re-export it) and \`bb doctor --fix\``;
|
|
76
|
+
}
|
|
71
77
|
if (code === "client_key_unavailable") {
|
|
72
78
|
return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
|
|
73
79
|
}
|
|
@@ -120,11 +126,17 @@ export async function doctorAgentAuth({
|
|
|
120
126
|
]);
|
|
121
127
|
try { owner = await ownerToken(); } catch { ownerReadFailed = true; }
|
|
122
128
|
const fresh = Boolean(state && isFresh(state, new Date()));
|
|
129
|
+
// BOT-1891: whether the binding's tier-2 MCP key is in THIS process's env — the
|
|
130
|
+
// self-heal fallback when the owner token is unreachable (a Background
|
|
131
|
+
// LaunchAgent). Env only, exactly like selfHealAgentSession; never the value.
|
|
132
|
+
const mcpEnv = typeof bound?.mcpEnv === "string" && bound.mcpEnv ? bound.mcpEnv : null;
|
|
133
|
+
const mcpKeyInEnv = Boolean(mcpEnv && typeof env?.[mcpEnv] === "string" && env[mcpEnv]);
|
|
123
134
|
const report = {
|
|
124
135
|
schema_version: 1,
|
|
125
136
|
worktree: cwd,
|
|
126
137
|
binding: bound?.tenant ? { status: "available", tenant: bound.tenant } : { status: "missing" },
|
|
127
138
|
client_key: clientKeyStatus(owner, now(), ownerReadFailed),
|
|
139
|
+
...(mcpEnv ? { mcp_key: { env: mcpEnv, in_env: mcpKeyInEnv } } : {}),
|
|
128
140
|
session: fresh
|
|
129
141
|
? { status: "fresh", agent_id: state.agent_id, session_id: state.session_id, expires_at: state.expires_at }
|
|
130
142
|
: { status: state ? "stale" : "missing" },
|
|
@@ -147,14 +159,28 @@ export async function doctorAgentAuth({
|
|
|
147
159
|
// `bb doctor --fix` (the remedy the stack refusal points at) would reuse a fresh but
|
|
148
160
|
// never-attested session and never re-establish the capability. `force` makes the
|
|
149
161
|
// self-heal run a registration/attestation round regardless of cache freshness.
|
|
150
|
-
|
|
162
|
+
// BOT-1891: an UNREADABLE client key (a Background LaunchAgent cannot reach the
|
|
163
|
+
// login Keychain) is repairable when the binding's tier-2 MCP key is in the env —
|
|
164
|
+
// selfHeal mints with it. A missing or expired key keeps its `bb login` verdict.
|
|
165
|
+
const mintCredentialAvailable = report.client_key.status === "available"
|
|
166
|
+
|| (mcpKeyInEnv && report.client_key.status === "unavailable");
|
|
167
|
+
if (fix && report.binding.status === "available" && mintCredentialAvailable) {
|
|
151
168
|
try {
|
|
152
169
|
credential = await selfHeal({ cwd, env, force: true });
|
|
153
|
-
report.session = {
|
|
170
|
+
report.session = {
|
|
171
|
+
status: credential.source === "minted" ? "minted" : credential.source,
|
|
172
|
+
agent_id: credential.agentId ?? null,
|
|
173
|
+
session_id: credential.sessionId ?? null,
|
|
174
|
+
...(credential.mintedWith ? { minted_with: credential.mintedWith } : {}),
|
|
175
|
+
};
|
|
154
176
|
applyStackLeaseCapability(report, credential.stackLeaseCapability);
|
|
155
177
|
} catch (error) {
|
|
156
|
-
report.session = {
|
|
157
|
-
|
|
178
|
+
report.session = {
|
|
179
|
+
status: "repair_failed",
|
|
180
|
+
error: error?.code ?? "agent_session_unavailable",
|
|
181
|
+
...(error?.mintedWith ? { minted_with: error.mintedWith } : {}),
|
|
182
|
+
};
|
|
183
|
+
report.recovery = repairFailureRecovery(error?.code, error);
|
|
158
184
|
return { exitCode: 3, report, exports: null };
|
|
159
185
|
}
|
|
160
186
|
}
|
|
@@ -173,7 +199,7 @@ export async function doctorAgentAuth({
|
|
|
173
199
|
// cached session left `credential` populated, report THAT prerequisite — not the
|
|
174
200
|
// Helper/capability message below, since registration never ran and the real remedy
|
|
175
201
|
// is `bb login` or restoring the binding.
|
|
176
|
-
if (fix && (report.binding.status !== "available" ||
|
|
202
|
+
if (fix && (report.binding.status !== "available" || !mintCredentialAvailable)) {
|
|
177
203
|
report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
|
|
178
204
|
return { exitCode: 3, report, exports };
|
|
179
205
|
}
|
|
@@ -194,5 +220,5 @@ export async function doctorAgentAuth({
|
|
|
194
220
|
}
|
|
195
221
|
|
|
196
222
|
export function doctorHelp() {
|
|
197
|
-
return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports.";
|
|
223
|
+
return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports. When the client key is unreachable (a Background LaunchAgent cannot read the login Keychain), --fix mints from the tier-2 MCP key exported under the binding's mcp_env name (env only, never Keychain).";
|
|
198
224
|
}
|
package/src/agent-session.mjs
CHANGED
|
Binary file
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/commands.mjs
CHANGED
|
@@ -226,7 +226,8 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
|
|
|
226
226
|
} else {
|
|
227
227
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
228
228
|
log(`client key: ${result.report.client_key.status}`);
|
|
229
|
-
log(`
|
|
229
|
+
if (result.report.mcp_key) log(`mcp key: ${result.report.mcp_key.env} ${result.report.mcp_key.in_env ? "in env" : "not in env"}`);
|
|
230
|
+
log(`agent session: ${result.report.session.status}${result.report.session.minted_with ? ` (minted_with: ${result.report.session.minted_with})` : ""}`);
|
|
230
231
|
if (result.report.stack_lease) {
|
|
231
232
|
const sl = result.report.stack_lease;
|
|
232
233
|
log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
|