@botbuddy/cli 1.60.6 → 1.60.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +11 -7
- package/src/pw/staging-auth.mjs +38 -8
- package/src/staging-pool/binding.mjs +25 -40
- package/src/staging-pool/cli.mjs +246 -91
- package/src/staging-pool/config.mjs +5 -1
- package/src/staging-pool/errors.mjs +122 -47
- package/src/staging-pool/health.mjs +2 -2
- package/src/staging-pool/help.mjs +31 -22
- package/src/staging-pool/registry.mjs +79 -0
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
// command and the doc section. `bb help symptom <code>` prints the same entry.
|
|
4
4
|
//
|
|
5
5
|
// Node-stdlib-only and self-contained (the installed job copies this directory).
|
|
6
|
-
// Templates take only non-secret values (lane, persona alias, paths, labels)
|
|
7
|
-
//
|
|
6
|
+
// Templates take only non-secret values (lane, persona alias, paths, labels).
|
|
7
|
+
// A real refusal (`{ real: true }`) never prints a <placeholder>: a missing value
|
|
8
|
+
// in prose becomes plain words, and a `next` command that needs a value it does
|
|
9
|
+
// not have falls back to the command that finds it (e.g. `botbuddy call whoami` for
|
|
10
|
+
// the tenant). Only
|
|
11
|
+
// `bb help symptom` (the reference view) shows <placeholders>.
|
|
8
12
|
|
|
9
13
|
export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
|
|
10
14
|
|
|
11
15
|
// Heading anchors in DOC_URL (checked by errors.test.mjs against the doc).
|
|
12
16
|
export const DOC_SECTIONS = Object.freeze({
|
|
13
17
|
overview: "staging-session-pool-bot-2084",
|
|
14
|
-
configure: "
|
|
18
|
+
configure: "register-a-tenants-pool",
|
|
15
19
|
install: "install-update-remove-human-only",
|
|
16
20
|
take: "take-a-session-agent",
|
|
17
21
|
close: "close-and-the-residual-window",
|
|
@@ -39,15 +43,15 @@ export const ERROR_CODES = Object.freeze({
|
|
|
39
43
|
auth_requires_lane_lock: entry("agent", "take",
|
|
40
44
|
"bb pw auth needs a lock-gated lane, and this run is unlocked.",
|
|
41
45
|
"BB_PW_NO_LOCK=1 is set in this shell.",
|
|
42
|
-
`bb locks -m --lane {lane}
|
|
46
|
+
`bb locks -m --lane {lane}{ticketFlag} && env -u BB_PW_NO_LOCK ${AUTH}`),
|
|
43
47
|
lane_not_claimed: entry("agent", "take",
|
|
44
48
|
"The lane lock is not held by this agent session.",
|
|
45
49
|
"The lane was never claimed, its lock expired, or another agent holds it.",
|
|
46
|
-
`bb locks -m --lane {lane}
|
|
50
|
+
`bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
|
|
47
51
|
exact_lane_holder_required: entry("agent", "take",
|
|
48
52
|
"The lane lock is held by another agent session of your owner, not by this session.",
|
|
49
53
|
"The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
|
|
50
|
-
`bb locks -m --lane {lane}
|
|
54
|
+
`bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
|
|
51
55
|
persona_not_allowlisted: entry("agent", "take",
|
|
52
56
|
"Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
|
|
53
57
|
"A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
|
|
@@ -64,10 +68,10 @@ export const ERROR_CODES = Object.freeze({
|
|
|
64
68
|
"Lane {lane} uses a persistent browser profile, which would write the session to disk.",
|
|
65
69
|
"The lane was opened with --persistent or --profile.",
|
|
66
70
|
`${REOPEN} && ${AUTH} # no --persistent / --profile`),
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
+
pool_ambiguous: entry("agent", "take",
|
|
72
|
+
"Several staging session pools match ({poolIds}), so the command does not guess.",
|
|
73
|
+
"More than one pool is installed (or registered for the tenant) and none was named.",
|
|
74
|
+
"{retryWithPool}"),
|
|
71
75
|
|
|
72
76
|
// ── bb pw … auth: install into the lane (agent retries once, then reports)
|
|
73
77
|
session_install_failed: entry("agent", "take",
|
|
@@ -139,15 +143,15 @@ export const ERROR_CODES = Object.freeze({
|
|
|
139
143
|
mint_backing_off: entry("human", "repair",
|
|
140
144
|
"Staging sign-in for {persona} keeps failing, so the job is backing off.",
|
|
141
145
|
"The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
|
|
142
|
-
"grep mint_failed {log} | tail -1 # then
|
|
146
|
+
"grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
|
|
143
147
|
last_tick_failed: entry("human", "repair",
|
|
144
|
-
"The pool job's last tick
|
|
148
|
+
"The pool job's last tick had sign-in (mint) failures.",
|
|
145
149
|
"The last mint_failed line in {log} names the cause.",
|
|
146
150
|
"grep mint_failed {log} | tail -1 # repair that code, then: {tickCommand}"),
|
|
147
151
|
ticks_failing: entry("human", "repair",
|
|
148
152
|
"Pool ticks run but none has succeeded for 3+ intervals.",
|
|
149
153
|
"A persistent mint failure; {log} names it.",
|
|
150
|
-
"grep mint_failed {log} | tail -1 # then
|
|
154
|
+
"grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
|
|
151
155
|
persona_below_target: entry("human", "repair",
|
|
152
156
|
"The pool has been below its configured size for {persona} for 3+ ticks.",
|
|
153
157
|
"Agents take sessions faster than the job refills them, or mints are partly failing ({log}).",
|
|
@@ -161,8 +165,8 @@ export const ERROR_CODES = Object.freeze({
|
|
|
161
165
|
"It was edited by hand, or an incompatible CLI wrote it.",
|
|
162
166
|
INSTALL + " # rewrites it from the binding; or fix the named field in {configPath}"),
|
|
163
167
|
pool_not_installed: entry("human", "install",
|
|
164
|
-
"
|
|
165
|
-
"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).",
|
|
166
170
|
INSTALL),
|
|
167
171
|
pool_target_mismatch: entry("human", "repair",
|
|
168
172
|
"The pool home belongs to a different staging target than the repository binding now names.",
|
|
@@ -180,26 +184,44 @@ export const ERROR_CODES = Object.freeze({
|
|
|
180
184
|
// ── who may run what
|
|
181
185
|
agent_context: entry("human", "install",
|
|
182
186
|
"This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
|
|
183
|
-
`It ran from a Claude or Codex terminal. ${HUMAN_ONLY}`,
|
|
184
|
-
"
|
|
187
|
+
`It ran from a Claude or Codex terminal; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
|
|
188
|
+
"bb staging-pool {command}"),
|
|
185
189
|
agent_context_unknown: entry("human", "install",
|
|
186
190
|
"bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
|
|
187
|
-
`/bin/ps is unavailable or restricted in this shell. ${HUMAN_ONLY}`,
|
|
188
|
-
"
|
|
191
|
+
`/bin/ps is unavailable or restricted in this shell; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
|
|
192
|
+
"bb staging-pool {command}"),
|
|
189
193
|
|
|
190
|
-
// ──
|
|
194
|
+
// ── the control-plane registry (BOT-2648)
|
|
195
|
+
tenant_required: entry("human", "install",
|
|
196
|
+
"bb staging-pool install needs the tenant whose registered pool to install.",
|
|
197
|
+
"--tenant was not given; pool bindings are registered per tenant in the BotBuddy control plane.",
|
|
198
|
+
INSTALL),
|
|
199
|
+
tenant_pool_not_registered: entry("human", "configure",
|
|
200
|
+
"Tenant {tenant} has no registered staging session pool{poolSuffix}.",
|
|
201
|
+
"The tenant's staging seed job has not registered one yet (register_staging_pool), or the pool id is wrong.",
|
|
202
|
+
"botbuddy call get_staging_pool --pin {tenant} # lists what is registered; the tenant's staging seed job registers it"),
|
|
203
|
+
pool_registry_unavailable: entry("human", "install",
|
|
204
|
+
"The BotBuddy control plane did not return the tenant's pool binding.",
|
|
205
|
+
"botbuddy is signed out on this Mac, the tenant pin is not one of your memberships, or BotBuddy is unreachable.",
|
|
206
|
+
"botbuddy login && " + INSTALL),
|
|
207
|
+
pool_registration_refused: entry("human", "configure",
|
|
208
|
+
"The registered pool binding does not pass this CLI's checks (see detail), so nothing was installed.",
|
|
209
|
+
"The registration is malformed, or it uses a newer schema than this @botbuddy/cli understands.",
|
|
210
|
+
"npm i -g @botbuddy/cli@latest && " + INSTALL + " # if it still refuses, fix the tenant's registration"),
|
|
211
|
+
|
|
212
|
+
// ── configuration (the registered binding and the installed config)
|
|
191
213
|
config_missing: entry("human", "configure",
|
|
192
214
|
"A required staging_session_pool value is missing (see detail).",
|
|
193
|
-
"The
|
|
194
|
-
INSTALL
|
|
215
|
+
"The registered binding or installed config lacks a field.",
|
|
216
|
+
INSTALL),
|
|
195
217
|
pool_binding_invalid: entry("human", "configure",
|
|
196
|
-
"The
|
|
197
|
-
"
|
|
198
|
-
"
|
|
218
|
+
"The registered staging_session_pool binding is invalid (see detail).",
|
|
219
|
+
"The tenant's registration broke a field; the message names it.",
|
|
220
|
+
"botbuddy call get_staging_pool --pin {tenant} # fix the named field in the tenant's registration, then: " + INSTALL),
|
|
199
221
|
origin_not_allowed: entry("human", "configure",
|
|
200
222
|
"The binding's app origin or Supabase URL is not an allowed staging target (see detail).",
|
|
201
|
-
"It is http, loopback, a path rather than a bare origin, or a production origin.",
|
|
202
|
-
"
|
|
223
|
+
"It is http, loopback, a path rather than a bare origin, or a production origin; this CLI refuses it whatever the control plane holds.",
|
|
224
|
+
"botbuddy call get_staging_pool --pin {tenant} # point app_origin / supabase_url at staging in the tenant's registration"),
|
|
203
225
|
persona_not_allowed: entry("agent", "take",
|
|
204
226
|
"Persona \"{persona}\" is not an allowlisted seeded persona of this pool.",
|
|
205
227
|
"A typo, or a persona the binding does not list. Allowed: {allowed}.",
|
|
@@ -277,7 +299,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
277
299
|
persona_mismatch: entry("human", "repair",
|
|
278
300
|
"MFA verify returned a session for a different user.",
|
|
279
301
|
"The binding's persona email does not match the seeded user.",
|
|
280
|
-
"
|
|
302
|
+
"botbuddy call get_staging_pool --pin {tenant} # check the registered personas, then: bb staging-pool mint-one --persona {persona}"),
|
|
281
303
|
|
|
282
304
|
unexpected_error: entry("agent", "codes",
|
|
283
305
|
"bb staging-pool failed unexpectedly (see detail).",
|
|
@@ -285,27 +307,63 @@ export const ERROR_CODES = Object.freeze({
|
|
|
285
307
|
"bb staging-pool status # once; report this code, the detail and the CLI version (bb version)", "report_blocker"),
|
|
286
308
|
});
|
|
287
309
|
|
|
310
|
+
// Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
|
|
311
|
+
export const AUTH_ONLY_CODES = Object.freeze(new Set([
|
|
312
|
+
"auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
|
|
313
|
+
"persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
|
|
314
|
+
"session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
|
|
315
|
+
"session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
|
|
316
|
+
"daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
|
|
317
|
+
]));
|
|
318
|
+
|
|
319
|
+
// `bb staging-pool` subcommands only a human runs. A refusal from one names
|
|
320
|
+
// Human (Jono) whatever the code, and prints no Agent guidance.
|
|
321
|
+
export const HUMAN_ONLY_COMMANDS = Object.freeze(new Set(["install", "uninstall", "tick", "mint-one"]));
|
|
322
|
+
|
|
323
|
+
// With no tenant known, a remedy names the command that reports it (BOT-2648).
|
|
324
|
+
export const FIND_TENANT_NEXT = 'botbuddy call whoami # its tenant_id is your --tenant; then: bb staging-pool install --tenant (that slug) --botbuddy-bin "$(command -v botbuddy)"';
|
|
325
|
+
export const installLine = (tenant, poolId) => `bb staging-pool install --tenant ${tenant}${poolId ? ` --pool ${poolId}` : ""} --botbuddy-bin "$(command -v botbuddy)"`;
|
|
326
|
+
|
|
288
327
|
// Vars that are already whole command lines; every other var is a single shell word.
|
|
289
|
-
const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry"]);
|
|
328
|
+
const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "retryWithPool", "ticketFlag"]);
|
|
329
|
+
// Vars where an empty string is a real value (no flag), not a missing one.
|
|
330
|
+
const OPTIONAL_VARS = new Set(["ticketFlag"]);
|
|
290
331
|
const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
|
|
291
332
|
// A <placeholder> stays as written: the reader replaces it.
|
|
292
333
|
const shellWord = (value) => (SHELL_SAFE.test(value) || /^<[^<>]+>$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
293
334
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
335
|
+
// What a missing value reads as in prose (real refusals only).
|
|
336
|
+
const DESCRIBED = Object.freeze({
|
|
337
|
+
persona: "the persona", lane: "the lane", log: "the pool log", configPath: "the installed pool config",
|
|
338
|
+
label: "the launchd job", envFile: "the persona password file", markers: "agent markers",
|
|
339
|
+
cwd: "This directory", consequence: "there is no pool to act on", command: "this command",
|
|
340
|
+
tenant: "your tenant", poolIds: "several pools", poolSuffix: "", allowed: "the binding's personas", firstAllowed: "a listed persona",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const isMissing = (key, value) => value === undefined || value === null || (value === "" && !OPTIONAL_VARS.has(key)) || (Array.isArray(value) && value.length === 0);
|
|
344
|
+
|
|
345
|
+
// In a `next` command a caller-supplied value (a typed persona, a path with
|
|
346
|
+
// spaces) is shell-quoted, so the line stays one safe copy-paste.
|
|
347
|
+
function fill(template, vars, { shell = false, missing = null, describe = false } = {}) {
|
|
297
348
|
return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
|
|
298
349
|
const value = vars?.[key];
|
|
299
|
-
if (
|
|
300
|
-
|
|
350
|
+
if (isMissing(key, value)) {
|
|
351
|
+
missing?.add(key);
|
|
352
|
+
return describe ? (DESCRIBED[key] ?? "it") : `<${key}>`;
|
|
353
|
+
}
|
|
354
|
+
if (Array.isArray(value)) return value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ");
|
|
301
355
|
return shell && !COMMAND_VARS.has(key) ? shellWord(String(value)) : String(value);
|
|
302
356
|
});
|
|
303
357
|
}
|
|
304
358
|
|
|
305
359
|
export const isKnownCode = (code) => Object.hasOwn(ERROR_CODES, String(code ?? ""));
|
|
306
360
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
361
|
+
/**
|
|
362
|
+
* The guidance for one code, interpolated with non-secret vars.
|
|
363
|
+
* real a refusal someone will act on: no <placeholder> is ever printed.
|
|
364
|
+
* actor "human" for a human-only subcommand, whatever the code's default.
|
|
365
|
+
*/
|
|
366
|
+
export function guidance(code, vars = {}, { real = false, actor = null } = {}) {
|
|
309
367
|
const known = isKnownCode(code);
|
|
310
368
|
const spec = known ? ERROR_CODES[code] : ERROR_CODES.unexpected_error;
|
|
311
369
|
const allowed = Array.isArray(vars.allowed) ? vars.allowed : vars.allowed ? [vars.allowed] : [];
|
|
@@ -313,20 +371,37 @@ export function guidance(code, vars = {}) {
|
|
|
313
371
|
...vars,
|
|
314
372
|
allowed,
|
|
315
373
|
firstAllowed: vars.firstAllowed ?? allowed[0],
|
|
316
|
-
|
|
374
|
+
ticketFlag: vars.ticketFlag ?? (real ? "" : " --ticket <ticket>"),
|
|
375
|
+
poolSuffix: vars.poolSuffix ?? (vars.poolId ? ` (${vars.poolId})` : ""),
|
|
317
376
|
tickCommand: vars.tickCommand ?? "bb staging-pool tick",
|
|
318
|
-
|
|
377
|
+
// Install reads the control plane, so it needs only the tenant: no checkout, no cwd.
|
|
378
|
+
installCommand: vars.installCommand ?? (vars.tenant ? installLine(shellWord(String(vars.tenant)), vars.installPool ?? null) : (real ? undefined : installLine("<tenant>"))),
|
|
319
379
|
};
|
|
320
|
-
// What a
|
|
380
|
+
// What a refusal re-runs: auth by default (the consumer), the subcommand otherwise.
|
|
321
381
|
filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
|
|
382
|
+
filled.retryWithPool = vars.retryWithPool ?? (real ? undefined : `${filled.retry} --pool <pool_id>`);
|
|
383
|
+
let next;
|
|
384
|
+
if (real) {
|
|
385
|
+
const missing = new Set();
|
|
386
|
+
next = fill(spec.next, filled, { shell: true, missing });
|
|
387
|
+
if (missing.size) {
|
|
388
|
+
// A value only the tenant or a resolved pool knows is missing: give the
|
|
389
|
+
// command that finds the tenant, or re-run the subcommand to get the
|
|
390
|
+
// specific refusal with real values. Never a <placeholder>.
|
|
391
|
+
next = missing.has("tenant") || missing.has("installCommand") ? FIND_TENANT_NEXT : filled.retry;
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
next = fill(spec.next, filled, { shell: true });
|
|
395
|
+
}
|
|
396
|
+
const finalActor = actor ?? spec.actor;
|
|
322
397
|
return {
|
|
323
398
|
code: known ? code : String(code ?? "unexpected_error"),
|
|
324
|
-
message: fill(spec.message, filled),
|
|
325
|
-
cause: fill(spec.cause, filled),
|
|
326
|
-
actor:
|
|
327
|
-
actor_label: ACTOR_LABELS[
|
|
328
|
-
next
|
|
329
|
-
agent_action: spec.agentAction,
|
|
399
|
+
message: fill(spec.message, filled, { describe: real }),
|
|
400
|
+
cause: fill(spec.cause, filled, { describe: real }),
|
|
401
|
+
actor: finalActor,
|
|
402
|
+
actor_label: ACTOR_LABELS[finalActor],
|
|
403
|
+
next,
|
|
404
|
+
agent_action: finalActor === "human" ? "report_blocker" : spec.agentAction,
|
|
330
405
|
doc: `${DOC_URL}#${DOC_SECTIONS[spec.doc]}`,
|
|
331
406
|
help: `bb help symptom ${known ? code : "unexpected_error"}`,
|
|
332
407
|
};
|
|
@@ -339,13 +414,13 @@ export function remedyLine(g) {
|
|
|
339
414
|
}
|
|
340
415
|
|
|
341
416
|
/** Plain-text block for stderr. */
|
|
342
|
-
export function formatGuidance(g, { prefix = "bb staging-pool" } = {}) {
|
|
417
|
+
export function formatGuidance(g, { prefix = "bb staging-pool", agentLine = true } = {}) {
|
|
343
418
|
const lines = [
|
|
344
419
|
`${prefix}: ${g.message} [${g.code}]`,
|
|
345
420
|
` Likely cause: ${g.cause}`,
|
|
346
421
|
` Next (${g.actor_label}${g.actor === "human" ? ", from Terminal.app or iTerm" : ""}): ${g.next}`,
|
|
347
422
|
];
|
|
348
|
-
if (g.actor === "human") lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
|
|
423
|
+
if (g.actor === "human" && agentLine) lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
|
|
349
424
|
lines.push(` Docs: ${g.doc}`, ` Explain: ${g.help}`);
|
|
350
425
|
return lines.join("\n");
|
|
351
426
|
}
|
|
@@ -15,7 +15,7 @@ export const HEALTH_REASON_CODES = Object.freeze([
|
|
|
15
15
|
export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
|
|
16
16
|
const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
|
|
17
17
|
return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
|
|
18
|
-
const g = guidance(code, vars);
|
|
18
|
+
const g = guidance(code, vars, { real: true });
|
|
19
19
|
return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
|
|
20
20
|
})));
|
|
21
21
|
}
|
|
@@ -35,7 +35,7 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
|
|
|
35
35
|
const record = { ok: false, reason, agentAction, ...extra };
|
|
36
36
|
if (vars) {
|
|
37
37
|
// Re-rendered with the persona this assessment is about.
|
|
38
|
-
const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona });
|
|
38
|
+
const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona }, { real: true });
|
|
39
39
|
remedy = remedyLine(g);
|
|
40
40
|
Object.assign(record, { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help });
|
|
41
41
|
}
|
|
@@ -4,18 +4,23 @@ import { DOC_SECTIONS, DOC_URL } from "./errors.mjs";
|
|
|
4
4
|
|
|
5
5
|
const doc = (section) => `${DOC_URL}#${DOC_SECTIONS[section]}`;
|
|
6
6
|
|
|
7
|
-
export const BINDING_KEYS_HELP = `
|
|
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
|
+
}
|