@botbuddy/cli 1.60.7 → 1.60.9

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,13 +37,12 @@ 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 { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
38
- import { findKnownCheckouts, isStableCheckout as defaultIsStableCheckout, recordCheckout } from "./checkouts.mjs";
40
+ import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode, remedyLine } from "./errors.mjs";
41
+ import { RegistryError, choosePool, fetchRegisteredPools, whoamiTenant } from "./registry.mjs";
39
42
  import { STAGING_POOL_HELP } from "./help.mjs";
40
43
 
41
44
  export const BUNDLE_FILES = Object.freeze([
42
45
  "binding.mjs",
43
- "checkouts.mjs",
44
46
  "cli.mjs",
45
47
  "config.mjs",
46
48
  "errors.mjs",
@@ -51,12 +53,13 @@ export const BUNDLE_FILES = Object.freeze([
51
53
  "password-source.mjs",
52
54
  "policy.mjs",
53
55
  "pool.mjs",
56
+ "registry.mjs",
54
57
  "signin-gotrue-totp.mjs",
55
58
  "state-file.mjs",
56
59
  "totp-botbuddy.mjs",
57
60
  ]);
58
61
 
59
- 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"]);
60
63
  const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check"]);
61
64
 
62
65
  export function parseArgs(argv) {
@@ -81,72 +84,83 @@ export function parseArgs(argv) {
81
84
  export function exitCodeFor(error) {
82
85
  if (error instanceof MintRefusedError || error?.code === "persona_not_allowed") return 2;
83
86
  if (error instanceof PoolEmptyError) return 3;
84
- 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;
85
88
  if (error?.code === "pool_unhealthy") return 5;
86
89
  return 1;
87
90
  }
88
91
 
89
- // Where this invocation's pool lives: the job passes --home; everyone else
90
- // resolves it from the repo binding (cwd).
91
- // The checkout a human-facing remedy may name: this repo root or the one install
92
- // recorded, if stable; else a stable checkout seen earlier for this pool; else none.
93
- async function stableCheckoutFor({ repoRoot, installed, poolId, home, readBinding, isStable }) {
94
- if (repoRoot && isStable(repoRoot)) return repoRoot;
95
- // The install-recorded checkout may since have moved or been deleted: name it only
96
- // while it still declares this pool's binding.
97
- const recorded = installed?.checkout;
98
- if (recorded && isStable(recorded)) {
99
- const found = await readBinding(recorded).catch(() => null);
100
- if (found && found.repoRoot === recorded && found.binding.poolId === poolId) return recorded;
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
+ }
101
115
  }
102
- const [candidate] = await findKnownCheckouts({ home, poolId, readBinding, isStable }).catch(() => []);
103
- return candidate?.path;
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 };
104
122
  }
105
123
 
106
- async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
124
+ async function resolveContext({ options, home }) {
107
125
  if (options.home) {
108
126
  const paths = poolHomePaths({ home, root: options.home });
109
- const config = readInstalledConfig(paths.config);
110
- const stableCheckout = await stableCheckoutFor({ installed: config, poolId: config.poolBinding.poolId, home, readBinding, isStable: isStableCheckout });
111
- return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding, stableCheckout };
127
+ return contextFor({ poolId: null, paths, installed: readInstalledConfig(paths.config) });
112
128
  }
113
- const found = await readBinding(cwd);
114
- if (!found) {
115
- throw new ConfigError(`${cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool.`, "pool_not_configured");
116
- }
117
- const paths = poolHomePaths({ home, poolId: found.binding.poolId });
118
- // Remembered so a later refusal run from the wrong directory can name this checkout.
119
- recordCheckout({ home, path: found.repoRoot, poolId: found.binding.poolId });
120
- let installed = null;
121
- try {
122
- installed = readInstalledConfig(paths.config);
123
- } catch (error) {
124
- 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
+ });
125
138
  }
126
- const stableCheckout = await stableCheckoutFor({ repoRoot: found.repoRoot, installed: installed?.error ? null : installed, poolId: found.binding.poolId, home, readBinding, isStable: isStableCheckout });
127
- return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot, stableCheckout };
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
+ });
128
142
  }
129
143
 
130
144
  const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
131
- // No checkout known → undefined, so a real refusal says where to run it instead of
132
- // printing the binding's `<your … checkout>` placeholder.
133
- export const installCommandFor = (repoRoot) =>
134
- repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : undefined;
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);
135
147
 
136
148
  // The non-secret values every guidance template may name.
137
149
  export function guidanceVars(ctx, extra = {}) {
138
150
  if (!ctx) return { ...extra };
151
+ const binding = ctx.poolBinding;
139
152
  return {
140
- label: ctx.poolBinding.launchdLabel,
153
+ label: binding?.launchdLabel,
141
154
  log: ctx.paths.log,
142
- installCommand: installCommandFor(ctx.stableCheckout),
143
- docs: ctx.poolBinding.docs,
155
+ installCommand: installCommandFor(ctx.tenant),
156
+ docs: binding?.docs,
144
157
  configPath: ctx.paths.config,
145
158
  poolHome: ctx.paths.root,
146
159
  tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
147
- checkout: ctx.stableCheckout,
148
- envFile: ctx.poolBinding.passwordEnvFile,
149
- 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 ?? {}),
150
164
  ...extra,
151
165
  };
152
166
  }
@@ -201,9 +215,28 @@ export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona
201
215
  };
202
216
  }
203
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
+
204
237
  /**
205
238
  * Agent path, used by `bb pw … auth`: claim one verified session for a persona
206
- * (alias or email) of this repo's binding. Returns { taken, target, binding,
239
+ * (alias or email) of the installed pool. Returns { taken, target, binding,
207
240
  * discard } — the caller installs taken.statePath into its claimed lane and then
208
241
  * MUST call discard() (single use). Throws PoolEmptyError carrying the health
209
242
  * reason/agentAction/remedy when nothing is ready.
@@ -211,17 +244,16 @@ export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona
211
244
  export async function takeForLane({
212
245
  persona,
213
246
  owner,
214
- cwd = process.cwd(),
247
+ poolId = null,
248
+ tenantHint = null,
215
249
  home = homedir(),
216
250
  now = () => Date.now(),
217
251
  fetchImpl = globalThis.fetch,
218
252
  exec = promisify(execFile),
219
253
  uid = userInfo().uid,
220
254
  logger = () => {},
221
- readBinding = readRepoPoolBinding,
222
- isStableCheckout = defaultIsStableCheckout,
223
255
  } = {}) {
224
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
256
+ const ctx = resolveInstalledPool({ home, poolId, tenantHint });
225
257
  const entry = findPersona(ctx.poolBinding, persona);
226
258
  const target = stagingTargetFromBinding(ctx.poolBinding);
227
259
  if (!entry) assertPersonaAllowed(target, persona);
@@ -246,12 +278,23 @@ export async function takeForLane({
246
278
  // Fields a refusal carries so a caller can act on it without reading the code.
247
279
  export const GUIDANCE_FIELDS = Object.freeze(["reason", "agentAction", "remedy", "message", "cause", "actor", "next", "doc", "help", "detail", "intervalSeconds"]);
248
280
 
249
- // The human-only command line to repeat from Terminal.app (no secrets: flags only).
281
+ // The command line to repeat (no secrets: flags only).
250
282
  function humanCommandLine(argv) {
251
- if (argv[0] === "install") return 'install --botbuddy-bin "$(command -v botbuddy)"';
252
283
  return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
253
284
  }
254
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
+
255
298
  // What a missing pool binding means for each subcommand (AC-8 follow-up: an
256
299
  // install refusal must not talk about taking a session).
257
300
  const NO_POOL_CONSEQUENCE = Object.freeze({
@@ -266,37 +309,46 @@ const NO_POOL_CONSEQUENCE = Object.freeze({
266
309
 
267
310
  /**
268
311
  * Build the refusal for a failed subcommand: the JSON record and the text block.
269
- * A human-only subcommand always names Human (Jono) and prints no Agent line. The
270
- * checkout comes from this directory, else from a checkout an install recorded or
271
- * one seen earlier; with none, the next step says where to run the command.
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>.
272
316
  */
273
- export async function refusalFor({ argv, command, error, options = {}, cwd = process.cwd(), home = homedir(), readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
317
+ export async function refusalFor({ argv, command, error, options = {}, home = homedir(), exec = promisify(execFile), env = process.env }) {
274
318
  const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
275
319
  const humanOnly = HUMAN_ONLY_COMMANDS.has(command);
276
320
  let ctx = null;
277
321
  try {
278
- ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
322
+ ctx = await resolveContext({ options, home });
279
323
  } catch {
280
324
  ctx = null;
281
325
  }
282
- if (!ctx) {
283
- const [candidate] = await findKnownCheckouts({ home, readBinding, isStable: isStableCheckout }).catch(() => []);
284
- if (candidate) {
285
- try {
286
- ctx = await resolveContext({ options: {}, cwd: candidate.path, home, readBinding, isStableCheckout });
287
- } catch {
288
- ctx = null;
289
- }
290
- }
291
- }
292
- const line = humanCommandLine(argv ?? [command]);
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;
293
338
  const vars = guidanceVars(ctx, {
294
- command: line,
295
- retry: `bb staging-pool ${line}`,
296
- cwd,
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,
297
348
  consequence: NO_POOL_CONSEQUENCE[command] ?? "there is no pool to act on",
298
349
  markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
299
350
  });
351
+ vars.poolSuffix = vars.poolId ? ` (${vars.poolId})` : "";
300
352
  vars.persona = options.persona ?? error?.persona ?? vars.allowed?.[0];
301
353
  const actor = humanOnly ? "human" : null;
302
354
  const g = guidance(code, vars, { real: true, actor });
@@ -314,6 +366,19 @@ export async function refusalFor({ argv, command, error, options = {}, cwd = pro
314
366
  return { record, text: formatGuidance(textG, { agentLine: !humanOnly }), exitCode: exitCodeFor(error) };
315
367
  }
316
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
+
317
382
  export async function runStagingPool(argv, deps = {}) {
318
383
  const {
319
384
  env = process.env,
@@ -330,9 +395,7 @@ export async function runStagingPool(argv, deps = {}) {
330
395
  scriptDir = dirname(fileURLToPath(import.meta.url)),
331
396
  nodeBin = process.execPath,
332
397
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
333
- readBinding = readRepoPoolBinding,
334
398
  readSecretFile = readFileSync,
335
- isStableCheckout = defaultIsStableCheckout,
336
399
  } = deps;
337
400
 
338
401
  const redactor = createRedactor();
@@ -391,47 +454,68 @@ export async function runStagingPool(argv, deps = {}) {
391
454
 
392
455
  const commands = {
393
456
  async install(options) {
394
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
395
- const target = stagingTargetFromBinding(ctx.poolBinding);
396
- 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;
397
461
  const botbuddyBin = requireBotbuddyBin(options);
398
- 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]) {
399
481
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
400
482
  await fs.chmod(dir, 0o700);
401
483
  }
402
- await copyBundle(ctx.paths.app);
403
- const rawBinding = JSON.parse(await fs.readFile(join(ctx.repoRoot, ".botbuddy-agent.json"), "utf8"));
484
+ await copyBundle(paths.app);
404
485
  let existing = null;
405
486
  try {
406
- existing = JSON.parse(await fs.readFile(ctx.paths.config, "utf8"));
487
+ existing = JSON.parse(await fs.readFile(paths.config, "utf8"));
407
488
  } catch (error) {
408
489
  if (error?.code !== "ENOENT") throw error;
409
490
  }
410
491
  const explicitInterval = options.interval === undefined ? undefined : Number(options.interval);
411
492
  const config = buildInstalledConfig({
412
493
  rawBinding,
413
- poolBinding: ctx.poolBinding,
494
+ poolBinding,
414
495
  botbuddyBin,
415
496
  intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
416
497
  existingPersonas: existing?.personas,
417
- checkout: ctx.repoRoot,
498
+ tenant,
499
+ registry: { commit_sha: row.commit_sha ?? null, updated_at: row.updated_at ?? null },
418
500
  installedAt: new Date(now()).toISOString(),
419
501
  });
420
- await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
421
- await fs.chmod(ctx.paths.config, 0o600);
422
- const plistPath = join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`);
423
- 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 });
424
506
  await fs.writeFile(plistPath, jobPlist(ctx, config.intervalSeconds), { mode: 0o644 });
425
507
  const launchctl = createLaunchctl({ exec, uid });
426
- await launchctl.bootout(ctx.poolBinding.launchdLabel);
508
+ await launchctl.bootout(poolBinding.launchdLabel);
427
509
  await launchctl.bootstrap(plistPath);
428
510
  emit({
429
511
  ok: true,
430
512
  command: "install",
431
- label: ctx.poolBinding.launchdLabel,
513
+ tenant,
514
+ poolId: poolBinding.poolId,
515
+ label: poolBinding.launchdLabel,
432
516
  plistPath,
433
- poolHome: ctx.paths.root,
434
- log: ctx.paths.log,
517
+ poolHome: paths.root,
518
+ log: paths.log,
435
519
  intervalSeconds: config.intervalSeconds,
436
520
  personas: config.personas,
437
521
  next: "bb staging-pool status --check # the pool fills within one tick",
@@ -440,7 +524,7 @@ export async function runStagingPool(argv, deps = {}) {
440
524
  },
441
525
 
442
526
  async uninstall(options) {
443
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
527
+ const ctx = usable(await resolveContext({ options, home }));
444
528
  const target = stagingTargetFromBinding(ctx.poolBinding);
445
529
  // The job and its sessions are the human's: an agent never stops or empties them.
446
530
  assertMintAllowed({ target, env, listAncestors });
@@ -454,7 +538,7 @@ export async function runStagingPool(argv, deps = {}) {
454
538
  },
455
539
 
456
540
  async tick(options) {
457
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
541
+ const ctx = usable(await resolveContext({ options, home }));
458
542
  const target = stagingTargetFromBinding(ctx.poolBinding);
459
543
  assertMintAllowed({ target, env, listAncestors });
460
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");
@@ -464,7 +548,7 @@ export async function runStagingPool(argv, deps = {}) {
464
548
  },
465
549
 
466
550
  async "mint-one"(options) {
467
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
551
+ const ctx = usable(await resolveContext({ options, home }));
468
552
  const target = stagingTargetFromBinding(ctx.poolBinding);
469
553
  assertMintAllowed({ target, env, listAncestors });
470
554
  const entry = findPersona(ctx.poolBinding, options.persona);
@@ -477,22 +561,28 @@ export async function runStagingPool(argv, deps = {}) {
477
561
  },
478
562
 
479
563
  async status(options) {
480
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
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
+ }
481
571
  const target = stagingTargetFromBinding(ctx.poolBinding);
482
572
  const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
483
573
  if (options.check) {
484
574
  const entry = options.persona ? findPersona(ctx.poolBinding, options.persona) : null;
485
575
  if (options.persona && !entry) assertPersonaAllowed(target, options.persona);
486
576
  const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry?.email });
487
- emit({ command: "status", check: true, ...health });
577
+ emit({ command: "status", check: true, ...health, ...upgradeNotice(ctx) });
488
578
  return health.ok ? 0 : 5;
489
579
  }
490
- emit({ ok: true, command: "status", ...(await pool.status()) });
580
+ emit({ ok: true, command: "status", ...(await pool.status()), ...upgradeNotice(ctx) });
491
581
  return 0;
492
582
  },
493
583
 
494
584
  async probe(options) {
495
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
585
+ const ctx = usable(await resolveContext({ options, home }));
496
586
  const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
497
587
  if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
498
588
  const receipt = await probeBotBuddy({
@@ -509,16 +599,15 @@ export async function runStagingPool(argv, deps = {}) {
509
599
  },
510
600
 
511
601
  async "print-plist"(options) {
512
- const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
602
+ const ctx = usable(await resolveContext({ options, home }));
513
603
  stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
514
604
  return 0;
515
605
  },
516
606
  };
517
607
 
518
- // One JSON line (machine-readable) then the same guidance as text, on stderr.
519
608
  // One JSON line (machine-readable) then the same guidance as text, on stderr.
520
609
  async function fail(command, error, options = {}) {
521
- const { record, text, exitCode } = await refusalFor({ argv, command, error, options, cwd, home, readBinding, isStableCheckout });
610
+ const { record, text, exitCode } = await refusalFor({ argv, command, error, options, home, exec, env });
522
611
  stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
523
612
  stderr(redactor.redact(text));
524
613
  return exitCode;
@@ -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, checkout, installedAt }) {
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,8 +98,9 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
98
98
  personas,
99
99
  botbuddyBin,
100
100
  intervalSeconds,
101
- // The checkout install ran from (non-secret), so a refusal elsewhere can name it.
102
- ...(typeof checkout === "string" && checkout.startsWith("/") ? { checkout } : {}),
101
+ // BOT-2648: installed from the tenant's control-plane registration.
102
+ ...(tenant ? { tenant, poolId: poolBinding.poolId, source: "control-plane" } : {}),
103
+ ...(registry ? { registry } : {}),
103
104
  ...(installedAt ? { installedAt } : {}),
104
105
  });
105
106
  }