@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.
@@ -6,7 +6,8 @@
6
6
  // Templates take only non-secret values (lane, persona alias, paths, labels).
7
7
  // A real refusal (`{ real: true }`) never prints a <placeholder>: a missing value
8
8
  // in prose becomes plain words, and a `next` command that needs a value it does
9
- // not have falls back to "run this from inside a repository checkout …". Only
9
+ // not have falls back to the command that finds it (e.g. `botbuddy call whoami` for
10
+ // the tenant). Only
10
11
  // `bb help symptom` (the reference view) shows <placeholders>.
11
12
 
12
13
  export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
@@ -14,7 +15,7 @@ export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/d
14
15
  // Heading anchors in DOC_URL (checked by errors.test.mjs against the doc).
15
16
  export const DOC_SECTIONS = Object.freeze({
16
17
  overview: "staging-session-pool-bot-2084",
17
- configure: "configure-a-repository",
18
+ configure: "register-a-tenants-pool",
18
19
  install: "install-update-remove-human-only",
19
20
  take: "take-a-session-agent",
20
21
  close: "close-and-the-residual-window",
@@ -67,10 +68,10 @@ export const ERROR_CODES = Object.freeze({
67
68
  "Lane {lane} uses a persistent browser profile, which would write the session to disk.",
68
69
  "The lane was opened with --persistent or --profile.",
69
70
  `${REOPEN} && ${AUTH} # no --persistent / --profile`),
70
- pool_not_configured: entry("agent", "configure",
71
- "{cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool, so {consequence}.",
72
- "The command ran outside the repository checkout (for example from your home directory), or that repository has not opted in.",
73
- "cd {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}"),
74
75
 
75
76
  // ── bb pw … auth: install into the lane (agent retries once, then reports)
76
77
  session_install_failed: entry("agent", "take",
@@ -164,8 +165,8 @@ export const ERROR_CODES = Object.freeze({
164
165
  "It was edited by hand, or an incompatible CLI wrote it.",
165
166
  INSTALL + " # rewrites it from the binding; or fix the named field in {configPath}"),
166
167
  pool_not_installed: entry("human", "install",
167
- "The pool job is not installed, so it cannot tick or mint.",
168
- "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).",
169
170
  INSTALL),
170
171
  pool_target_mismatch: entry("human", "repair",
171
172
  "The pool home belongs to a different staging target than the repository binding now names.",
@@ -184,25 +185,43 @@ export const ERROR_CODES = Object.freeze({
184
185
  agent_context: entry("human", "install",
185
186
  "This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
186
187
  `It ran from a Claude or Codex terminal; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
187
- "cd {checkout} && bb staging-pool {command}"),
188
+ "bb staging-pool {command}"),
188
189
  agent_context_unknown: entry("human", "install",
189
190
  "bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
190
191
  `/bin/ps is unavailable or restricted in this shell; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
191
- "cd {checkout} && bb staging-pool {command}"),
192
+ "bb staging-pool {command}"),
192
193
 
193
- // ── 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)
194
213
  config_missing: entry("human", "configure",
195
214
  "A required staging_session_pool value is missing (see detail).",
196
- "The repository binding or installed config lacks a field.",
197
- INSTALL + " # after fixing .botbuddy-agent.json staging_session_pool"),
215
+ "The registered binding or installed config lacks a field.",
216
+ INSTALL),
198
217
  pool_binding_invalid: entry("human", "configure",
199
- "The repository's .botbuddy-agent.json staging_session_pool is invalid (see detail).",
200
- "A committed edit broke a field; the message names it.",
201
- "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),
202
221
  origin_not_allowed: entry("human", "configure",
203
222
  "The binding's app origin or Supabase URL is not an allowed staging target (see detail).",
204
- "It is http, loopback, a path rather than a bare origin, or a production origin.",
205
- "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"),
206
225
  persona_not_allowed: entry("agent", "take",
207
226
  "Persona \"{persona}\" is not an allowlisted seeded persona of this pool.",
208
227
  "A typo, or a persona the binding does not list. Allowed: {allowed}.",
@@ -280,7 +299,7 @@ export const ERROR_CODES = Object.freeze({
280
299
  persona_mismatch: entry("human", "repair",
281
300
  "MFA verify returned a session for a different user.",
282
301
  "The binding's persona email does not match the seeded user.",
283
- "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}"),
284
303
 
285
304
  unexpected_error: entry("agent", "codes",
286
305
  "bb staging-pool failed unexpectedly (see detail).",
@@ -301,11 +320,12 @@ export const AUTH_ONLY_CODES = Object.freeze(new Set([
301
320
  // Human (Jono) whatever the code, and prints no Agent guidance.
302
321
  export const HUMAN_ONLY_COMMANDS = Object.freeze(new Set(["install", "uninstall", "tick", "mint-one"]));
303
322
 
304
- export const CHECKOUT_SENTENCE = "From inside a repository checkout whose .botbuddy-agent.json declares staging_session_pool (for BotBuddy: your botbuddy-webapp clone on main), run";
305
- export const BARE_INSTALL = 'bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"';
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)"`;
306
326
 
307
327
  // Vars that are already whole command lines; every other var is a single shell word.
308
- const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "ticketFlag"]);
328
+ const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "retryWithPool", "ticketFlag"]);
309
329
  // Vars where an empty string is a real value (no flag), not a missing one.
310
330
  const OPTIONAL_VARS = new Set(["ticketFlag"]);
311
331
  const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
@@ -317,13 +337,13 @@ const DESCRIBED = Object.freeze({
317
337
  persona: "the persona", lane: "the lane", log: "the pool log", configPath: "the installed pool config",
318
338
  label: "the launchd job", envFile: "the persona password file", markers: "agent markers",
319
339
  cwd: "This directory", consequence: "there is no pool to act on", command: "this command",
320
- checkout: "the repository checkout", allowed: "the binding's personas", firstAllowed: "a listed persona",
340
+ tenant: "your tenant", poolIds: "several pools", poolSuffix: "", allowed: "the binding's personas", firstAllowed: "a listed persona",
321
341
  });
322
342
 
323
343
  const isMissing = (key, value) => value === undefined || value === null || (value === "" && !OPTIONAL_VARS.has(key)) || (Array.isArray(value) && value.length === 0);
324
344
 
325
- // In a `next` command a caller-supplied value (a typed persona, a checkout path
326
- // with spaces) is shell-quoted, so the line stays one safe copy-paste.
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.
327
347
  function fill(template, vars, { shell = false, missing = null, describe = false } = {}) {
328
348
  return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
329
349
  const value = vars?.[key];
@@ -352,22 +372,23 @@ export function guidance(code, vars = {}, { real = false, actor = null } = {}) {
352
372
  allowed,
353
373
  firstAllowed: vars.firstAllowed ?? allowed[0],
354
374
  ticketFlag: vars.ticketFlag ?? (real ? "" : " --ticket <ticket>"),
355
- // Without a resolved pool these still read as runnable commands from the checkout.
375
+ poolSuffix: vars.poolSuffix ?? (vars.poolId ? ` (${vars.poolId})` : ""),
356
376
  tickCommand: vars.tickCommand ?? "bb staging-pool tick",
357
- installCommand: vars.installCommand ?? (vars.checkout ? `cd ${shellWord(String(vars.checkout))} && ${BARE_INSTALL}` : (real ? undefined : `cd <checkout> && ${BARE_INSTALL}`)),
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>"))),
358
379
  };
359
- // 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.
360
381
  filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
382
+ filled.retryWithPool = vars.retryWithPool ?? (real ? undefined : `${filled.retry} --pool <pool_id>`);
361
383
  let next;
362
384
  if (real) {
363
385
  const missing = new Set();
364
386
  next = fill(spec.next, filled, { shell: true, missing });
365
387
  if (missing.size) {
366
- // No concrete checkout (or a value only a resolved pool knows): say where to
367
- // run it, and give the command that works from there.
368
- const bareMissing = new Set();
369
- const bare = fill(spec.next.replace("cd {checkout} && ", ""), { ...filled, installCommand: BARE_INSTALL }, { shell: true, missing: bareMissing });
370
- next = `${CHECKOUT_SENTENCE}: ${bareMissing.size ? filled.retry : bare}`;
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;
371
392
  }
372
393
  } else {
373
394
  next = fill(spec.next, filled, { shell: true });
@@ -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
+ }
@@ -10,6 +10,7 @@
10
10
  // success). It carries row COUNTS only — never paths, never prompt text, never
11
11
  // token totals. Fail-open: any failure exits 0 unless --strict.
12
12
  import { execFileSync } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
13
14
  import { existsSync, readFileSync } from "node:fs";
14
15
  import { readdir, stat } from "node:fs/promises";
15
16
  import { homedir, hostname as systemHostname } from "node:os";
@@ -90,16 +91,20 @@ export function transcriptRoots(env = process.env, home = homedir()) {
90
91
  * subdirectories) modified since `cutoffMs`. `shouldStop` is checked before
91
92
  * every directory read and stat, so a huge history or a slow filesystem cannot
92
93
  * outlive the run's time budget (the pre-push lane runs with 5 s).
93
- * @returns {Promise<{ files: string[], stopped: boolean }>}
94
+ * `errors` counts directories or files that exist but could not be read
95
+ * (EACCES, EPERM, …); a missing root or a file that vanished is not an error.
96
+ * @returns {Promise<{ files: string[], stopped: boolean, errors: number }>}
94
97
  */
95
98
  export async function listTranscripts(root, cutoffMs, { shouldStop = () => false, keep = () => false } = {}) {
96
99
  const out = [];
97
100
  let stopped = false;
101
+ let errors = 0;
102
+ const benign = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
98
103
  const halt = () => (stopped = stopped || shouldStop());
99
104
  async function walk(dir) {
100
105
  if (halt()) return;
101
106
  let entries;
102
- try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
107
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!benign(error)) errors += 1; return; }
103
108
  entries.sort((a, b) => a.name.localeCompare(b.name));
104
109
  for (const entry of entries) {
105
110
  if (halt()) return;
@@ -109,12 +114,12 @@ export async function listTranscripts(root, cutoffMs, { shouldStop = () => false
109
114
  try {
110
115
  const info = await stat(path);
111
116
  if (info.mtimeMs >= cutoffMs || keep(path, info)) out.push(path);
112
- } catch { /* vanished between readdir and stat */ }
117
+ } catch (error) { if (!benign(error)) errors += 1; /* ENOENT: vanished between readdir and stat */ }
113
118
  }
114
119
  }
115
120
  }
116
121
  await walk(root);
117
- return { files: out, stopped };
122
+ return { files: out, stopped, errors };
118
123
  }
119
124
 
120
125
  /** Which adapter owns a single `--file`: under a Codex root, or a rollout whose first record is session_meta. */
@@ -373,19 +378,24 @@ export async function syncUsage(opts, deps = {}) {
373
378
  roots = transcriptRoots(env, home), repoFor = createRepoResolver(), branchFor = createBranchResolver({ env }), cliVersion = "0.0.0",
374
379
  tenantFor = createTenantResolver({ env }), resolveTarget = defaultResolveTarget, readLines = readCompleteLines,
375
380
  serverUrl = env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL, statePath = cursorPath(home),
381
+ newSyncId = randomUUID,
376
382
  } = deps;
377
383
  const startedMs = now();
378
384
  const deadlineMs = startedMs + opts.maxSeconds * 1000;
379
385
  const parseDeadlineMs = startedMs + opts.maxSeconds * 1000 * PARSE_BUDGET_FRACTION;
380
386
  const startedAt = new Date(startedMs).toISOString();
387
+ // BOT-2649: one id for this whole invocation, sent with every batch (both
388
+ // providers), so v_usage_sync_hosts judges the attempt as a whole: one
389
+ // refused batch fails the sync even when a later batch succeeded.
390
+ const syncId = newSyncId();
381
391
  const receipt = {
382
392
  schema_version: 1, command: "usage sync", outcome: "ok", trigger: opts.trigger, dry_run: opts.dryRun,
383
393
  providers: [], files_scanned: 0, files_read: 0, envelopes: 0, batches: 0,
384
394
  tenant: null, skipped_other_tenant: 0, skipped_unbound: 0,
385
395
  seen: 0, inserted: 0, superseded: 0, deduped: 0, refused: 0, refusal_reasons: {}, batches_all_refused: 0,
386
- refused_local: 0, parse_errors: 0,
396
+ refused_local: 0, parse_errors: 0, read_errors: 0,
387
397
  held_open_messages: 0, cost_state_withheld: 0,
388
- privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, duration_ms: 0,
398
+ privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, sync_id: syncId, duration_ms: 0,
389
399
  };
390
400
  const finish = (outcome, error) => {
391
401
  receipt.outcome = outcome;
@@ -441,6 +451,8 @@ export async function syncUsage(opts, deps = {}) {
441
451
  },
442
452
  });
443
453
  if (listed.stopped) receipt.budget_exhausted = true;
454
+ // BOT-2649: an unreadable transcript directory is a read failure, not an idle host.
455
+ receipt.read_errors += listed.errors ?? 0;
444
456
  plan.push({ provider, files: listed.stopped ? [] : listed.files });
445
457
  }
446
458
  }
@@ -466,7 +478,7 @@ export async function syncUsage(opts, deps = {}) {
466
478
  : createClaudeAdapter({ hmac, environment, repoFor, tenantFor });
467
479
  const run = {
468
480
  host: hostname(), trigger: opts.trigger, provider, cli_version: cliVersion,
469
- parser_version: adapter.parserVersion, started_at: startedAt,
481
+ parser_version: adapter.parserVersion, started_at: startedAt, sync_id: syncId,
470
482
  };
471
483
 
472
484
  // Envelopes are queued with a global sequence; a file's cursor commits only
@@ -535,7 +547,9 @@ export async function syncUsage(opts, deps = {}) {
535
547
  for (const path of files) {
536
548
  if (now() >= parseDeadlineMs) { receipt.budget_exhausted = true; break; }
537
549
  let info;
538
- try { info = await stat(path); } catch { continue; }
550
+ // A file that vanished since listing is benign; any other stat failure
551
+ // (e.g. EACCES) means this run could not read pending usage (BOT-2649).
552
+ try { info = await stat(path); } catch (error) { if (error?.code !== "ENOENT") receipt.read_errors += 1; continue; }
539
553
  const sourceHmac = hmac(resolve(path));
540
554
  // Cursors are per (tenant, file): a run skips other tenants' records, so a
541
555
  // shared per-file cursor would consume them for every later tenant's run.
@@ -560,6 +574,7 @@ export async function syncUsage(opts, deps = {}) {
560
574
  parser.parseRecord(record, offset);
561
575
  }, { shouldStop: () => now() >= parseDeadlineMs });
562
576
  } catch {
577
+ receipt.read_errors += 1; // counted: a run that could not read is not a clean idle run
563
578
  continue; // unreadable file: skip, cursor unchanged
564
579
  }
565
580
  if (result.stopped) receipt.budget_exhausted = true;
@@ -606,8 +621,43 @@ export async function syncUsage(opts, deps = {}) {
606
621
  }
607
622
  commitDelivered();
608
623
  }
624
+ // BOT-2649: a run that uploaded nothing still records one empty ok run, so an
625
+ // idle but healthy host is not aged into "stale" by v_usage_sync_hosts. Only a
626
+ // CLEAN idle run does: one that refused records locally, hit unparseable lines,
627
+ // could not read a transcript or ran out of budget did not prove usage lands, so it records nothing and the
628
+ // host ages as before rather than reading healthy (independent review on #1324).
629
+ if (!opts.dryRun && receipt.batches === 0) {
630
+ const withheld = receipt.budget_exhausted ? "budget_exhausted"
631
+ : receipt.refused_local > 0 ? "refused_local"
632
+ : receipt.parse_errors > 0 ? "parse_errors"
633
+ : receipt.read_errors > 0 ? "read_errors" : null;
634
+ if (withheld) {
635
+ receipt.empty_run_withheld = withheld;
636
+ } else {
637
+ const error = await postEmptyRun();
638
+ if (error === OUT_OF_TIME) return finalize(null);
639
+ if (error) return finalize(error);
640
+ }
641
+ }
609
642
  return finalize(null);
610
643
 
644
+ async function postEmptyRun() {
645
+ // Same deadline rules as a batch: never START a request past the budget.
646
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
647
+ const resolved = await ensureAuth();
648
+ if (resolved.error) return resolved.error;
649
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
650
+ const run = { host: hostname(), trigger: opts.trigger, cli_version: cliVersion, started_at: startedAt, sync_id: syncId };
651
+ const result = await postBatch({
652
+ endpoint: usageIngestEndpoint(serverUrl), tenant: resolved.tenant,
653
+ headers: resolved.headers, run, envelopes: [], fetchImpl, timeoutMs: deadlineMs - now(),
654
+ });
655
+ if (!result.ok) return result.error;
656
+ receipt.empty_run = true;
657
+ if (result.run_id) receipt.run_ids.push(result.run_id);
658
+ return null;
659
+ }
660
+
611
661
  async function finalize(error) {
612
662
  const outcome = error ? "error" : opts.dryRun ? "dry_run" : receipt.budget_exhausted ? "partial" : "ok";
613
663
  finish(outcome, error);
@@ -1,120 +0,0 @@
1
- // BOT-2084 (AC-8 follow-up): which repository checkouts on this Mac declare a
2
- // staging_session_pool, so a refusal run from the wrong directory (e.g. `~`) can
3
- // name a concrete `cd <path>` instead of a placeholder.
4
- //
5
- // Sources, both non-secret paths only:
6
- // * every installed pool's config.json `checkout` (written by install);
7
- // * ~/.botbuddy/staging-session-pool/known-checkouts.json (0600), updated each
8
- // time a bb staging-pool / bb pw auth command resolves a binding from a cwd.
9
- // A candidate counts only if it still exists and still declares a pool binding.
10
- // Node-stdlib-only and self-contained (bundled with the installed job).
11
- import { chmodSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
12
- import { homedir, tmpdir } from "node:os";
13
- import { join, sep } from "node:path";
14
-
15
- import { readRepoPoolBinding } from "./binding.mjs";
16
-
17
- const MAX_KNOWN = 20;
18
-
19
- const linkedWorktree = (path) => {
20
- try {
21
- return statSync(join(path, ".git")).isFile();
22
- } catch {
23
- return false;
24
- }
25
- };
26
- const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
27
- // Path segments that mark agent, scratch or disposable checkouts.
28
- const DISPOSABLE_SEGMENTS = new Set(["worktrees", "scratch-workspaces"]);
29
-
30
- /**
31
- * A checkout a human can be told to `cd` into: not under a temp dir, not an agent
32
- * or scratch worktree (a `worktrees` / `scratch-workspaces` path segment), and not
33
- * a linked git worktree (`.git` is a file). Remedies addressed to a human never
34
- * embed any other path (AC-8 follow-up, found on staging acceptance).
35
- */
36
- export function isStableCheckout(path, { tmp = tmpdir(), gitFileIsWorktree = linkedWorktree } = {}) {
37
- if (typeof path !== "string" || !path.startsWith("/")) return false;
38
- const roots = [...TEMP_ROOTS, tmp];
39
- try {
40
- roots.push(realpathSync(tmp));
41
- } catch {
42
- // tmp may not resolve in a test sandbox
43
- }
44
- for (const root of roots) {
45
- const base = String(root).replace(/\/+$/, "");
46
- if (base && (path === base || path.startsWith(`${base}/`))) return false;
47
- }
48
- if (path.split(sep).some((segment) => DISPOSABLE_SEGMENTS.has(segment))) return false;
49
- return !gitFileIsWorktree(path);
50
- }
51
- const poolsRoot = (home) => join(home, ".botbuddy", "staging-session-pool");
52
- export const knownCheckoutsPath = (home = homedir()) => join(poolsRoot(home), "known-checkouts.json");
53
-
54
- function readKnown(home) {
55
- try {
56
- const data = JSON.parse(readFileSync(knownCheckoutsPath(home), "utf8"));
57
- return Array.isArray(data?.checkouts) ? data.checkouts.filter((entry) => typeof entry?.path === "string" && entry.path.startsWith("/")) : [];
58
- } catch {
59
- return [];
60
- }
61
- }
62
-
63
- /** Best effort: remember a checkout that resolved a binding. Never throws. */
64
- export function recordCheckout({ home = homedir(), path, poolId, now = Date.now() } = {}) {
65
- try {
66
- if (typeof path !== "string" || !path.startsWith("/")) return;
67
- const entries = [{ path, pool_id: poolId ?? null, seen_at: new Date(now).toISOString() }, ...readKnown(home).filter((entry) => entry.path !== path)].slice(0, MAX_KNOWN);
68
- mkdirSync(poolsRoot(home), { recursive: true, mode: 0o700 });
69
- const file = knownCheckoutsPath(home);
70
- const tmp = `${file}.${process.pid}.tmp`;
71
- writeFileSync(tmp, `${JSON.stringify({ version: 1, checkouts: entries }, null, 2)}\n`, { mode: 0o600 });
72
- chmodSync(tmp, 0o600);
73
- renameSync(tmp, file);
74
- } catch {
75
- // A refusal's hint must never fail the command that produced it.
76
- }
77
- }
78
-
79
- function installedCheckouts(home) {
80
- const found = [];
81
- let names = [];
82
- try {
83
- names = readdirSync(poolsRoot(home));
84
- } catch {
85
- return found;
86
- }
87
- for (const name of names) {
88
- try {
89
- const config = JSON.parse(readFileSync(join(poolsRoot(home), name, "config.json"), "utf8"));
90
- if (typeof config?.checkout === "string" && config.checkout.startsWith("/")) found.push({ path: config.checkout, pool_id: name, seen_at: config.installedAt ?? null });
91
- } catch {
92
- // not installed, or unreadable: not a candidate
93
- }
94
- }
95
- return found;
96
- }
97
-
98
- /**
99
- * Checkouts that still declare a staging_session_pool, most relevant first:
100
- * installed pools' checkouts, then the most recently seen. `poolId` narrows to
101
- * one pool when it is known.
102
- */
103
- export async function findKnownCheckouts({ home = homedir(), poolId = null, readBinding = readRepoPoolBinding, isStable = isStableCheckout } = {}) {
104
- const seen = new Set();
105
- const result = [];
106
- for (const entry of [...installedCheckouts(home), ...readKnown(home)]) {
107
- if (seen.has(entry.path)) continue;
108
- seen.add(entry.path);
109
- if (!isStable(entry.path)) continue;
110
- try {
111
- const found = await readBinding(entry.path);
112
- if (!found || found.repoRoot !== entry.path) continue;
113
- if (poolId && found.binding.poolId !== poolId) continue;
114
- result.push({ path: entry.path, poolId: found.binding.poolId });
115
- } catch {
116
- // gone, or no longer a valid binding
117
- }
118
- }
119
- return result;
120
- }