@botbuddy/cli 1.60.6 → 1.60.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.6",
3
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
4
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
2
+ "release_version": "1.60.8",
3
+ "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
+ "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
5
5
  "target": "darwin-arm64",
6
- "sha256": "2cd38dbe8411dd7499107065d3c526bd171f9edc37bc820aa79ab2c5cead27ec"
6
+ "sha256": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.6",
3
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
4
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
2
+ "release_version": "1.60.8",
3
+ "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
+ "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
5
5
  "target": "linux-x64",
6
- "sha256": "4d422182d63d0ee3ac38bf2823d255fd059ca5195efda355681e8f94137eaf7a"
6
+ "sha256": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.6",
4
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
5
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
3
+ "release_version": "1.60.8",
4
+ "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
5
+ "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "2cd38dbe8411dd7499107065d3c526bd171f9edc37bc820aa79ab2c5cead27ec",
11
+ "sha256": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "4d422182d63d0ee3ac38bf2823d255fd059ca5195efda355681e8f94137eaf7a",
18
+ "sha256": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.60.6",
3
+ "version": "1.60.8",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pw/args.mjs CHANGED
@@ -67,16 +67,20 @@ export function planInvocation(argv, env = process.env) {
67
67
  const authArgs = argv.slice(2);
68
68
  const strayAuth = authArgs.find((value) => credentialFlagOf(value));
69
69
  if (strayAuth) throw misplacedCredentialFlag(strayAuth);
70
- const usage = "bb-pw: usage: bb pw --environment staging <lane> auth --persona <slug>";
70
+ const usage = "bb-pw: usage: bb pw --environment staging <lane> auth --persona <slug> [--pool <pool_id>]";
71
71
  let persona = null;
72
+ let pool = null;
72
73
  for (let i = 0; i < authArgs.length; i++) {
73
74
  const token = String(authArgs[i]);
74
75
  if (token === "--persona" && persona === null && authArgs[i + 1] !== undefined && !String(authArgs[i + 1]).startsWith("--")) { persona = String(authArgs[++i]); continue; }
75
76
  if (token.startsWith("--persona=") && persona === null && token.length > "--persona=".length) { persona = token.slice("--persona=".length); continue; }
77
+ // BOT-2648: which installed pool, when several are installed.
78
+ if (token === "--pool" && pool === null && authArgs[i + 1] !== undefined && /^[a-z0-9][a-z0-9-]{0,63}$/.test(String(authArgs[i + 1]))) { pool = String(authArgs[++i]); continue; }
79
+ if (token.startsWith("--pool=") && pool === null && /^[a-z0-9][a-z0-9-]{0,63}$/.test(token.slice("--pool=".length))) { pool = token.slice("--pool=".length); continue; }
76
80
  throw new Error(`${usage} (unexpected '${token}')`);
77
81
  }
78
82
  if (!persona) throw new Error(`${usage} (--persona is required)`);
79
- return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "auth", persona };
83
+ return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "auth", persona, pool };
80
84
  }
81
85
  if (verb === "status") {
82
86
  // `status` has no workload, so — like the global verbs — there is no `--`
package/src/pw/run.mjs CHANGED
@@ -190,7 +190,10 @@ function consumeWrapperFlags(list, deps, start = 0) {
190
190
  }
191
191
 
192
192
  // The non-secret values a staging-auth refusal names in its next command.
193
- const authVars = (plan) => ({ lane: plan.lane, persona: plan.persona });
193
+ const authVars = (plan, deps = {}) => {
194
+ const cwd = deps.cwd ?? process.cwd();
195
+ return { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: stagingAuth.ticketFlagFor(cwd) };
196
+ };
194
197
 
195
198
  // `bb pw auth --help` / `bb pw [flags] <lane> auth --help`: auth's own help, before
196
199
  // any credential, lock or daemon call.
@@ -229,8 +232,8 @@ async function runPwInner(argv, deps = {}) {
229
232
  // local mode — before any credential, lock or issuer call.
230
233
  if (plan.mode === "auth") {
231
234
  const authEnvironment = resolveEnvironment({ flag: deps.environment ?? null, env });
232
- if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan));
233
- if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan));
235
+ if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan, deps));
236
+ if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan, deps));
234
237
  }
235
238
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
236
239
  if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
@@ -241,7 +244,8 @@ async function runPwInner(argv, deps = {}) {
241
244
  try {
242
245
  const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
243
246
  managedCredential = credential;
244
- deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null };
247
+ // BOT-2648 (Codex P2, #1323): the credential's tenant picks the installed staging pool.
248
+ deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null, credentialTenant: credential.identity?.tenant ?? null };
245
249
  } catch (error) {
246
250
  stderr.write(selfHealErrorLine(error)); return 3;
247
251
  }
@@ -278,7 +282,7 @@ async function runPwInner(argv, deps = {}) {
278
282
  // A lane that is provably no longer ours (not a transient verify error):
279
283
  // clear the session we installed and revoke its custody (AC-3).
280
284
  if (auth.reason === "not_held" || auth.reason === "held_by_other") await stagingAuth.onLaneAuthorityLost(authCtx).catch(() => {});
281
- if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan));
285
+ if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan, deps));
282
286
  stderr.write(`${auth.message}\n`); return 3;
283
287
  }
284
288
  // Report the host the lock service actually stored (alias-collapsed), so the
@@ -305,10 +309,10 @@ async function runPwInner(argv, deps = {}) {
305
309
  const me = await authCtx.resolveCallerId().catch(() => null);
306
310
  if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
307
311
  }
308
- if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan));
312
+ if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
309
313
  if (tracker) {
310
314
  const browserSession = await tracker.status();
311
- if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan));
315
+ if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan, deps));
312
316
  }
313
317
  return stagingAuth.runStagingAuth(authCtx);
314
318
  }
@@ -15,6 +15,7 @@
15
15
  // clear that fails blocks the lane until it is resolved.
16
16
  // * The ledger (~/.botbuddy/pw-staging-auth/<host>/lane-<n>.json, 0600) holds
17
17
  // non-secret install records only.
18
+ import { execFileSync } from "node:child_process";
18
19
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
19
20
  import { homedir } from "node:os";
20
21
  import { dirname, join } from "node:path";
@@ -229,8 +230,24 @@ const GUIDED_FIELDS = ["message", "cause", "actor", "next", "doc", "help", "agen
229
230
  * `extra` fields already rendered by the pool (an empty pool's health reason) win.
230
231
  * `vars` are non-secret only: lane, persona alias, allowed aliases, checkout.
231
232
  */
233
+ /**
234
+ * ` --ticket BOT-123` from the checkout's branch name (`…/bot-123-…`), or "" so a
235
+ * lane-claim command never prints a <ticket> placeholder. Best effort, refusal-only.
236
+ */
237
+ export function ticketFlagFor(cwd, { exec = execFileSync } = {}) {
238
+ try {
239
+ // symbolic-ref also names an unborn branch; a detached HEAD has no ticket.
240
+ const branch = String(exec("git", ["-C", cwd, "symbolic-ref", "--short", "-q", "HEAD"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] })).trim();
241
+ const match = /(?:^|[/_-])((?:bot|ent)-\d+)(?=$|[/_-])/i.exec(branch);
242
+ return match ? ` --ticket ${match[1].toUpperCase()}` : "";
243
+ } catch {
244
+ return "";
245
+ }
246
+ }
247
+
232
248
  export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars = {}) {
233
- const g = guidance(isKnownCode(code) ? code : "pool_take_failed", vars);
249
+ // A real refusal: never a <placeholder> (AC-8 follow-up).
250
+ const g = guidance(isKnownCode(code) ? code : "pool_take_failed", { consequence: "bb pw auth has no pool to take a session from", ...vars }, { real: true });
234
251
  const guided = { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help, agentAction: g.agent_action, remedy: remedyLine(g) };
235
252
  for (const field of GUIDED_FIELDS) if (extra[field] !== undefined) guided[field] = extra[field];
236
253
  const text = {
@@ -256,15 +273,28 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
256
273
  const poolModule = deps.stagingPoolModule ?? await loadPool();
257
274
  const poolOptions = deps.stagingPool ?? {};
258
275
  const cwd = deps.cwd ?? process.cwd();
259
- const vars = { lane: plan.lane, persona: plan.persona, checkout: cwd };
260
- let found;
276
+ const authLine = `bb pw --environment staging ${plan.lane} auth --persona ${plan.persona}`;
277
+ // The lane credential's tenant (resolved by run.mjs), else an explicit hint.
278
+ const tenantHint = deps.credentialTenant ?? deps.tenant ?? env.BOTBUDDY_TENANT ?? null;
279
+ const vars = { lane: plan.lane, persona: plan.persona, ticketFlag: ticketFlagFor(cwd), consequence: "bb pw auth has no pool to take a session from" };
280
+ // BOT-2648: the pool comes from the installed pool home (--pool, the only one,
281
+ // or the one for this tenant), never from the working directory.
282
+ let ctxPool;
261
283
  try {
262
- found = await (await loadBinding()).readRepoPoolBinding(cwd);
284
+ ctxPool = poolModule.resolveInstalledPool({ ...(poolOptions.home ? { home: poolOptions.home } : {}), poolId: plan.pool ?? null, tenantHint });
263
285
  } catch (error) {
264
- return refuse(io, error?.code === "pool_binding_invalid" ? "pool_binding_invalid" : "pool_not_configured", 3, { detail: String(error?.message ?? error) }, vars);
286
+ const poolIds = Array.isArray(error?.poolIds) ? error.poolIds : [];
287
+ const code = error?.code === "pool_ambiguous" ? "pool_ambiguous" : "pool_not_installed";
288
+ return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, {
289
+ ...vars,
290
+ tenant: tenantHint ?? undefined,
291
+ poolIds,
292
+ poolId: plan.pool ?? undefined,
293
+ retryWithPool: poolIds.length ? `${authLine} --pool ${poolIds[0]}` : undefined,
294
+ });
265
295
  }
266
- if (!found) return refuse(io, "pool_not_configured", 3, {}, vars);
267
- Object.assign(vars, { checkout: found.repoRoot ?? cwd, allowed: Object.keys(found.binding.personas ?? {}) });
296
+ const found = { binding: ctxPool.poolBinding };
297
+ Object.assign(vars, { allowed: Object.keys(found.binding.personas ?? {}), tenant: ctxPool.tenant ?? undefined, poolId: ctxPool.poolId ?? undefined });
268
298
  const { findPersona } = await loadBinding();
269
299
  const persona = findPersona(found.binding, plan.persona);
270
300
  if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
@@ -281,7 +311,7 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
281
311
  dropGone(ctx, generation);
282
312
  let claim;
283
313
  try {
284
- claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, cwd, ...poolOptions });
314
+ claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions });
285
315
  } catch (error) {
286
316
  if (error?.code === "pool_empty") {
287
317
  return refuse(io, "pool_empty", 3, Object.fromEntries(["reason", "detail", ...GUIDED_FIELDS].filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
@@ -1,14 +1,10 @@
1
- // BOT-2084: the per-repo staging session pool binding.
1
+ // BOT-2084 / BOT-2648: the staging session pool binding.
2
2
  //
3
- // A repository opts in by declaring `staging_session_pool` in its committed
4
- // `.botbuddy-agent.json` (next to its tenant binding). This module parses and
5
- // validates that section into the camelCase shape the pool uses. Node-stdlib-only
6
- // and self-contained: the installed job keeps a validated snapshot in its config.
7
-
8
- import { readFile } from "node:fs/promises";
9
- import { dirname, join, parse } from "node:path";
10
-
11
- export const AGENT_BINDING_FILE = ".botbuddy-agent.json";
3
+ // A tenant registers its binding in the BotBuddy control plane
4
+ // (register_staging_pool); `bb staging-pool install --tenant` reads it and
5
+ // this module parses and validates it into the camelCase shape the pool uses. It
6
+ // is never read from a repository checkout. Node-stdlib-only and self-contained:
7
+ // the installed job keeps a validated snapshot in its config.
12
8
 
13
9
  export class BindingError extends Error {
14
10
  constructor(message, code = "pool_binding_invalid") {
@@ -29,8 +25,21 @@ const PUBLIC_KEY_RE = /^(sb_publishable_[A-Za-z0-9_-]{16,128}|eyJ[A-Za-z0-9_-]+\
29
25
  export const ADAPTERS = Object.freeze(["supabase-gotrue-totp"]);
30
26
  export const MAX_POOL_SIZE = 4;
31
27
 
28
+ // A legacy Supabase key is a JWT; only the public `anon` role is a publishable key
29
+ // (Codex P1, #1323: a service_role JWT is a secret and is never installed).
30
+ export function isPublicAnonJwt(value) {
31
+ const parts = String(value).split(".");
32
+ if (parts.length !== 3) return false;
33
+ try {
34
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
35
+ return claims !== null && typeof claims === "object" && claims.role === "anon";
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
32
41
  function need(condition, message) {
33
- if (!condition) throw new BindingError(`${AGENT_BINDING_FILE} staging_session_pool: ${message}`);
42
+ if (!condition) throw new BindingError(`staging_session_pool binding: ${message}`);
34
43
  }
35
44
 
36
45
  // raw: the parsed .botbuddy-agent.json object. Returns null when the repo does not
@@ -48,12 +57,15 @@ export function parsePoolBinding(raw) {
48
57
  need(typeof section.app_origin === "string", "app_origin is required");
49
58
  need(typeof section.supabase_url === "string", "supabase_url is required");
50
59
  need(typeof section.publishable_key === "string" && PUBLIC_KEY_RE.test(section.publishable_key), "publishable_key must be the public publishable/anon key");
60
+ need(!section.publishable_key.startsWith("eyJ") || isPublicAnonJwt(section.publishable_key), "a legacy JWT publishable_key must carry role anon (a service_role key is a secret)");
51
61
  need(typeof section.storage_key === "string" && STORAGE_KEY_RE.test(section.storage_key), "storage_key must be the app's supabase-js auth storage key");
52
62
  need(Array.isArray(section.production_origins ?? []), "production_origins must be an array");
53
63
  need(typeof section.password_env_file === "string" && /^(~\/|\/)/.test(section.password_env_file), "password_env_file must be an absolute or ~/ path");
54
64
  need(typeof section.launchd_label === "string" && LABEL_RE.test(section.launchd_label), "launchd_label must be a reverse-DNS label");
55
- need(typeof section.docs === "string" && section.docs.length > 0, "docs (where the pool is documented) is required");
56
- need(typeof section.install_command === "string" && section.install_command.length > 0, "install_command (the human's install line) is required");
65
+ // BOT-2648: docs is optional, and install_command is retired (install reads the
66
+ // control plane by tenant); a pre-registry snapshot may still carry it.
67
+ need(section.docs === undefined || (typeof section.docs === "string" && section.docs.length > 0), "docs must be a non-empty string when present");
68
+ need(section.install_command === undefined || typeof section.install_command === "string", "install_command must be a string when present");
57
69
  const personas = section.personas;
58
70
  need(personas && typeof personas === "object" && Object.keys(personas).length > 0, "personas must name at least one persona");
59
71
  const parsedPersonas = {};
@@ -84,7 +96,6 @@ export function parsePoolBinding(raw) {
84
96
  personas: Object.freeze(parsedPersonas),
85
97
  launchdLabel: section.launchd_label,
86
98
  docs: section.docs,
87
- installCommand: section.install_command,
88
99
  });
89
100
  }
90
101
 
@@ -94,29 +105,3 @@ export function findPersona(binding, aliasOrEmail) {
94
105
  if (Object.hasOwn(binding.personas, key)) return binding.personas[key];
95
106
  return Object.values(binding.personas).find((persona) => persona.email === key) ?? null;
96
107
  }
97
-
98
- // Walk up from cwd to the committed binding file; null when none or no section.
99
- export async function readRepoPoolBinding(cwd = process.cwd(), { read = readFile } = {}) {
100
- let dir = cwd;
101
- const root = parse(dir).root;
102
- while (true) {
103
- let text = null;
104
- try {
105
- text = await read(join(dir, AGENT_BINDING_FILE), "utf8");
106
- } catch (error) {
107
- if (error?.code !== "ENOENT") throw error;
108
- }
109
- if (text !== null) {
110
- let raw;
111
- try {
112
- raw = JSON.parse(text);
113
- } catch {
114
- throw new BindingError(`${AGENT_BINDING_FILE} is not valid JSON`);
115
- }
116
- const binding = parsePoolBinding(raw);
117
- return binding ? { binding, repoRoot: dir } : null;
118
- }
119
- if (dir === root) return null;
120
- dir = dirname(dir);
121
- }
122
- }