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