@botbuddy/cli 1.60.7 → 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.
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/pw/args.mjs +6 -2
- package/src/pw/run.mjs +2 -1
- package/src/pw/staging-auth.mjs +20 -12
- package/src/staging-pool/binding.mjs +25 -40
- package/src/staging-pool/cli.mjs +197 -108
- package/src/staging-pool/config.mjs +4 -3
- package/src/staging-pool/errors.mjs +54 -33
- package/src/staging-pool/help.mjs +31 -22
- package/src/staging-pool/registry.mjs +79 -0
- package/src/staging-pool/checkouts.mjs +0 -120
|
@@ -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
|
|
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: "
|
|
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
|
-
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
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
|
-
"
|
|
168
|
-
"The one-time install has not been run
|
|
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
|
-
"
|
|
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
|
-
"
|
|
192
|
+
"bb staging-pool {command}"),
|
|
192
193
|
|
|
193
|
-
// ──
|
|
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
|
|
197
|
-
INSTALL
|
|
215
|
+
"The registered binding or installed config lacks a field.",
|
|
216
|
+
INSTALL),
|
|
198
217
|
pool_binding_invalid: entry("human", "configure",
|
|
199
|
-
"The
|
|
200
|
-
"
|
|
201
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
305
|
-
export const
|
|
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
|
-
|
|
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
|
|
326
|
-
//
|
|
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
|
-
|
|
375
|
+
poolSuffix: vars.poolSuffix ?? (vars.poolId ? ` (${vars.poolId})` : ""),
|
|
356
376
|
tickCommand: vars.tickCommand ?? "bb staging-pool tick",
|
|
357
|
-
|
|
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
|
|
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
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
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 = `
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
job
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
105
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
}
|