@botbuddy/cli 1.22.0 → 1.24.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 +1 -1
- package/src/api.mjs +78 -17
- package/src/auth.mjs +12 -1
- package/src/cli-credentials.mjs +44 -11
- package/src/commands.mjs +389 -39
- package/src/credential-kinds.mjs +3 -0
- package/src/discovery.mjs +14 -1
- package/src/mcp-key.mjs +385 -0
- package/src/oauth-loopback.mjs +12 -4
- package/src/profile-bootstrap.mjs +16 -3
- package/src/wait.mjs +60 -6
package/package.json
CHANGED
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
|
|
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)
|
|
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
|
-
|
|
94
|
+
log(prettyJson(text));
|
|
43
95
|
} else {
|
|
44
|
-
|
|
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(
|
|
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
|
|
194
|
+
const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
|
|
134
195
|
const data = await res.json();
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
206
|
+
log(prettyJson(text));
|
|
146
207
|
} else {
|
|
147
|
-
|
|
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.
|
package/src/cli-credentials.mjs
CHANGED
|
@@ -25,9 +25,19 @@ import {
|
|
|
25
25
|
} from "./agent-credential-store.mjs";
|
|
26
26
|
import { resolveAgentProfile } from "./wait-profile.mjs";
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 = () =>
|
|
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();
|