@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.
@@ -3,15 +3,19 @@
3
3
  // command and the doc section. `bb help symptom <code>` prints the same entry.
4
4
  //
5
5
  // Node-stdlib-only and self-contained (the installed job copies this directory).
6
- // Templates take only non-secret values (lane, persona alias, paths, labels);
7
- // a missing value renders as a <placeholder>, never as an empty string.
6
+ // Templates take only non-secret values (lane, persona alias, paths, labels).
7
+ // A real refusal (`{ real: true }`) never prints a <placeholder>: a missing value
8
+ // in prose becomes plain words, and a `next` command that needs a value it does
9
+ // not have falls back to the command that finds it (e.g. `botbuddy call whoami` for
10
+ // the tenant). Only
11
+ // `bb help symptom` (the reference view) shows <placeholders>.
8
12
 
9
13
  export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
10
14
 
11
15
  // Heading anchors in DOC_URL (checked by errors.test.mjs against the doc).
12
16
  export const DOC_SECTIONS = Object.freeze({
13
17
  overview: "staging-session-pool-bot-2084",
14
- configure: "configure-a-repository",
18
+ configure: "register-a-tenants-pool",
15
19
  install: "install-update-remove-human-only",
16
20
  take: "take-a-session-agent",
17
21
  close: "close-and-the-residual-window",
@@ -39,15 +43,15 @@ export const ERROR_CODES = Object.freeze({
39
43
  auth_requires_lane_lock: entry("agent", "take",
40
44
  "bb pw auth needs a lock-gated lane, and this run is unlocked.",
41
45
  "BB_PW_NO_LOCK=1 is set in this shell.",
42
- `bb locks -m --lane {lane} --ticket <ticket> && env -u BB_PW_NO_LOCK ${AUTH}`),
46
+ `bb locks -m --lane {lane}{ticketFlag} && env -u BB_PW_NO_LOCK ${AUTH}`),
43
47
  lane_not_claimed: entry("agent", "take",
44
48
  "The lane lock is not held by this agent session.",
45
49
  "The lane was never claimed, its lock expired, or another agent holds it.",
46
- `bb locks -m --lane {lane} --ticket <ticket> && ${AUTH}`),
50
+ `bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
47
51
  exact_lane_holder_required: entry("agent", "take",
48
52
  "The lane lock is held by another agent session of your owner, not by this session.",
49
53
  "The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
50
- `bb locks -m --lane {lane} --ticket <ticket> # from THIS session, then: ${AUTH}`),
54
+ `bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
51
55
  persona_not_allowlisted: entry("agent", "take",
52
56
  "Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
53
57
  "A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
@@ -64,10 +68,10 @@ export const ERROR_CODES = Object.freeze({
64
68
  "Lane {lane} uses a persistent browser profile, which would write the session to disk.",
65
69
  "The lane was opened with --persistent or --profile.",
66
70
  `${REOPEN} && ${AUTH} # no --persistent / --profile`),
67
- pool_not_configured: entry("agent", "configure",
68
- "This directory's repository declares no staging_session_pool, so there is no pool to take from.",
69
- "The command ran outside the repository checkout, or the repository has not opted in.",
70
- "cd <repository checkout> && {retry}"),
71
+ pool_ambiguous: entry("agent", "take",
72
+ "Several staging session pools match ({poolIds}), so the command does not guess.",
73
+ "More than one pool is installed (or registered for the tenant) and none was named.",
74
+ "{retryWithPool}"),
71
75
 
72
76
  // ── bb pw … auth: install into the lane (agent retries once, then reports)
73
77
  session_install_failed: entry("agent", "take",
@@ -139,15 +143,15 @@ export const ERROR_CODES = Object.freeze({
139
143
  mint_backing_off: entry("human", "repair",
140
144
  "Staging sign-in for {persona} keeps failing, so the job is backing off.",
141
145
  "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
142
- "grep mint_failed {log} | tail -1 # then the matching code: bb help symptom <code>"),
146
+ "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
143
147
  last_tick_failed: entry("human", "repair",
144
- "The pool job's last tick could not sign {persona} in.",
148
+ "The pool job's last tick had sign-in (mint) failures.",
145
149
  "The last mint_failed line in {log} names the cause.",
146
150
  "grep mint_failed {log} | tail -1 # repair that code, then: {tickCommand}"),
147
151
  ticks_failing: entry("human", "repair",
148
152
  "Pool ticks run but none has succeeded for 3+ intervals.",
149
153
  "A persistent mint failure; {log} names it.",
150
- "grep mint_failed {log} | tail -1 # then: bb help symptom <code>"),
154
+ "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
151
155
  persona_below_target: entry("human", "repair",
152
156
  "The pool has been below its configured size for {persona} for 3+ ticks.",
153
157
  "Agents take sessions faster than the job refills them, or mints are partly failing ({log}).",
@@ -161,8 +165,8 @@ export const ERROR_CODES = Object.freeze({
161
165
  "It was edited by hand, or an incompatible CLI wrote it.",
162
166
  INSTALL + " # rewrites it from the binding; or fix the named field in {configPath}"),
163
167
  pool_not_installed: entry("human", "install",
164
- "The pool job is not installed, so it cannot tick or mint.",
165
- "The one-time install has not been run on this Mac.",
168
+ "No staging session pool is installed on this Mac{poolSuffix}, so {consequence}.",
169
+ "The one-time install has not been run here (pools install from the BotBuddy control plane with --tenant).",
166
170
  INSTALL),
167
171
  pool_target_mismatch: entry("human", "repair",
168
172
  "The pool home belongs to a different staging target than the repository binding now names.",
@@ -180,26 +184,44 @@ export const ERROR_CODES = Object.freeze({
180
184
  // ── who may run what
181
185
  agent_context: entry("human", "install",
182
186
  "This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
183
- `It ran from a Claude or Codex terminal. ${HUMAN_ONLY}`,
184
- "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
187
+ `It ran from a Claude or Codex terminal; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
188
+ "bb staging-pool {command}"),
185
189
  agent_context_unknown: entry("human", "install",
186
190
  "bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
187
- `/bin/ps is unavailable or restricted in this shell. ${HUMAN_ONLY}`,
188
- "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
191
+ `/bin/ps is unavailable or restricted in this shell; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
192
+ "bb staging-pool {command}"),
189
193
 
190
- // ── configuration (repository binding and installed config)
194
+ // ── the control-plane registry (BOT-2648)
195
+ tenant_required: entry("human", "install",
196
+ "bb staging-pool install needs the tenant whose registered pool to install.",
197
+ "--tenant was not given; pool bindings are registered per tenant in the BotBuddy control plane.",
198
+ INSTALL),
199
+ tenant_pool_not_registered: entry("human", "configure",
200
+ "Tenant {tenant} has no registered staging session pool{poolSuffix}.",
201
+ "The tenant's staging seed job has not registered one yet (register_staging_pool), or the pool id is wrong.",
202
+ "botbuddy call get_staging_pool --pin {tenant} # lists what is registered; the tenant's staging seed job registers it"),
203
+ pool_registry_unavailable: entry("human", "install",
204
+ "The BotBuddy control plane did not return the tenant's pool binding.",
205
+ "botbuddy is signed out on this Mac, the tenant pin is not one of your memberships, or BotBuddy is unreachable.",
206
+ "botbuddy login && " + INSTALL),
207
+ pool_registration_refused: entry("human", "configure",
208
+ "The registered pool binding does not pass this CLI's checks (see detail), so nothing was installed.",
209
+ "The registration is malformed, or it uses a newer schema than this @botbuddy/cli understands.",
210
+ "npm i -g @botbuddy/cli@latest && " + INSTALL + " # if it still refuses, fix the tenant's registration"),
211
+
212
+ // ── configuration (the registered binding and the installed config)
191
213
  config_missing: entry("human", "configure",
192
214
  "A required staging_session_pool value is missing (see detail).",
193
- "The repository binding or installed config lacks a field.",
194
- INSTALL + " # after fixing .botbuddy-agent.json staging_session_pool"),
215
+ "The registered binding or installed config lacks a field.",
216
+ INSTALL),
195
217
  pool_binding_invalid: entry("human", "configure",
196
- "The repository's .botbuddy-agent.json staging_session_pool is invalid (see detail).",
197
- "A committed edit broke a field; the message names it.",
198
- "git log -p -1 -- .botbuddy-agent.json # fix the named field in a PR, then: bb staging-pool status"),
218
+ "The registered staging_session_pool binding is invalid (see detail).",
219
+ "The tenant's registration broke a field; the message names it.",
220
+ "botbuddy call get_staging_pool --pin {tenant} # fix the named field in the tenant's registration, then: " + INSTALL),
199
221
  origin_not_allowed: entry("human", "configure",
200
222
  "The binding's app origin or Supabase URL is not an allowed staging target (see detail).",
201
- "It is http, loopback, a path rather than a bare origin, or a production origin.",
202
- "git log -p -1 -- .botbuddy-agent.json # point app_origin / supabase_url at staging in a PR"),
223
+ "It is http, loopback, a path rather than a bare origin, or a production origin; this CLI refuses it whatever the control plane holds.",
224
+ "botbuddy call get_staging_pool --pin {tenant} # point app_origin / supabase_url at staging in the tenant's registration"),
203
225
  persona_not_allowed: entry("agent", "take",
204
226
  "Persona \"{persona}\" is not an allowlisted seeded persona of this pool.",
205
227
  "A typo, or a persona the binding does not list. Allowed: {allowed}.",
@@ -277,7 +299,7 @@ export const ERROR_CODES = Object.freeze({
277
299
  persona_mismatch: entry("human", "repair",
278
300
  "MFA verify returned a session for a different user.",
279
301
  "The binding's persona email does not match the seeded user.",
280
- "git log -p -1 -- .botbuddy-agent.json # check personas, then: bb staging-pool mint-one --persona {persona}"),
302
+ "botbuddy call get_staging_pool --pin {tenant} # check the registered personas, then: bb staging-pool mint-one --persona {persona}"),
281
303
 
282
304
  unexpected_error: entry("agent", "codes",
283
305
  "bb staging-pool failed unexpectedly (see detail).",
@@ -285,27 +307,63 @@ export const ERROR_CODES = Object.freeze({
285
307
  "bb staging-pool status # once; report this code, the detail and the CLI version (bb version)", "report_blocker"),
286
308
  });
287
309
 
310
+ // Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
311
+ export const AUTH_ONLY_CODES = Object.freeze(new Set([
312
+ "auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
313
+ "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
314
+ "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
315
+ "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
316
+ "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
317
+ ]));
318
+
319
+ // `bb staging-pool` subcommands only a human runs. A refusal from one names
320
+ // Human (Jono) whatever the code, and prints no Agent guidance.
321
+ export const HUMAN_ONLY_COMMANDS = Object.freeze(new Set(["install", "uninstall", "tick", "mint-one"]));
322
+
323
+ // With no tenant known, a remedy names the command that reports it (BOT-2648).
324
+ export const FIND_TENANT_NEXT = 'botbuddy call whoami # its tenant_id is your --tenant; then: bb staging-pool install --tenant (that slug) --botbuddy-bin "$(command -v botbuddy)"';
325
+ export const installLine = (tenant, poolId) => `bb staging-pool install --tenant ${tenant}${poolId ? ` --pool ${poolId}` : ""} --botbuddy-bin "$(command -v botbuddy)"`;
326
+
288
327
  // Vars that are already whole command lines; every other var is a single shell word.
289
- const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry"]);
328
+ const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "retryWithPool", "ticketFlag"]);
329
+ // Vars where an empty string is a real value (no flag), not a missing one.
330
+ const OPTIONAL_VARS = new Set(["ticketFlag"]);
290
331
  const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
291
332
  // A <placeholder> stays as written: the reader replaces it.
292
333
  const shellWord = (value) => (SHELL_SAFE.test(value) || /^<[^<>]+>$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
293
334
 
294
- // In a `next` command a caller-supplied value (a typed persona, a checkout path
295
- // with spaces) is shell-quoted, so the line stays one safe copy-paste.
296
- function fill(template, vars, { shell = false } = {}) {
335
+ // What a missing value reads as in prose (real refusals only).
336
+ const DESCRIBED = Object.freeze({
337
+ persona: "the persona", lane: "the lane", log: "the pool log", configPath: "the installed pool config",
338
+ label: "the launchd job", envFile: "the persona password file", markers: "agent markers",
339
+ cwd: "This directory", consequence: "there is no pool to act on", command: "this command",
340
+ tenant: "your tenant", poolIds: "several pools", poolSuffix: "", allowed: "the binding's personas", firstAllowed: "a listed persona",
341
+ });
342
+
343
+ const isMissing = (key, value) => value === undefined || value === null || (value === "" && !OPTIONAL_VARS.has(key)) || (Array.isArray(value) && value.length === 0);
344
+
345
+ // In a `next` command a caller-supplied value (a typed persona, a path with
346
+ // spaces) is shell-quoted, so the line stays one safe copy-paste.
347
+ function fill(template, vars, { shell = false, missing = null, describe = false } = {}) {
297
348
  return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
298
349
  const value = vars?.[key];
299
- if (Array.isArray(value)) return value.length ? value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ") : `<${key}>`;
300
- if (value === undefined || value === null || value === "") return `<${key}>`;
350
+ if (isMissing(key, value)) {
351
+ missing?.add(key);
352
+ return describe ? (DESCRIBED[key] ?? "it") : `<${key}>`;
353
+ }
354
+ if (Array.isArray(value)) return value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ");
301
355
  return shell && !COMMAND_VARS.has(key) ? shellWord(String(value)) : String(value);
302
356
  });
303
357
  }
304
358
 
305
359
  export const isKnownCode = (code) => Object.hasOwn(ERROR_CODES, String(code ?? ""));
306
360
 
307
- /** The guidance for one code, interpolated with non-secret vars. */
308
- export function guidance(code, vars = {}) {
361
+ /**
362
+ * The guidance for one code, interpolated with non-secret vars.
363
+ * real a refusal someone will act on: no <placeholder> is ever printed.
364
+ * actor "human" for a human-only subcommand, whatever the code's default.
365
+ */
366
+ export function guidance(code, vars = {}, { real = false, actor = null } = {}) {
309
367
  const known = isKnownCode(code);
310
368
  const spec = known ? ERROR_CODES[code] : ERROR_CODES.unexpected_error;
311
369
  const allowed = Array.isArray(vars.allowed) ? vars.allowed : vars.allowed ? [vars.allowed] : [];
@@ -313,20 +371,37 @@ export function guidance(code, vars = {}) {
313
371
  ...vars,
314
372
  allowed,
315
373
  firstAllowed: vars.firstAllowed ?? allowed[0],
316
- // Without a resolved pool these still read as runnable commands from the checkout.
374
+ ticketFlag: vars.ticketFlag ?? (real ? "" : " --ticket <ticket>"),
375
+ poolSuffix: vars.poolSuffix ?? (vars.poolId ? ` (${vars.poolId})` : ""),
317
376
  tickCommand: vars.tickCommand ?? "bb staging-pool tick",
318
- installCommand: vars.installCommand ?? `cd ${vars.checkout ? shellWord(String(vars.checkout)) : "<checkout>"} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"`,
377
+ // Install reads the control plane, so it needs only the tenant: no checkout, no cwd.
378
+ installCommand: vars.installCommand ?? (vars.tenant ? installLine(shellWord(String(vars.tenant)), vars.installPool ?? null) : (real ? undefined : installLine("<tenant>"))),
319
379
  };
320
- // What a "go to the right checkout" step re-runs: auth by default.
380
+ // What a refusal re-runs: auth by default (the consumer), the subcommand otherwise.
321
381
  filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
382
+ filled.retryWithPool = vars.retryWithPool ?? (real ? undefined : `${filled.retry} --pool <pool_id>`);
383
+ let next;
384
+ if (real) {
385
+ const missing = new Set();
386
+ next = fill(spec.next, filled, { shell: true, missing });
387
+ if (missing.size) {
388
+ // A value only the tenant or a resolved pool knows is missing: give the
389
+ // command that finds the tenant, or re-run the subcommand to get the
390
+ // specific refusal with real values. Never a <placeholder>.
391
+ next = missing.has("tenant") || missing.has("installCommand") ? FIND_TENANT_NEXT : filled.retry;
392
+ }
393
+ } else {
394
+ next = fill(spec.next, filled, { shell: true });
395
+ }
396
+ const finalActor = actor ?? spec.actor;
322
397
  return {
323
398
  code: known ? code : String(code ?? "unexpected_error"),
324
- message: fill(spec.message, filled),
325
- cause: fill(spec.cause, filled),
326
- actor: spec.actor,
327
- actor_label: ACTOR_LABELS[spec.actor],
328
- next: fill(spec.next, filled, { shell: true }),
329
- agent_action: spec.agentAction,
399
+ message: fill(spec.message, filled, { describe: real }),
400
+ cause: fill(spec.cause, filled, { describe: real }),
401
+ actor: finalActor,
402
+ actor_label: ACTOR_LABELS[finalActor],
403
+ next,
404
+ agent_action: finalActor === "human" ? "report_blocker" : spec.agentAction,
330
405
  doc: `${DOC_URL}#${DOC_SECTIONS[spec.doc]}`,
331
406
  help: `bb help symptom ${known ? code : "unexpected_error"}`,
332
407
  };
@@ -339,13 +414,13 @@ export function remedyLine(g) {
339
414
  }
340
415
 
341
416
  /** Plain-text block for stderr. */
342
- export function formatGuidance(g, { prefix = "bb staging-pool" } = {}) {
417
+ export function formatGuidance(g, { prefix = "bb staging-pool", agentLine = true } = {}) {
343
418
  const lines = [
344
419
  `${prefix}: ${g.message} [${g.code}]`,
345
420
  ` Likely cause: ${g.cause}`,
346
421
  ` Next (${g.actor_label}${g.actor === "human" ? ", from Terminal.app or iTerm" : ""}): ${g.next}`,
347
422
  ];
348
- if (g.actor === "human") lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
423
+ if (g.actor === "human" && agentLine) lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
349
424
  lines.push(` Docs: ${g.doc}`, ` Explain: ${g.help}`);
350
425
  return lines.join("\n");
351
426
  }
@@ -15,7 +15,7 @@ export const HEALTH_REASON_CODES = Object.freeze([
15
15
  export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
16
16
  const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
17
17
  return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
18
- const g = guidance(code, vars);
18
+ const g = guidance(code, vars, { real: true });
19
19
  return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
20
20
  })));
21
21
  }
@@ -35,7 +35,7 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
35
35
  const record = { ok: false, reason, agentAction, ...extra };
36
36
  if (vars) {
37
37
  // Re-rendered with the persona this assessment is about.
38
- const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona });
38
+ const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona }, { real: true });
39
39
  remedy = remedyLine(g);
40
40
  Object.assign(record, { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help });
41
41
  }
@@ -4,18 +4,23 @@ import { DOC_SECTIONS, DOC_URL } from "./errors.mjs";
4
4
 
5
5
  const doc = (section) => `${DOC_URL}#${DOC_SECTIONS[section]}`;
6
6
 
7
- export const BINDING_KEYS_HELP = ` .botbuddy-agent.json (committed, at the repository root) → staging_session_pool:
8
- schema_version 1, pool_id, environment "staging", app_slug, adapter
9
- "supabase-gotrue-totp", app_origin, supabase_url, publishable_key (public),
10
- storage_key, production_origins, password_env_file, launchd_label, docs,
11
- install_command, personas { <alias>: { email, password_env, default_count 0-4 } }.
12
- No flag or environment variable can change the origin or add a persona.`;
7
+ export const BINDING_KEYS_HELP = ` Registered per tenant in the BotBuddy control plane by the tenant's staging seed
8
+ job (register_staging_pool); read with
9
+ botbuddy call get_staging_pool --pin <tenant>. Keys: schema_version 1,
10
+ pool_id, environment "staging", app_slug, adapter "supabase-gotrue-totp",
11
+ app_origin, supabase_url, publishable_key (public), storage_key,
12
+ production_origins, password_env_file, launchd_label, docs,
13
+ personas { <alias>: { email, password_env, default_count 0-4 } }.
14
+ No secret is registered: each persona's password stays in password_env_file
15
+ (0600) on this Mac. Install refuses a production, http or loopback origin
16
+ whatever is registered. No repository checkout is read.`;
13
17
 
14
18
  export const PW_AUTH_HELP = `bb pw auth — sign a claimed browser lane in to staging as a seeded test persona (BOT-2084).
15
19
 
16
20
  USAGE
17
- bb pw --environment staging <lane> auth --persona <alias>
18
- (--environment and credential flags go BEFORE the lane; --persona goes after auth)
21
+ bb pw --environment staging <lane> auth --persona <alias> [--pool <pool_id>]
22
+ (--environment and credential flags go BEFORE the lane; --persona and --pool go
23
+ after auth; --pool only when several pools are installed)
19
24
 
20
25
  WHO RUNS IT
21
26
  Agents (and humans). It never types a password or one-time code: it installs a
@@ -23,10 +28,10 @@ WHO RUNS IT
23
28
  (bb staging-pool --help).
24
29
 
25
30
  BEFORE YOU RUN IT
26
- 1. You are in a checkout of a repository whose .botbuddy-agent.json declares
27
- staging_session_pool (check: bb staging-pool status).
28
- 2. The human has installed the pool on this Mac
29
- (check: bb staging-pool status --check prints "ok": true).
31
+ 1. The human has installed the tenant's pool on this Mac, from the BotBuddy
32
+ control plane (check: bb staging-pool status --check prints "ok": true).
33
+ Any working directory works; no repository checkout is needed.
34
+ 2. With several pools installed, pass --pool (bb staging-pool status lists them).
30
35
  3. THIS agent session holds the lane lock, and the lane is open on staging and
31
36
  not persistent:
32
37
  bb locks -m --lane <lane> --ticket <ticket>
@@ -64,15 +69,16 @@ IF IT REFUSES
64
69
  export const STAGING_POOL_HELP = `bb staging-pool — the human-installed job that keeps signed-in staging sessions ready for agents.
65
70
 
66
71
  A launchd job on this Mac signs each allowlisted seeded persona in to the
67
- repository's staging app (password + TOTP via BotBuddy get_totp_code) every
72
+ tenant's staging app (password + TOTP via BotBuddy get_totp_code) every
68
73
  5 minutes and keeps a few single-use sessions ready. Agents never sign in: they
69
74
  take a session with bb pw --environment staging <lane> auth --persona <alias>
70
75
  (bb pw auth --help).
71
76
 
72
- Run from the repository checkout: every command reads .botbuddy-agent.json there.
77
+ Install reads the tenant's pool from the BotBuddy control plane, so every command
78
+ runs from any directory. With several pools installed, name one with --pool <pool_id>.
73
79
 
74
80
  ANYONE (agents too) — read-only
75
- status [--check] [--persona <alias>]
81
+ status [--check] [--persona <alias>] [--pool <pool_id>]
76
82
  Counts and last tick; never session material. --check prints one health
77
83
  reason with who acts and the next command (exit 5 when unhealthy).
78
84
  e.g. bb staging-pool status --check --persona staging-admin
@@ -86,11 +92,13 @@ ANYONE (agents too) — read-only
86
92
 
87
93
  HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Codex session
88
94
  These sign in with the persona password or change the standing sessions.
89
- install --botbuddy-bin <absolute path> [--interval <seconds>]
90
- Copies the job to the pool home, writes its config and loads the launchd
91
- job. Re-run after upgrading @botbuddy/cli; it keeps counts and interval.
92
- e.g. cd <checkout> && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"
93
- tick [--home <pool home>]
95
+ install --tenant <slug> [--pool <pool_id>] --botbuddy-bin <absolute path> [--interval <seconds>]
96
+ Reads the tenant's registered pool from the BotBuddy control plane, copies
97
+ the job to the pool home, writes its config and loads the launchd job. Run
98
+ it from any directory. Re-run after upgrading @botbuddy/cli; it keeps the
99
+ counts and interval. --pool is needed only when the tenant has several.
100
+ e.g. bb staging-pool install --tenant botbuddy --botbuddy-bin "$(command -v botbuddy)"
101
+ tick [--pool <pool_id>] [--home <pool home>]
94
102
  One refresh and refill pass now (what the job runs).
95
103
  e.g. bb staging-pool tick
96
104
  mint-one --persona <alias>
@@ -101,8 +109,9 @@ HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Cod
101
109
  e.g. bb staging-pool uninstall
102
110
 
103
111
  PREREQUISITES (human, once per Mac)
104
- - @botbuddy/cli installed globally, and botbuddy login done (the job calls
105
- botbuddy call get_totp_code).
112
+ - @botbuddy/cli installed globally, and botbuddy login done as a member of the
113
+ tenant (install reads get_staging_pool; the job calls get_totp_code).
114
+ - The tenant's pool registered (its staging seed job does this).
106
115
  - The persona password in the binding's password_env_file, mode 0600, never
107
116
  committed (BotBuddy: ~/.config/botbuddy-web/.env.local.shared).
108
117
 
@@ -0,0 +1,79 @@
1
+ // BOT-2648: the pool binding comes from the BotBuddy control plane
2
+ // (get_staging_pool), read through the human's `botbuddy` CLI login and
3
+ // pinned to the tenant. This is the only source `bb staging-pool install` reads;
4
+ // no command reads a staging_session_pool block from the working directory.
5
+ // Node-stdlib-only and self-contained (bundled with the installed job).
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ import { AGENT_CONTEXT_ENV_MARKERS } from "./policy.mjs";
11
+
12
+ export class RegistryError extends Error {
13
+ constructor(code, message, extra = {}) {
14
+ super(message);
15
+ this.name = "RegistryError";
16
+ this.code = code;
17
+ Object.assign(this, extra);
18
+ }
19
+ }
20
+
21
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
22
+ const defaultExec = promisify(execFile);
23
+
24
+ function childEnv(env) {
25
+ const next = { ...env };
26
+ for (const name of AGENT_CONTEXT_ENV_MARKERS) delete next[name];
27
+ return next;
28
+ }
29
+
30
+ /** The tenant's registered pools: [{ pool_id, binding, commit_sha, updated_at }]. */
31
+ export async function fetchRegisteredPools({ exec = defaultExec, botbuddyBin, env = process.env, tenant, poolId = null }) {
32
+ if (!SLUG_RE.test(String(tenant ?? ""))) throw new RegistryError("tenant_required", "A --tenant slug is required.");
33
+ let stdout;
34
+ try {
35
+ ({ stdout } = await exec(botbuddyBin, ["call", "get_staging_pool", "--pin", tenant, "--json", JSON.stringify(poolId ? { pool_id: poolId } : {})], {
36
+ env: childEnv(env),
37
+ timeout: 60_000,
38
+ maxBuffer: 1024 * 1024,
39
+ }));
40
+ } catch (error) {
41
+ const stderr = String(error?.stderr ?? "").slice(0, 300);
42
+ throw new RegistryError("pool_registry_unavailable", `botbuddy call get_staging_pool failed (exit ${error?.code ?? "?"}): ${stderr}`, { tenant });
43
+ }
44
+ let body;
45
+ try {
46
+ body = JSON.parse(String(stdout));
47
+ } catch {
48
+ throw new RegistryError("pool_registry_unavailable", "get_staging_pool returned non-JSON output.", { tenant });
49
+ }
50
+ if (body?.ok === false) {
51
+ const code = body.code === "tenant_required" ? "tenant_required" : "pool_registry_unavailable";
52
+ throw new RegistryError(code, `get_staging_pool refused: ${String(body.code ?? "error")}`, { tenant });
53
+ }
54
+ if (!Array.isArray(body?.pools)) throw new RegistryError("pool_registry_unavailable", "get_staging_pool returned no pool list.", { tenant });
55
+ return body.pools.filter((row) => row && typeof row.pool_id === "string" && row.binding && typeof row.binding === "object");
56
+ }
57
+
58
+ /** Pick one pool: the named one, or the only one; otherwise a self-guiding refusal. */
59
+ export function choosePool(pools, { tenant, poolId = null }) {
60
+ if (poolId) {
61
+ const match = pools.find((row) => row.pool_id === poolId);
62
+ if (!match) throw new RegistryError("tenant_pool_not_registered", `Tenant ${tenant} has no registered pool ${poolId}.`, { tenant, poolId, poolIds: pools.map((row) => row.pool_id) });
63
+ return match;
64
+ }
65
+ if (pools.length === 0) throw new RegistryError("tenant_pool_not_registered", `Tenant ${tenant} has no registered staging session pool.`, { tenant });
66
+ if (pools.length > 1) throw new RegistryError("pool_ambiguous", `Tenant ${tenant} has ${pools.length} pools.`, { tenant, poolIds: pools.map((row) => row.pool_id).sort() });
67
+ return pools[0];
68
+ }
69
+
70
+ /** Best effort: the tenant this botbuddy login resolves to (for a refusal's next step). */
71
+ export async function whoamiTenant({ exec = defaultExec, botbuddyBin = "botbuddy", env = process.env } = {}) {
72
+ try {
73
+ const { stdout } = await exec(botbuddyBin, ["call", "whoami", "--json", "{}"], { env: childEnv(env), timeout: 15_000, maxBuffer: 256 * 1024 });
74
+ const tenant = JSON.parse(String(stdout))?.tenant_id;
75
+ return SLUG_RE.test(String(tenant ?? "")) ? tenant : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }