@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.
@@ -11,20 +11,23 @@
11
11
  //
12
12
  // Agents take sessions only through `bb pw --environment staging <lane> auth`
13
13
  // (takeForLane below): a session is handed over by installing it into a claimed
14
- // lane, never as a reusable file path. The configuration is the repo's committed
15
- // `.botbuddy-agent.json` `staging_session_pool` binding; the installed job keeps a
16
- // validated snapshot. Node-stdlib-only and self-contained (install copies this
17
- // directory to <pool home>/app and the job runs that copy).
14
+ // lane, never as a reusable file path. The configuration is the tenant's pool
15
+ // binding registered in the BotBuddy control plane (BOT-2648): `install --tenant`
16
+ // reads it through the botbuddy CLI from any directory and keeps a validated
17
+ // snapshot in the pool home, which every other subcommand (and `bb pw … auth`)
18
+ // reads. Nothing reads a binding from the working directory. Node-stdlib-only and
19
+ // self-contained (install copies this directory to <pool home>/app and the job
20
+ // runs that copy).
18
21
 
19
22
  import { execFile } from "node:child_process";
20
23
  import * as nodeFs from "node:fs/promises";
21
- import { readFileSync, realpathSync } from "node:fs";
24
+ import { readFileSync, readdirSync, realpathSync } from "node:fs";
22
25
  import { homedir, userInfo } from "node:os";
23
26
  import { dirname, join } from "node:path";
24
27
  import { fileURLToPath } from "node:url";
25
28
  import { promisify } from "node:util";
26
29
 
27
- import { BindingError, findPersona, readRepoPoolBinding } from "./binding.mjs";
30
+ import { BindingError, findPersona, parsePoolBinding } from "./binding.mjs";
28
31
  import { ConfigError, DEFAULT_INTERVAL_SECONDS, buildInstalledConfig, poolHomePaths, readInstalledConfig, serialiseInstalledConfig } from "./config.mjs";
29
32
  import { assessPoolHealth, healthReasons } from "./health.mjs";
30
33
  import { buildLaunchAgentPlist, createLaunchctl } from "./launchd.mjs";
@@ -34,7 +37,8 @@ import { PoolEmptyError, createSessionPool } from "./pool.mjs";
34
37
  import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
35
38
  import { createRedactor } from "./state-file.mjs";
36
39
  import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
37
- import { formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
40
+ import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode, remedyLine } from "./errors.mjs";
41
+ import { RegistryError, choosePool, fetchRegisteredPools, whoamiTenant } from "./registry.mjs";
38
42
  import { STAGING_POOL_HELP } from "./help.mjs";
39
43
 
40
44
  export const BUNDLE_FILES = Object.freeze([
@@ -49,12 +53,13 @@ export const BUNDLE_FILES = Object.freeze([
49
53
  "password-source.mjs",
50
54
  "policy.mjs",
51
55
  "pool.mjs",
56
+ "registry.mjs",
52
57
  "signin-gotrue-totp.mjs",
53
58
  "state-file.mjs",
54
59
  "totp-botbuddy.mjs",
55
60
  ]);
56
61
 
57
- const VALUE_FLAGS = new Set(["persona", "home", "botbuddy-bin", "interval", "owner"]);
62
+ const VALUE_FLAGS = new Set(["persona", "home", "botbuddy-bin", "interval", "owner", "tenant", "pool"]);
58
63
  const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check"]);
59
64
 
60
65
  export function parseArgs(argv) {
@@ -79,51 +84,83 @@ export function parseArgs(argv) {
79
84
  export function exitCodeFor(error) {
80
85
  if (error instanceof MintRefusedError || error?.code === "persona_not_allowed") return 2;
81
86
  if (error instanceof PoolEmptyError) return 3;
82
- if (error instanceof ConfigError || error instanceof BindingError || error?.code === "config_missing") return 4;
87
+ if (error instanceof ConfigError || error instanceof BindingError || error instanceof RegistryError || error?.code === "config_missing") return 4;
83
88
  if (error?.code === "pool_unhealthy") return 5;
84
89
  return 1;
85
90
  }
86
91
 
87
- // Where this invocation's pool lives: the job passes --home; everyone else
88
- // resolves it from the repo binding (cwd).
89
- async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding }) {
92
+ // Where this invocation's pool lives: the job passes --home; everyone else names
93
+ // an installed pool with --pool (or --tenant), or uses the only one installed.
94
+ // Never the working directory (BOT-2648).
95
+ export function poolsRoot(home) {
96
+ return join(home, ".botbuddy", "staging-session-pool");
97
+ }
98
+
99
+ /** Installed pool homes: [{ poolId, paths, installed | error }]. */
100
+ export function listInstalledPools(home) {
101
+ let names = [];
102
+ try {
103
+ names = readdirSync(poolsRoot(home), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
104
+ } catch {
105
+ return [];
106
+ }
107
+ const pools = [];
108
+ for (const poolId of names.sort()) {
109
+ const paths = poolHomePaths({ home, poolId });
110
+ try {
111
+ pools.push({ poolId, paths, installed: readInstalledConfig(paths.config) });
112
+ } catch (error) {
113
+ if (error?.code !== "config_missing") pools.push({ poolId, paths, installed: { error } });
114
+ }
115
+ }
116
+ return pools;
117
+ }
118
+
119
+ function contextFor({ poolId, paths, installed }) {
120
+ if (installed?.error) return { paths, poolId, poolBinding: null, installed };
121
+ return { paths, poolId, poolBinding: installed.poolBinding, installed, rawBinding: installed.binding, tenant: installed.tenant ?? installed.binding?.tenant ?? null };
122
+ }
123
+
124
+ async function resolveContext({ options, home }) {
90
125
  if (options.home) {
91
126
  const paths = poolHomePaths({ home, root: options.home });
92
- const config = readInstalledConfig(paths.config);
93
- return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding };
94
- }
95
- const found = await readBinding(cwd);
96
- if (!found) {
97
- throw new ConfigError("This repository declares no staging_session_pool in .botbuddy-agent.json, so it has no staging session pool.", "pool_not_configured");
127
+ return contextFor({ poolId: null, paths, installed: readInstalledConfig(paths.config) });
98
128
  }
99
- const paths = poolHomePaths({ home, poolId: found.binding.poolId });
100
- let installed = null;
101
- try {
102
- installed = readInstalledConfig(paths.config);
103
- } catch (error) {
104
- if (error?.code !== "config_missing") installed = { error };
129
+ const installed = listInstalledPools(home);
130
+ let candidates = installed;
131
+ if (options.pool) candidates = installed.filter((pool) => pool.poolId === options.pool);
132
+ else if (options.tenant) candidates = installed.filter((pool) => !pool.installed.error && (pool.installed.tenant ?? pool.installed.binding?.tenant) === options.tenant);
133
+ if (candidates.length === 1) return contextFor(candidates[0]);
134
+ if (candidates.length === 0) {
135
+ throw Object.assign(new ConfigError(`No staging session pool${options.pool ? ` ${options.pool}` : ""} is installed on this Mac.`, "pool_not_installed"), {
136
+ tenant: options.tenant ?? null, poolId: options.pool ?? null, poolIds: installed.map((pool) => pool.poolId),
137
+ });
105
138
  }
106
- return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot };
139
+ throw Object.assign(new ConfigError(`Several staging session pools are installed: ${candidates.map((pool) => pool.poolId).join(", ")}.`, "pool_ambiguous"), {
140
+ poolIds: candidates.map((pool) => pool.poolId),
141
+ });
107
142
  }
108
143
 
109
144
  const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
110
- export const installCommandFor = (repoRoot, fallback) =>
111
- repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : fallback;
145
+ // The one upgrade/re-install command for an installed pool (its tenant is known).
146
+ export const installCommandFor = (tenant, poolId = null) => (tenant ? installLine(shellWord(String(tenant)), poolId) : undefined);
112
147
 
113
148
  // The non-secret values every guidance template may name.
114
149
  export function guidanceVars(ctx, extra = {}) {
115
150
  if (!ctx) return { ...extra };
151
+ const binding = ctx.poolBinding;
116
152
  return {
117
- label: ctx.poolBinding.launchdLabel,
153
+ label: binding?.launchdLabel,
118
154
  log: ctx.paths.log,
119
- installCommand: installCommandFor(ctx.repoRoot, ctx.poolBinding.installCommand),
120
- docs: ctx.poolBinding.docs,
155
+ installCommand: installCommandFor(ctx.tenant),
156
+ docs: binding?.docs,
121
157
  configPath: ctx.paths.config,
122
158
  poolHome: ctx.paths.root,
123
159
  tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
124
- checkout: ctx.repoRoot,
125
- envFile: ctx.poolBinding.passwordEnvFile,
126
- allowed: Object.keys(ctx.poolBinding.personas ?? {}),
160
+ tenant: ctx.tenant ?? undefined,
161
+ poolId: ctx.poolId ?? binding?.poolId,
162
+ envFile: binding?.passwordEnvFile,
163
+ allowed: Object.keys(binding?.personas ?? {}),
127
164
  ...extra,
128
165
  };
129
166
  }
@@ -178,9 +215,28 @@ export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona
178
215
  };
179
216
  }
180
217
 
218
+ /**
219
+ * The installed pool `bb pw … auth` takes from: --pool, else the only installed
220
+ * pool, else the one for `tenantHint` (the lane credential's tenant). Throws
221
+ * ConfigError pool_not_installed / pool_ambiguous (with poolIds) otherwise.
222
+ */
223
+ export function resolveInstalledPool({ home = homedir(), poolId = null, tenantHint = null } = {}) {
224
+ const installed = listInstalledPools(home).filter((pool) => !pool.installed.error);
225
+ if (poolId) {
226
+ const match = installed.find((pool) => pool.poolId === poolId);
227
+ if (!match) throw Object.assign(new ConfigError(`Staging session pool ${poolId} is not installed on this Mac.`, "pool_not_installed"), { poolId, poolIds: installed.map((pool) => pool.poolId) });
228
+ return contextFor(match);
229
+ }
230
+ if (installed.length === 1) return contextFor(installed[0]);
231
+ if (installed.length === 0) throw Object.assign(new ConfigError("No staging session pool is installed on this Mac.", "pool_not_installed"), { tenant: tenantHint, poolIds: [] });
232
+ const forTenant = tenantHint ? installed.filter((pool) => (pool.installed.tenant ?? pool.installed.binding?.tenant) === tenantHint) : [];
233
+ if (forTenant.length === 1) return contextFor(forTenant[0]);
234
+ throw Object.assign(new ConfigError(`Several staging session pools are installed: ${installed.map((pool) => pool.poolId).join(", ")}.`, "pool_ambiguous"), { poolIds: installed.map((pool) => pool.poolId) });
235
+ }
236
+
181
237
  /**
182
238
  * Agent path, used by `bb pw … auth`: claim one verified session for a persona
183
- * (alias or email) of this repo's binding. Returns { taken, target, binding,
239
+ * (alias or email) of the installed pool. Returns { taken, target, binding,
184
240
  * discard } — the caller installs taken.statePath into its claimed lane and then
185
241
  * MUST call discard() (single use). Throws PoolEmptyError carrying the health
186
242
  * reason/agentAction/remedy when nothing is ready.
@@ -188,16 +244,16 @@ export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona
188
244
  export async function takeForLane({
189
245
  persona,
190
246
  owner,
191
- cwd = process.cwd(),
247
+ poolId = null,
248
+ tenantHint = null,
192
249
  home = homedir(),
193
250
  now = () => Date.now(),
194
251
  fetchImpl = globalThis.fetch,
195
252
  exec = promisify(execFile),
196
253
  uid = userInfo().uid,
197
254
  logger = () => {},
198
- readBinding = readRepoPoolBinding,
199
255
  } = {}) {
200
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
256
+ const ctx = resolveInstalledPool({ home, poolId, tenantHint });
201
257
  const entry = findPersona(ctx.poolBinding, persona);
202
258
  const target = stagingTargetFromBinding(ctx.poolBinding);
203
259
  if (!entry) assertPersonaAllowed(target, persona);
@@ -222,12 +278,107 @@ export async function takeForLane({
222
278
  // Fields a refusal carries so a caller can act on it without reading the code.
223
279
  export const GUIDANCE_FIELDS = Object.freeze(["reason", "agentAction", "remedy", "message", "cause", "actor", "next", "doc", "help", "detail", "intervalSeconds"]);
224
280
 
225
- // The human-only command line to repeat from Terminal.app (no secrets: flags only).
281
+ // The command line to repeat (no secrets: flags only).
226
282
  function humanCommandLine(argv) {
227
- if (argv[0] === "install") return 'install --botbuddy-bin "$(command -v botbuddy)"';
228
283
  return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
229
284
  }
230
285
 
286
+ // argv with one flag set or replaced (for a refusal's "run it with --pool X").
287
+ function withFlag(argv, flag, value) {
288
+ const out = [];
289
+ for (let index = 0; index < argv.length; index += 1) {
290
+ const token = argv[index];
291
+ if (token === `--${flag}`) { index += 1; continue; }
292
+ if (token.startsWith(`--${flag}=`)) continue;
293
+ out.push(token);
294
+ }
295
+ return [...out, `--${flag}`, value];
296
+ }
297
+
298
+ // What a missing pool binding means for each subcommand (AC-8 follow-up: an
299
+ // install refusal must not talk about taking a session).
300
+ const NO_POOL_CONSEQUENCE = Object.freeze({
301
+ install: "there is no pool definition to install",
302
+ uninstall: "there is no pool to remove",
303
+ tick: "there is no pool to refill",
304
+ "mint-one": "there is no pool to sign a persona in for",
305
+ status: "there is no pool to report on",
306
+ probe: "there are no pool personas to probe",
307
+ "print-plist": "there is no pool job to describe",
308
+ });
309
+
310
+ /**
311
+ * Build the refusal for a failed subcommand: the JSON record and the text block.
312
+ * A human-only subcommand always names Human (Jono) and prints no Agent line. No
313
+ * remedy names a directory: install reads the control plane by tenant (BOT-2648),
314
+ * so a refusal needs only the tenant (from --tenant, the installed pool, or the
315
+ * botbuddy login's whoami) and never prints a <placeholder>.
316
+ */
317
+ export async function refusalFor({ argv, command, error, options = {}, home = homedir(), exec = promisify(execFile), env = process.env }) {
318
+ const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
319
+ const humanOnly = HUMAN_ONLY_COMMANDS.has(command);
320
+ let ctx = null;
321
+ try {
322
+ ctx = await resolveContext({ options, home });
323
+ } catch {
324
+ ctx = null;
325
+ }
326
+ const args = argv ?? [command];
327
+ let tenant = options.tenant ?? error?.tenant ?? ctx?.tenant ?? null;
328
+ if (!tenant && command === "install") tenant = await whoamiTenant({ exec, botbuddyBin: options["botbuddy-bin"] ?? "botbuddy", env });
329
+ const poolIds = Array.isArray(error?.poolIds) ? error.poolIds : [];
330
+ // install's re-run is always the complete line: --tenant and --botbuddy-bin included.
331
+ const installPool = command === "install" ? (options.pool ?? null) : null;
332
+ const retry = command === "install"
333
+ ? (tenant ? installLine(tenant, installPool) : undefined)
334
+ : `bb staging-pool ${humanCommandLine(args)}`;
335
+ const retryWithPool = poolIds.length
336
+ ? (command === "install" ? (tenant ? installLine(tenant, poolIds[0]) : undefined) : `bb staging-pool ${humanCommandLine(withFlag(args, "pool", poolIds[0]))}`)
337
+ : undefined;
338
+ const vars = guidanceVars(ctx, {
339
+ // install's line is always the complete one (tenant + --botbuddy-bin), never the typed args.
340
+ command: command === "install" && retry ? retry.replace(/^bb staging-pool /, "") : humanCommandLine(args),
341
+ retry,
342
+ retryWithPool,
343
+ tenant: tenant ?? undefined,
344
+ installPool,
345
+ installCommand: tenant ? installLine(tenant, installPool) : undefined,
346
+ poolIds,
347
+ poolId: error?.poolId ?? options.pool ?? ctx?.poolId,
348
+ consequence: NO_POOL_CONSEQUENCE[command] ?? "there is no pool to act on",
349
+ markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
350
+ });
351
+ vars.poolSuffix = vars.poolId ? ` (${vars.poolId})` : "";
352
+ vars.persona = options.persona ?? error?.persona ?? vars.allowed?.[0];
353
+ const actor = humanOnly ? "human" : null;
354
+ const g = guidance(code, vars, { real: true, actor });
355
+ const record = {
356
+ ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
357
+ agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
358
+ };
359
+ // A pool refusal (pool_empty / unhealthy) already carries its health guidance;
360
+ // an ordinary error's own message stays in `detail`.
361
+ if (typeof error?.reason === "string") for (const field of GUIDANCE_FIELDS) if (error[field] !== undefined) record[field] = error[field];
362
+ record.remedy ??= remedyLine(g);
363
+ if (humanOnly) Object.assign(record, { actor: g.actor_label, agentAction: "report_blocker" });
364
+ // An empty pool is explained by its health reason (e.g. job_not_loaded).
365
+ const textG = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars, { real: true, actor }) : g;
366
+ return { record, text: formatGuidance(textG, { agentLine: !humanOnly }), exitCode: exitCodeFor(error) };
367
+ }
368
+
369
+ // A pool home whose config.json is unreadable refuses with that config error.
370
+ function usable(ctx) {
371
+ if (ctx.installed?.error) throw ctx.installed.error;
372
+ return ctx;
373
+ }
374
+
375
+ // BOT-2648 migration: an install from before the control-plane registry keeps
376
+ // working; status names the one command that re-installs it from the registry.
377
+ export function upgradeNotice(ctx) {
378
+ if (!ctx?.installed || ctx.installed.error || ctx.installed.source === "control-plane" || !ctx.tenant) return {};
379
+ return { upgrade: { reason: "installed_before_registry", message: "This pool was installed from a repository checkout; re-install it from the BotBuddy control plane (keeps counts and interval).", next: installCommandFor(ctx.tenant) } };
380
+ }
381
+
231
382
  export async function runStagingPool(argv, deps = {}) {
232
383
  const {
233
384
  env = process.env,
@@ -244,7 +395,6 @@ export async function runStagingPool(argv, deps = {}) {
244
395
  scriptDir = dirname(fileURLToPath(import.meta.url)),
245
396
  nodeBin = process.execPath,
246
397
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
247
- readBinding = readRepoPoolBinding,
248
398
  readSecretFile = readFileSync,
249
399
  } = deps;
250
400
 
@@ -304,45 +454,68 @@ export async function runStagingPool(argv, deps = {}) {
304
454
 
305
455
  const commands = {
306
456
  async install(options) {
307
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
308
- const target = stagingTargetFromBinding(ctx.poolBinding);
309
- assertMintAllowed({ target, env, listAncestors });
457
+ // Human-only: refuse an agent session before any network or file call.
458
+ assertMintAllowed({ target: { name: "staging", appOrigin: "the tenant's staging app", loopback: false }, env, listAncestors });
459
+ if (!options.tenant) throw Object.assign(new ConfigError("install needs --tenant <slug>: pool bindings are registered per tenant.", "tenant_required"), {});
460
+ const tenant = options.tenant;
310
461
  const botbuddyBin = requireBotbuddyBin(options);
311
- for (const dir of [ctx.paths.root, ctx.paths.logDir]) {
462
+ // The one source: the tenant's registration in the BotBuddy control plane.
463
+ const pools = await fetchRegisteredPools({ exec, botbuddyBin, env, tenant, poolId: options.pool ?? null });
464
+ const row = choosePool(pools, { tenant, poolId: options.pool ?? null });
465
+ const rawBinding = { schema_version: 1, tenant, staging_session_pool: row.binding };
466
+ let poolBinding;
467
+ try {
468
+ poolBinding = parsePoolBinding(rawBinding);
469
+ } catch (error) {
470
+ throw Object.assign(new ConfigError(String(error?.message ?? error), "pool_registration_refused"), { tenant });
471
+ }
472
+ // The origin allowlist and production refusal hold whatever the control plane returned.
473
+ try {
474
+ stagingTargetFromBinding(poolBinding);
475
+ } catch (error) {
476
+ throw Object.assign(error, { tenant });
477
+ }
478
+ const paths = poolHomePaths({ home, poolId: poolBinding.poolId });
479
+ const ctx = { paths, poolBinding, tenant, poolId: poolBinding.poolId };
480
+ for (const dir of [paths.root, paths.logDir]) {
312
481
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
313
482
  await fs.chmod(dir, 0o700);
314
483
  }
315
- await copyBundle(ctx.paths.app);
316
- const rawBinding = JSON.parse(await fs.readFile(join(ctx.repoRoot, ".botbuddy-agent.json"), "utf8"));
484
+ await copyBundle(paths.app);
317
485
  let existing = null;
318
486
  try {
319
- existing = JSON.parse(await fs.readFile(ctx.paths.config, "utf8"));
487
+ existing = JSON.parse(await fs.readFile(paths.config, "utf8"));
320
488
  } catch (error) {
321
489
  if (error?.code !== "ENOENT") throw error;
322
490
  }
323
491
  const explicitInterval = options.interval === undefined ? undefined : Number(options.interval);
324
492
  const config = buildInstalledConfig({
325
493
  rawBinding,
326
- poolBinding: ctx.poolBinding,
494
+ poolBinding,
327
495
  botbuddyBin,
328
496
  intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
329
497
  existingPersonas: existing?.personas,
498
+ tenant,
499
+ registry: { commit_sha: row.commit_sha ?? null, updated_at: row.updated_at ?? null },
500
+ installedAt: new Date(now()).toISOString(),
330
501
  });
331
- await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
332
- await fs.chmod(ctx.paths.config, 0o600);
333
- const plistPath = join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`);
334
- await fs.mkdir(ctx.paths.plistDir, { recursive: true });
502
+ await fs.writeFile(paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
503
+ await fs.chmod(paths.config, 0o600);
504
+ const plistPath = join(paths.plistDir, `${poolBinding.launchdLabel}.plist`);
505
+ await fs.mkdir(paths.plistDir, { recursive: true });
335
506
  await fs.writeFile(plistPath, jobPlist(ctx, config.intervalSeconds), { mode: 0o644 });
336
507
  const launchctl = createLaunchctl({ exec, uid });
337
- await launchctl.bootout(ctx.poolBinding.launchdLabel);
508
+ await launchctl.bootout(poolBinding.launchdLabel);
338
509
  await launchctl.bootstrap(plistPath);
339
510
  emit({
340
511
  ok: true,
341
512
  command: "install",
342
- label: ctx.poolBinding.launchdLabel,
513
+ tenant,
514
+ poolId: poolBinding.poolId,
515
+ label: poolBinding.launchdLabel,
343
516
  plistPath,
344
- poolHome: ctx.paths.root,
345
- log: ctx.paths.log,
517
+ poolHome: paths.root,
518
+ log: paths.log,
346
519
  intervalSeconds: config.intervalSeconds,
347
520
  personas: config.personas,
348
521
  next: "bb staging-pool status --check # the pool fills within one tick",
@@ -351,7 +524,7 @@ export async function runStagingPool(argv, deps = {}) {
351
524
  },
352
525
 
353
526
  async uninstall(options) {
354
- const ctx = await resolveContext({ options, cwd, home, readBinding });
527
+ const ctx = usable(await resolveContext({ options, home }));
355
528
  const target = stagingTargetFromBinding(ctx.poolBinding);
356
529
  // The job and its sessions are the human's: an agent never stops or empties them.
357
530
  assertMintAllowed({ target, env, listAncestors });
@@ -365,7 +538,7 @@ export async function runStagingPool(argv, deps = {}) {
365
538
  },
366
539
 
367
540
  async tick(options) {
368
- const ctx = await resolveContext({ options, cwd, home, readBinding });
541
+ const ctx = usable(await resolveContext({ options, home }));
369
542
  const target = stagingTargetFromBinding(ctx.poolBinding);
370
543
  assertMintAllowed({ target, env, listAncestors });
371
544
  if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
@@ -375,7 +548,7 @@ export async function runStagingPool(argv, deps = {}) {
375
548
  },
376
549
 
377
550
  async "mint-one"(options) {
378
- const ctx = await resolveContext({ options, cwd, home, readBinding });
551
+ const ctx = usable(await resolveContext({ options, home }));
379
552
  const target = stagingTargetFromBinding(ctx.poolBinding);
380
553
  assertMintAllowed({ target, env, listAncestors });
381
554
  const entry = findPersona(ctx.poolBinding, options.persona);
@@ -388,22 +561,28 @@ export async function runStagingPool(argv, deps = {}) {
388
561
  },
389
562
 
390
563
  async status(options) {
391
- const ctx = await resolveContext({ options, cwd, home, readBinding });
564
+ const ctx = await resolveContext({ options, home });
565
+ if (ctx.installed?.error) {
566
+ // An unreadable installed config is one health reason, not a crash.
567
+ const health = await assessHealth({ ctx, pool: null, now, exec, uid });
568
+ emit({ command: "status", check: Boolean(options.check), ...health });
569
+ return 5;
570
+ }
392
571
  const target = stagingTargetFromBinding(ctx.poolBinding);
393
572
  const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
394
573
  if (options.check) {
395
574
  const entry = options.persona ? findPersona(ctx.poolBinding, options.persona) : null;
396
575
  if (options.persona && !entry) assertPersonaAllowed(target, options.persona);
397
576
  const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry?.email });
398
- emit({ command: "status", check: true, ...health });
577
+ emit({ command: "status", check: true, ...health, ...upgradeNotice(ctx) });
399
578
  return health.ok ? 0 : 5;
400
579
  }
401
- emit({ ok: true, command: "status", ...(await pool.status()) });
580
+ emit({ ok: true, command: "status", ...(await pool.status()), ...upgradeNotice(ctx) });
402
581
  return 0;
403
582
  },
404
583
 
405
584
  async probe(options) {
406
- const ctx = await resolveContext({ options, cwd, home, readBinding });
585
+ const ctx = usable(await resolveContext({ options, home }));
407
586
  const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
408
587
  if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
409
588
  const receipt = await probeBotBuddy({
@@ -420,7 +599,7 @@ export async function runStagingPool(argv, deps = {}) {
420
599
  },
421
600
 
422
601
  async "print-plist"(options) {
423
- const ctx = await resolveContext({ options, cwd, home, readBinding });
602
+ const ctx = usable(await resolveContext({ options, home }));
424
603
  stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
425
604
  return 0;
426
605
  },
@@ -428,34 +607,10 @@ export async function runStagingPool(argv, deps = {}) {
428
607
 
429
608
  // One JSON line (machine-readable) then the same guidance as text, on stderr.
430
609
  async function fail(command, error, options = {}) {
431
- const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
432
- let ctx = null;
433
- try {
434
- ctx = await resolveContext({ options, cwd, home, readBinding });
435
- } catch {
436
- ctx = null;
437
- }
438
- const persona = options.persona ?? error?.persona;
439
- const vars = guidanceVars(ctx, {
440
- persona,
441
- command: humanCommandLine(argv),
442
- markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
443
- checkout: ctx?.repoRoot ?? cwd,
444
- retry: `bb staging-pool ${humanCommandLine(argv)}`,
445
- });
446
- const g = guidance(code, vars);
447
- const record = {
448
- ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
449
- agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
450
- };
451
- // A pool refusal already carries its health guidance (pool_empty / unhealthy).
452
- for (const field of GUIDANCE_FIELDS) if (error?.[field] !== undefined) record[field] = error[field];
453
- record.remedy ??= remedyLine(g);
610
+ const { record, text, exitCode } = await refusalFor({ argv, command, error, options, home, exec, env });
454
611
  stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
455
- // An empty pool is explained by its health reason (e.g. job_not_loaded).
456
- const text = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars) : g;
457
- stderr(redactor.redact(formatGuidance(text)));
458
- return exitCodeFor(error);
612
+ stderr(redactor.redact(text));
613
+ return exitCode;
459
614
  }
460
615
 
461
616
  let parsed;
@@ -87,7 +87,7 @@ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
87
87
  }
88
88
 
89
89
  // rawBinding: { tenant, staging_session_pool } exactly as committed.
90
- export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas }) {
90
+ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, tenant, registry, installedAt }) {
91
91
  const personas = {};
92
92
  for (const persona of Object.values(poolBinding.personas)) {
93
93
  personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
@@ -98,6 +98,10 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
98
98
  personas,
99
99
  botbuddyBin,
100
100
  intervalSeconds,
101
+ // BOT-2648: installed from the tenant's control-plane registration.
102
+ ...(tenant ? { tenant, poolId: poolBinding.poolId, source: "control-plane" } : {}),
103
+ ...(registry ? { registry } : {}),
104
+ ...(installedAt ? { installedAt } : {}),
101
105
  });
102
106
  }
103
107