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