@botbuddy/cli 1.24.0 → 1.26.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 +2 -1
- package/src/agent-credential-store.mjs +11 -181
- package/src/api.mjs +8 -8
- package/src/auth.mjs +1 -1
- package/src/cli-credentials.mjs +11 -6
- package/src/codex-bridge.mjs +3 -3
- package/src/commands.mjs +61 -143
- package/src/config.mjs +1 -1
- package/src/mcp-key.mjs +23 -22
- package/src/pw/coordinator.mjs +0 -5
- package/src/pw/run.mjs +32 -32
- package/src/stack.mjs +3 -3
- package/src/wait-profile.mjs +128 -79
- package/src/wait.mjs +88 -226
- package/src/profile-bootstrap.mjs +0 -252
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
+
"bb": "./bin/botbuddy.mjs",
|
|
7
8
|
"botbuddy": "./bin/botbuddy.mjs",
|
|
8
9
|
"pw": "./bin/pw.mjs",
|
|
9
10
|
"bb-pw": "./bin/bb-pw.mjs"
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir, userInfo } from "node:os";
|
|
1
|
+
import { userInfo } from "node:os";
|
|
3
2
|
import { execFile, spawn } from "node:child_process";
|
|
4
3
|
import { promisify } from "node:util";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
4
|
import { existsSync } from "node:fs";
|
|
8
5
|
|
|
9
|
-
const STORE_SCHEMA_VERSION = 1;
|
|
10
|
-
const LOCK_RETRY_MS = 10;
|
|
11
|
-
const LOCK_MAX_ATTEMPTS = 100;
|
|
12
|
-
const STALE_LOCK_MS = 30_000;
|
|
13
6
|
const execFileAsync = promisify(execFile);
|
|
14
7
|
|
|
15
8
|
// BOT-1520: is the macOS Keychain backend usable on this host? The CLI stores
|
|
@@ -24,20 +17,17 @@ export function keychainAvailable(platform = process.platform, exists = existsSy
|
|
|
24
17
|
return platform === "darwin" && exists("/usr/bin/security");
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export function profileCredentialEnvironment(profile) {
|
|
34
|
-
return profile === "botbuddy-dev" ? "BOTBUDDY_BB_AGENT_KEY"
|
|
35
|
-
: profile === "supplyguard-dev" ? "BOTBUDDY_SG_AGENT_KEY" : null;
|
|
36
|
-
}
|
|
20
|
+
// BOT-1607 / BOT-1608: the canonical env/Keychain var a `.mcp.json` references.
|
|
21
|
+
// The `.botbuddy-agent.json` binding's `mcp_env` defaults to this; `botbuddy mcp`
|
|
22
|
+
// mints into it (or an `--env <NAME>` override). Defined here — the one module
|
|
23
|
+
// both mcp-key.mjs and wait-profile.mjs already import — so neither has to import
|
|
24
|
+
// the other (an api.mjs ↔ wait-profile.mjs ↔ mcp-key.mjs cycle).
|
|
25
|
+
export const DEFAULT_MCP_ENV_VAR = "BOTBUDDY_MCP_KEY";
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
// The pre-1607 var `profile setup` planted the reused bb_agent_ session token in.
|
|
28
|
+
// A `.mcp.json` still referencing it keeps authenticating for one release; the
|
|
29
|
+
// CLI recognises it as a DEPRECATED alias and tells the operator to migrate.
|
|
30
|
+
export const LEGACY_MCP_ENV_VAR = "BOTBUDDY_BB_AGENT_KEY";
|
|
41
31
|
|
|
42
32
|
// A keychain/credential-store write failed AFTER OAuth + registration already
|
|
43
33
|
// succeeded. It carries its own code so the CLI never mislabels a storage
|
|
@@ -212,163 +202,3 @@ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsy
|
|
|
212
202
|
return false;
|
|
213
203
|
}
|
|
214
204
|
}
|
|
215
|
-
|
|
216
|
-
export async function writeKeychain(profile, token, options = {}) {
|
|
217
|
-
const service = keychainService(profile);
|
|
218
|
-
if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
|
|
219
|
-
return writeKeychainSecret(service, token, options);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function readKeychain(profile) {
|
|
223
|
-
// Honor the keychain escape hatch (BOTBUDDY_NO_KEYCHAIN / non-darwin) so the
|
|
224
|
-
// per-profile environment key is the sole credential source there.
|
|
225
|
-
if (!keychainAvailable()) return null;
|
|
226
|
-
return readKeychainSecret(keychainService(profile));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function profileCredentialStorePath(home = homedir()) {
|
|
230
|
-
return join(home, ".botbuddy", "agent-profiles.json");
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function readStore({ home = homedir() } = {}) {
|
|
234
|
-
try {
|
|
235
|
-
const raw = await readFile(profileCredentialStorePath(home), "utf8");
|
|
236
|
-
const parsed = JSON.parse(raw);
|
|
237
|
-
if (parsed?.schema_version !== STORE_SCHEMA_VERSION || !parsed.profiles || typeof parsed.profiles !== "object") {
|
|
238
|
-
return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
|
|
239
|
-
}
|
|
240
|
-
return parsed;
|
|
241
|
-
} catch (error) {
|
|
242
|
-
if (error?.code === "ENOENT") return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function validIdentityEntry(entry) {
|
|
248
|
-
return entry && typeof entry === "object" && typeof entry.name === "string";
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function withStoreLock(path, operation) {
|
|
252
|
-
const lockPath = `${path}.lock`;
|
|
253
|
-
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
254
|
-
const ownerPath = `${lockPath}.${process.pid}.${randomUUID()}`;
|
|
255
|
-
try {
|
|
256
|
-
await writeFile(ownerPath, JSON.stringify({ pid: process.pid, owner_path: ownerPath }), { flag: "wx", mode: 0o600 });
|
|
257
|
-
await link(ownerPath, lockPath);
|
|
258
|
-
const ownerStat = await stat(ownerPath);
|
|
259
|
-
const ownsLock = async () => {
|
|
260
|
-
try {
|
|
261
|
-
const current = await stat(lockPath);
|
|
262
|
-
return current.dev === ownerStat.dev && current.ino === ownerStat.ino;
|
|
263
|
-
} catch (error) {
|
|
264
|
-
if (error?.code === "ENOENT") return false;
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
const refresh = setInterval(() => {
|
|
269
|
-
const now = new Date();
|
|
270
|
-
void ownsLock().then((owned) => owned && utimes(lockPath, now, now)).catch(() => {});
|
|
271
|
-
}, Math.floor(STALE_LOCK_MS / 3));
|
|
272
|
-
try {
|
|
273
|
-
return await operation();
|
|
274
|
-
} finally {
|
|
275
|
-
clearInterval(refresh);
|
|
276
|
-
if (await ownsLock()) await unlink(lockPath).catch(() => {});
|
|
277
|
-
await unlink(ownerPath).catch(() => {});
|
|
278
|
-
}
|
|
279
|
-
} catch (error) {
|
|
280
|
-
await unlink(ownerPath).catch(() => {});
|
|
281
|
-
if (error?.code !== "EEXIST") throw error;
|
|
282
|
-
try {
|
|
283
|
-
const lockStat = await stat(lockPath);
|
|
284
|
-
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
285
|
-
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
|
286
|
-
let ownerAlive = false;
|
|
287
|
-
try { process.kill(lock.pid, 0); ownerAlive = true; } catch (ownerError) { ownerAlive = ownerError?.code === "EPERM"; }
|
|
288
|
-
if (!ownerAlive) {
|
|
289
|
-
await unlink(lockPath);
|
|
290
|
-
if (typeof lock.owner_path === "string") await unlink(lock.owner_path).catch(() => {});
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
} catch (lockError) {
|
|
294
|
-
if (lockError?.code !== "ENOENT") throw lockError;
|
|
295
|
-
}
|
|
296
|
-
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
throw new Error("profile credential store is busy; retry profile setup");
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export async function readProfileIdentity(profile, options = {}) {
|
|
303
|
-
const store = await readStore(options);
|
|
304
|
-
const entry = store.profiles[profile];
|
|
305
|
-
if (!validIdentityEntry(entry) || typeof entry.tenant !== "string" || typeof entry.agent_id !== "string") return null;
|
|
306
|
-
return { agentId: entry.agent_id, tenant: entry.tenant, name: entry.name };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export async function readProfileRetryIdentity(profile, options = {}) {
|
|
310
|
-
const store = await readStore(options);
|
|
311
|
-
const entry = store.profiles[profile];
|
|
312
|
-
if (!validIdentityEntry(entry)) return null;
|
|
313
|
-
const agentId = typeof entry.agent_id === "string" ? entry.agent_id : entry.pending_agent_id;
|
|
314
|
-
return typeof agentId === "string" ? { agentId, name: entry.name } : null;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
export async function readProfileCredential(profile, options = {}) {
|
|
318
|
-
return (options.keychainRead ?? readKeychain)(profile);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function writeProfileMetadata(profile, entry, {
|
|
322
|
-
home = homedir(),
|
|
323
|
-
onlyIfNoAttestedIdentity = false,
|
|
324
|
-
afterWrite = null,
|
|
325
|
-
} = {}) {
|
|
326
|
-
const path = profileCredentialStorePath(home);
|
|
327
|
-
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
328
|
-
await withStoreLock(path, async () => {
|
|
329
|
-
const store = await readStore({ home });
|
|
330
|
-
const current = store.profiles[profile];
|
|
331
|
-
if (onlyIfNoAttestedIdentity && validIdentityEntry(current) && typeof current.tenant === "string" && typeof current.agent_id === "string") {
|
|
332
|
-
return false;
|
|
333
|
-
}
|
|
334
|
-
const next = {
|
|
335
|
-
schema_version: STORE_SCHEMA_VERSION,
|
|
336
|
-
profiles: { ...store.profiles, [profile]: entry },
|
|
337
|
-
};
|
|
338
|
-
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
339
|
-
await writeFile(temporaryPath, JSON.stringify(next), { mode: 0o600 });
|
|
340
|
-
await chmod(temporaryPath, 0o600);
|
|
341
|
-
await rename(temporaryPath, path);
|
|
342
|
-
await chmod(path, 0o600);
|
|
343
|
-
await afterWrite?.();
|
|
344
|
-
return true;
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export async function recordProfileRetryIdentity({ profile, agentId, name }, options = {}) {
|
|
349
|
-
if (![profile, agentId, name].every((value) => typeof value === "string" && value.length > 0)) {
|
|
350
|
-
throw new Error("profile retry identity requires profile, agentId, and name");
|
|
351
|
-
}
|
|
352
|
-
// This entry is explicitly not tenant-attested and contains no credential. It
|
|
353
|
-
// may only be used to reconnect with the profile's fixed tenant on retry.
|
|
354
|
-
await writeProfileMetadata(profile, { pending_agent_id: agentId, name }, {
|
|
355
|
-
...options,
|
|
356
|
-
onlyIfNoAttestedIdentity: true,
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export async function installProfileCredential({ profile, tenant, agentId, name, token }, { home = homedir(), keychainWrite = writeKeychain } = {}) {
|
|
361
|
-
if (![profile, tenant, agentId, name, token].every((value) => typeof value === "string" && value.length > 0)) {
|
|
362
|
-
throw new Error("profile credential store requires profile, tenant, agentId, name, and token");
|
|
363
|
-
}
|
|
364
|
-
// Keep non-secret identity metadata even if Keychain is momentarily locked.
|
|
365
|
-
// The next setup call reconnects this same server agent instead of minting a
|
|
366
|
-
// random orphan; credentials are never written to this file.
|
|
367
|
-
await writeProfileMetadata(profile, { tenant, agent_id: agentId, name }, {
|
|
368
|
-
home,
|
|
369
|
-
// Keep this Keychain operation in the same lock as the metadata write. A
|
|
370
|
-
// later setup cannot leave metadata from one agent beside another agent's
|
|
371
|
-
// credential, while a failed write still leaves resumable metadata.
|
|
372
|
-
afterWrite: () => keychainWrite(profile, token),
|
|
373
|
-
});
|
|
374
|
-
}
|
package/src/api.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getConfig, SERVER_URL } from "./config.mjs";
|
|
2
2
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { readAgentBinding } from "./wait-profile.mjs";
|
|
4
4
|
import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
|
|
5
5
|
|
|
6
6
|
// Same slug shape the server accepts on `?tenant=`.
|
|
@@ -10,7 +10,7 @@ const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
|
10
10
|
// server refuses unpinned (MCP_TENANT_PIN_REQUIRED). Generic commands
|
|
11
11
|
// (`botbuddy call`, resources) therefore derive a default pin:
|
|
12
12
|
// 1. BOTBUDDY_TENANT (explicit; malformed → usage error, never sent),
|
|
13
|
-
// 2. the repo's .botbuddy-agent.json
|
|
13
|
+
// 2. the repo's .botbuddy-agent.json tenant (walks up from cwd),
|
|
14
14
|
// 3. the token's sole reachable tenant,
|
|
15
15
|
// 4. none — the server's error names the fix.
|
|
16
16
|
// A sealed (tenant-mode) or pre-1571 token gets NO derived pin: the server
|
|
@@ -21,8 +21,7 @@ export async function resolveDefaultTenantPin({
|
|
|
21
21
|
getConfig: getCfg = getConfig,
|
|
22
22
|
env = process.env,
|
|
23
23
|
cwd = process.cwd(),
|
|
24
|
-
|
|
25
|
-
profileFor = getAgentProfile,
|
|
24
|
+
readBinding = readAgentBinding,
|
|
26
25
|
} = {}) {
|
|
27
26
|
const explicit = typeof env.BOTBUDDY_TENANT === "string" ? env.BOTBUDDY_TENANT.trim() : "";
|
|
28
27
|
if (explicit) {
|
|
@@ -31,10 +30,11 @@ export async function resolveDefaultTenantPin({
|
|
|
31
30
|
}
|
|
32
31
|
const cfg = getCfg() ?? {};
|
|
33
32
|
if (cfg.token_tenant_mode !== "user") return null;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// A malformed / retired-shape .botbuddy-agent.json never breaks a generic call —
|
|
34
|
+
// fall through to the sole-tenant rung rather than throwing here.
|
|
35
|
+
let bindingTenant = null;
|
|
36
|
+
try { bindingTenant = (await readBinding(cwd))?.tenant ?? null; } catch { bindingTenant = null; }
|
|
37
|
+
if (bindingTenant) return bindingTenant;
|
|
38
38
|
const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
|
|
39
39
|
return tenants.length === 1 ? tenants[0] : null;
|
|
40
40
|
}
|
package/src/auth.mjs
CHANGED
|
@@ -187,7 +187,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
187
187
|
}.`,
|
|
188
188
|
);
|
|
189
189
|
if (tenantMode === "user") {
|
|
190
|
-
log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy
|
|
190
|
+
log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy mcp setup --tenant <slug>")} mints a key pinned to its own.`);
|
|
191
191
|
} else if (tenantId) {
|
|
192
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
193
|
}
|
package/src/cli-credentials.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
readKeychainSecret,
|
|
24
24
|
deleteKeychainSecret,
|
|
25
25
|
} from "./agent-credential-store.mjs";
|
|
26
|
-
import {
|
|
26
|
+
import { resolveAgentBinding } from "./wait-profile.mjs";
|
|
27
27
|
|
|
28
28
|
// BOT-1574: the per-machine CLIENT KEY. `botbuddy login` mints a user-mode OAuth
|
|
29
29
|
// token (BOT-1571) — tenant-agnostic, pins one tenant per request against the
|
|
@@ -267,15 +267,20 @@ export async function clearOwnerToken({
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
// Resolve the
|
|
271
|
-
//
|
|
272
|
-
// This is the
|
|
270
|
+
// Resolve the agent-context credential from the worktree binding — the
|
|
271
|
+
// `.botbuddy-agent.json` mcp_env var (env or Keychain) — without throwing when no
|
|
272
|
+
// binding is configured. This is the fallback the owner OAuth token defers to for
|
|
273
|
+
// tool-call auth now that `register` and `profile setup` are both gone.
|
|
273
274
|
export async function resolveAgentKey(options = {}) {
|
|
274
275
|
try {
|
|
275
|
-
const { token } = await
|
|
276
|
+
const { token } = await resolveAgentBinding(options);
|
|
276
277
|
return token || null;
|
|
277
278
|
} catch (error) {
|
|
278
|
-
|
|
279
|
+
// A missing / malformed / retired-shape binding is not authenticated — let the
|
|
280
|
+
// caller fall through to its "not authenticated" path rather than throwing.
|
|
281
|
+
if (["agent_binding_required", "invalid_binding", "binding_migration_required"].includes(error?.code)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
279
284
|
throw error;
|
|
280
285
|
}
|
|
281
286
|
}
|
package/src/codex-bridge.mjs
CHANGED
|
@@ -66,14 +66,14 @@ function isAddressInUseError(text) {
|
|
|
66
66
|
|
|
67
67
|
export async function runBridge(args) {
|
|
68
68
|
const cfg = getConfig(); // non-secret metadata only (agent_name)
|
|
69
|
-
// BOT-1520: authenticate from the Keychain — the
|
|
70
|
-
// (`botbuddy
|
|
69
|
+
// BOT-1520: authenticate from the Keychain — the MCP key
|
|
70
|
+
// (`botbuddy mcp setup`, BOT-1608) or the owner OAuth token (`botbuddy login`).
|
|
71
71
|
// Resolve ONCE here for the life of the bridge and cache it, so the relay
|
|
72
72
|
// helpers don't shell out to `security` on every request.
|
|
73
73
|
const agentKey = await resolveAgentKey();
|
|
74
74
|
const owner = agentKey ? null : await resolveOwnerToken({ getConfig });
|
|
75
75
|
if (!agentKey && !owner) {
|
|
76
|
-
die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy
|
|
76
|
+
die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy mcp setup")}).`);
|
|
77
77
|
}
|
|
78
78
|
bridgeAuth = { agentKey, owner };
|
|
79
79
|
|
package/src/commands.mjs
CHANGED
|
@@ -14,8 +14,8 @@ import { cmdTest } from "./test-lane.mjs";
|
|
|
14
14
|
import { runWait } from "./wait.mjs";
|
|
15
15
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
16
16
|
import { VERSION } from "./version.mjs";
|
|
17
|
-
import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
|
|
18
17
|
import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFAULT_MCP_ENV_VAR, McpKeyError, McpKeyStoreError } from "./mcp-key.mjs";
|
|
18
|
+
import { readAgentBinding } from "./wait-profile.mjs";
|
|
19
19
|
import { runPw } from "./pw/run.mjs";
|
|
20
20
|
import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
|
|
21
21
|
|
|
@@ -56,8 +56,6 @@ export async function run(argv, {
|
|
|
56
56
|
case "test": return cmdTest(args);
|
|
57
57
|
case "wait": return runWait(args);
|
|
58
58
|
case "pw": return runPw(args);
|
|
59
|
-
case "profile": return cmdProfile(args);
|
|
60
|
-
case "carrier": return cmdCarrier(args);
|
|
61
59
|
case "mcp": return cmdMcp(args);
|
|
62
60
|
case "resources": return callTool("list_resources");
|
|
63
61
|
case "agents": return callTool("list_agents");
|
|
@@ -78,15 +76,17 @@ export async function run(argv, {
|
|
|
78
76
|
if (args.length > 0) return cmdToolHelp(args);
|
|
79
77
|
return cmdHelp();
|
|
80
78
|
default:
|
|
81
|
-
die(`Unknown command: ${command}. Run ${cyan("
|
|
79
|
+
die(`Unknown command: ${command}. Run ${cyan("bb help")} for usage.`);
|
|
82
80
|
}
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
function cmdHelp() {
|
|
86
|
-
console.log(`${bold("
|
|
84
|
+
console.log(`${bold("bb")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
|
|
85
|
+
|
|
86
|
+
${dim(`Run everything as ${cyan("bb <command>")} — the short, preferred alias. ${cyan("botbuddy <command>")} is the identical long form.`)}
|
|
87
87
|
|
|
88
88
|
${bold("USAGE")}
|
|
89
|
-
|
|
89
|
+
bb start Start BotBuddy (login + codex server + bridge)
|
|
90
90
|
|
|
91
91
|
${bold("OPTIONS")}
|
|
92
92
|
start [options]
|
|
@@ -100,8 +100,6 @@ ${bold("AUTH")}
|
|
|
100
100
|
Authenticate via OAuth (opens browser + localhost callback)
|
|
101
101
|
logout Remove saved credentials
|
|
102
102
|
status Show current auth status (local metadata + server check)
|
|
103
|
-
carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
|
|
104
|
-
(interactive operators use ${cyan("login")} — it installs the client key)
|
|
105
103
|
|
|
106
104
|
${bold("MCP CONFIG KEY")}
|
|
107
105
|
mcp setup [--env <NAME>] [--tenant <slug>] [--label <l>] [--expiry-days <n>]
|
|
@@ -109,6 +107,7 @@ ${bold("MCP CONFIG KEY")}
|
|
|
109
107
|
revocable; stored under ${dim("BOTBUDDY_MCP_KEY")})
|
|
110
108
|
mcp revoke <agent_id> Revoke only that MCP key (login + sessions unaffected)
|
|
111
109
|
mcp status Which env var holds the MCP key (never prints it)
|
|
110
|
+
mcp env Print the shell loader for the MCP key ($BOTBUDDY_MCP_KEY)
|
|
112
111
|
|
|
113
112
|
${bold("TOOLS")}
|
|
114
113
|
help --tools List every BotBuddy tool
|
|
@@ -170,7 +169,7 @@ async function cmdToolHelp(args) {
|
|
|
170
169
|
const res = await fetch(discoveryUrlFor(SERVER_URL, tool));
|
|
171
170
|
if (!res.ok) {
|
|
172
171
|
die(res.status === 404
|
|
173
|
-
? `Unknown tool: ${tool}. Run ${cyan("
|
|
172
|
+
? `Unknown tool: ${tool}. Run ${cyan("bb help --tools")} to list them.`
|
|
174
173
|
: `Discovery failed: ${res.status} ${res.statusText}`);
|
|
175
174
|
}
|
|
176
175
|
console.log(formatDiscovery(await res.json(), { tool }));
|
|
@@ -299,8 +298,9 @@ ${bold("TOKEN SCOPE")}
|
|
|
299
298
|
By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
|
|
300
299
|
token that is not bound to a tenant and reaches every tenant you belong to —
|
|
301
300
|
each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
|
|
302
|
-
exchanges it for a per-session agent token. No per-tenant
|
|
303
|
-
|
|
301
|
+
exchanges it for a per-session agent token. No per-tenant credential is needed
|
|
302
|
+
for normal use — mint a ${bold("bb_mcp_")} key per tenant (${cyan("botbuddy mcp setup")}) only
|
|
303
|
+
when a repo binds a distinct ${dim("mcp_env")}.
|
|
304
304
|
${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
|
|
305
305
|
tenant (the MCP-session model); use it only when you want that guarantee.
|
|
306
306
|
|
|
@@ -351,7 +351,8 @@ ${bold("NOTES")}
|
|
|
351
351
|
|
|
352
352
|
${bold("RECOVERY")}
|
|
353
353
|
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
354
|
-
unattended CI host (no browser),
|
|
354
|
+
unattended CI host (no browser), mint a key on a machine with a browser
|
|
355
|
+
(${cyan("botbuddy mcp setup")}) and export it as ${cyan("$BOTBUDDY_MCP_KEY")} on the CI host.`);
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
async function cmdStart(args) {
|
|
@@ -554,143 +555,14 @@ async function cmdLogout() {
|
|
|
554
555
|
console.log(`${green("✓")} Logged out. Credentials removed.`);
|
|
555
556
|
}
|
|
556
557
|
|
|
557
|
-
async function cmdAgentAuth(args) {
|
|
558
|
-
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
559
|
-
console.log(`Usage: botbuddy auth <login|status|logout> [--profile <name>] [--token <key>]
|
|
560
|
-
|
|
561
|
-
auth login Store a tenant-bound agent credential in the OS keyring. On macOS,
|
|
562
|
-
it imports the existing launchd credential once when no --token or
|
|
563
|
-
profile-specific environment key is supplied.
|
|
564
|
-
auth status Show whether the profile has a stored key (never prints it).
|
|
565
|
-
auth logout Remove the profile's stored key.`);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
let options;
|
|
569
|
-
try {
|
|
570
|
-
options = parseAgentAuthArgs(args);
|
|
571
|
-
} catch (error) {
|
|
572
|
-
die(error.message);
|
|
573
|
-
}
|
|
574
|
-
const profile = options.profile || await findProfileName(process.cwd());
|
|
575
|
-
if (!profile || !getProfileDefinition(profile)) {
|
|
576
|
-
die("No supported BotBuddy agent profile found. Add .botbuddy-agent.json or pass --profile <name>.");
|
|
577
|
-
}
|
|
578
|
-
if (options.action === "login") {
|
|
579
|
-
const token = await resolveLoginToken({ profile, explicitToken: options.token });
|
|
580
|
-
const result = await loginAgentCredential({ profile, token });
|
|
581
|
-
console.log(`${green("✓")} Stored ${result.profile} (${result.tenant}) agent credential in the OS keyring.`);
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
if (options.action === "status") {
|
|
585
|
-
const result = await getAgentAuthStatus({ profile });
|
|
586
|
-
console.log(result.authenticated
|
|
587
|
-
? `${green("✓")} ${result.profile} (${result.tenant}) machine credential is stored in the OS keyring.`
|
|
588
|
-
: `${red("✗")} ${result.profile} (${result.tenant}) has no stored machine credential.`);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const result = await logoutAgentCredential({ profile });
|
|
592
|
-
console.log(`${green("✓")} ${result.profile} (${result.tenant}) machine credential ${result.removed ? "removed" : "was not present"}.`);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
558
|
function cmdHeartbeat(args) {
|
|
596
559
|
return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
|
|
597
560
|
}
|
|
598
561
|
|
|
599
|
-
// BOT-1574 AC6: the carrier bootstrap that `profile setup --unattended` and
|
|
600
|
-
// `carrier setup` share. Never gated on a TTY — the caller decides whether the
|
|
601
|
-
// interactive guard applies before reaching here.
|
|
602
|
-
async function runCarrierSetup(profile, { log, setExitCode, bootstrap }) {
|
|
603
|
-
try {
|
|
604
|
-
const receipt = await bootstrap(profile, { call: callToolJson });
|
|
605
|
-
log(JSON.stringify(receipt));
|
|
606
|
-
} catch (error) {
|
|
607
|
-
const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
|
|
608
|
-
log(JSON.stringify({
|
|
609
|
-
schema_version: 1,
|
|
610
|
-
outcome: "error",
|
|
611
|
-
error: code,
|
|
612
|
-
recovery: profileBootstrapRecovery(code, profile),
|
|
613
|
-
}));
|
|
614
|
-
setExitCode(3);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
export async function cmdProfile(args, {
|
|
619
|
-
isTTY = Boolean(process.stdout.isTTY),
|
|
620
|
-
log = (line) => console.log(line),
|
|
621
|
-
setExitCode = (n) => { process.exitCode = n; },
|
|
622
|
-
bootstrap = bootstrapProfile,
|
|
623
|
-
} = {}) {
|
|
624
|
-
if (args[0] === "--help" || args[0] === "-h") {
|
|
625
|
-
console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]
|
|
626
|
-
|
|
627
|
-
setup <profile> Carrier-only (CI): mint/reconnect and store a tenant-bound
|
|
628
|
-
carrier key. Retired for interactive use — run ${cyan("botbuddy login")}
|
|
629
|
-
on a workstation. On a TTY this refuses unless --unattended.
|
|
630
|
-
env <profile> Print shell exports that load the stored carrier key
|
|
631
|
-
|
|
632
|
-
Interactive operators: ${cyan("botbuddy login")} installs the per-machine client key.
|
|
633
|
-
CI hosts: ${cyan("botbuddy carrier setup <profile>")} (alias of setup --unattended).`);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
if (args[0] === "env" && args[1] && args.length === 2) {
|
|
637
|
-
try {
|
|
638
|
-
log(profileShellRefresh(args[1]));
|
|
639
|
-
return;
|
|
640
|
-
} catch {
|
|
641
|
-
die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
const unattended = args.includes("--unattended");
|
|
645
|
-
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
646
|
-
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
647
|
-
die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]");
|
|
648
|
-
}
|
|
649
|
-
const profile = positionals[1];
|
|
650
|
-
// BOT-1574 AC6: interactive setup is retired. A human on a TTY who did not ask
|
|
651
|
-
// for the unattended carrier path is redirected to `botbuddy login` (which
|
|
652
|
-
// installs the machine client key) instead of minting a per-tenant slot. A
|
|
653
|
-
// non-TTY caller (CI) or an explicit --unattended keeps the carrier path.
|
|
654
|
-
if (isTTY && !unattended) {
|
|
655
|
-
log(JSON.stringify({
|
|
656
|
-
schema_version: 1,
|
|
657
|
-
outcome: "error",
|
|
658
|
-
error: "interactive_use_retired",
|
|
659
|
-
recovery: `Interactive profile setup is retired. Run \`botbuddy login\` to install this machine's client key (bb_cli_) — it reaches every tenant you belong to, no per-tenant slot needed. For an unattended CI carrier, re-run with --unattended or use \`botbuddy carrier setup ${profile}\`.`,
|
|
660
|
-
}));
|
|
661
|
-
setExitCode(4);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
await runCarrierSetup(profile, { log, setExitCode, bootstrap });
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// BOT-1574 AC6: `botbuddy carrier setup <profile>` — the explicit CI-carrier
|
|
668
|
-
// alias of `profile setup --unattended`. Being the named carrier command, it is
|
|
669
|
-
// never subject to the interactive-TTY guard.
|
|
670
|
-
export async function cmdCarrier(args, {
|
|
671
|
-
log = (line) => console.log(line),
|
|
672
|
-
setExitCode = (n) => { process.exitCode = n; },
|
|
673
|
-
bootstrap = bootstrapProfile,
|
|
674
|
-
} = {}) {
|
|
675
|
-
if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
|
|
676
|
-
console.log(`Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>
|
|
677
|
-
|
|
678
|
-
Mint/reconnect and store an unattended CI carrier key (alias of
|
|
679
|
-
${cyan("botbuddy profile setup <profile> --unattended")}). Interactive operators use
|
|
680
|
-
${cyan("botbuddy login")} instead.`);
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
684
|
-
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
685
|
-
die("Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>");
|
|
686
|
-
}
|
|
687
|
-
await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
|
|
688
|
-
}
|
|
689
|
-
|
|
690
562
|
// ─── BOT-1607: `botbuddy mcp` — the tier-2 bb_mcp_ config key ────────────────
|
|
691
563
|
|
|
692
564
|
function mcpHelp(log) {
|
|
693
|
-
log(`Usage: botbuddy mcp <setup|revoke|status> [options]
|
|
565
|
+
log(`Usage: botbuddy mcp <setup|revoke|status|env> [options]
|
|
694
566
|
|
|
695
567
|
setup [--env <NAME>] [--tenant <slug>] [--label <label>] [--expiry-days <n>] [--force]
|
|
696
568
|
Mint a bb_mcp_ MCP config key (authenticated by your
|
|
@@ -709,6 +581,10 @@ function mcpHelp(log) {
|
|
|
709
581
|
Report which env var holds the MCP config key (never prints
|
|
710
582
|
the secret). ${dim("BOTBUDDY_BB_AGENT_KEY")} is honoured as a deprecated
|
|
711
583
|
alias for one release.
|
|
584
|
+
env [--env <NAME>]
|
|
585
|
+
Print the shell loader that reads the key from the Keychain
|
|
586
|
+
into ${dim("$BOTBUDDY_MCP_KEY")}: eval "$(botbuddy mcp env)" in a shell,
|
|
587
|
+
or in the desktop LaunchAgent env-install recipe.
|
|
712
588
|
|
|
713
589
|
Present the key from .mcp.json instead of reusing an agent session token — it is
|
|
714
590
|
independently revocable, so a leaked config credential rotates without a re-login.`);
|
|
@@ -819,11 +695,34 @@ export async function cmdMcp(args, {
|
|
|
819
695
|
revoke = revokeMcpKey,
|
|
820
696
|
resolveConfigKey = resolveMcpConfigKey,
|
|
821
697
|
env = process.env,
|
|
698
|
+
cwd = process.cwd(),
|
|
699
|
+
readBinding = readAgentBinding,
|
|
822
700
|
} = {}) {
|
|
823
701
|
if (args[0] === "--help" || args[0] === "-h") return mcpHelp(log);
|
|
824
702
|
const sub = args.find((a) => !a.startsWith("-"));
|
|
825
703
|
if (!sub) return mcpHelp(log);
|
|
826
704
|
|
|
705
|
+
// BOT-1608 Codex P2: `mcp status`/`mcp env` with no explicit --env must report
|
|
706
|
+
// and export the var the committed .botbuddy-agent.json actually binds (e.g.
|
|
707
|
+
// BOTBUDDY_MCP_KEY_SG), not the hard-coded default — otherwise the MCP client's
|
|
708
|
+
// configured slot is left unset and auth fails unless --env is redundantly
|
|
709
|
+
// passed. An explicit --env still wins; a malformed binding falls back to the
|
|
710
|
+
// default var (status/env are diagnostics, not the fail-closed wait path).
|
|
711
|
+
const resolveRequestedEnv = async (requested) => {
|
|
712
|
+
if (requested.explicit) return requested;
|
|
713
|
+
try {
|
|
714
|
+
const binding = await readBinding(cwd);
|
|
715
|
+
// A binding that names a NON-default var is an explicit selection of that
|
|
716
|
+
// var (declared in the file rather than on the flag) — so status inspects
|
|
717
|
+
// exactly it. A binding naming the default var stays non-explicit so the
|
|
718
|
+
// deprecated-alias fallback still applies.
|
|
719
|
+
if (binding?.mcpEnv && binding.mcpEnv !== DEFAULT_MCP_ENV_VAR) {
|
|
720
|
+
return { envVar: binding.mcpEnv, explicit: true };
|
|
721
|
+
}
|
|
722
|
+
} catch { /* malformed binding: keep the default var for diagnostics */ }
|
|
723
|
+
return requested;
|
|
724
|
+
};
|
|
725
|
+
|
|
827
726
|
if (sub === "setup") {
|
|
828
727
|
try {
|
|
829
728
|
// Parse inside the try so a bad --env (missing value / reserved name) becomes
|
|
@@ -888,7 +787,7 @@ export async function cmdMcp(args, {
|
|
|
888
787
|
if (sub === "status") {
|
|
889
788
|
let requested;
|
|
890
789
|
try {
|
|
891
|
-
requested = parseMcpStatusEnv(args);
|
|
790
|
+
requested = await resolveRequestedEnv(parseMcpStatusEnv(args));
|
|
892
791
|
} catch (error) {
|
|
893
792
|
const code = error instanceof McpKeyError ? error.code : "invalid_env_var";
|
|
894
793
|
log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
|
|
@@ -911,6 +810,25 @@ export async function cmdMcp(args, {
|
|
|
911
810
|
return;
|
|
912
811
|
}
|
|
913
812
|
|
|
813
|
+
if (sub === "env") {
|
|
814
|
+
// BOT-1608 (AC4): the "export the key" job folded out of the retired
|
|
815
|
+
// `profile env`. Prints the shell loader that reads the MCP key from the
|
|
816
|
+
// Keychain into $envVar so `eval "$(botbuddy mcp env)"` (and the desktop
|
|
817
|
+
// LaunchAgent recipe) load the credential the MCP client / bb-wait consume.
|
|
818
|
+
let requested;
|
|
819
|
+
try {
|
|
820
|
+
requested = await resolveRequestedEnv(parseMcpStatusEnv(args));
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const code = error instanceof McpKeyError ? error.code : "invalid_env_var";
|
|
823
|
+
log(JSON.stringify({ schema_version: 1, outcome: "error", error: code, recovery: mcpRecovery(code) }));
|
|
824
|
+
setExitCode(3);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const envVar = requested.envVar;
|
|
828
|
+
log(`export ${envVar}="$(security find-generic-password -w -a "$USER" -s ${envVar})"`);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
914
832
|
die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
|
|
915
833
|
}
|
|
916
834
|
|
package/src/config.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function loadConfig({ platform = process.platform, warn = (m) => co
|
|
|
65
65
|
warn(
|
|
66
66
|
migrated
|
|
67
67
|
? "✓ Migrated the owner token from ~/.botbuddy/config.json into the macOS Keychain."
|
|
68
|
-
: "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy
|
|
68
|
+
: "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy mcp setup` for agents) to re-establish credentials in the Keychain.",
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
}
|