@botbuddy/cli 1.60.5 → 1.60.7
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 +4 -2
- package/src/commands.mjs +28 -0
- package/src/pw/args.mjs +17 -0
- package/src/pw/coordinator.mjs +5 -1
- package/src/pw/run.mjs +73 -9
- package/src/pw/staging-auth.mjs +393 -0
- package/src/staging-pool/binding.mjs +122 -0
- package/src/staging-pool/checkouts.mjs +120 -0
- package/src/staging-pool/cli.mjs +548 -0
- package/src/staging-pool/config.mjs +110 -0
- package/src/staging-pool/errors.mjs +434 -0
- package/src/staging-pool/health.mjs +84 -0
- package/src/staging-pool/help.mjs +125 -0
- package/src/staging-pool/job.mjs +7 -0
- package/src/staging-pool/launchd.mjs +92 -0
- package/src/staging-pool/password-source.mjs +65 -0
- package/src/staging-pool/policy.mjs +186 -0
- package/src/staging-pool/pool.mjs +666 -0
- package/src/staging-pool/signin-gotrue-totp.mjs +198 -0
- package/src/staging-pool/state-file.mjs +166 -0
- package/src/staging-pool/totp-botbuddy.mjs +106 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// BOT-2084 (lifted from supplyspark-ent ENT-5120 cli.mjs): `bb staging-pool`, the
|
|
2
|
+
// human-owned staging session pool broker.
|
|
3
|
+
//
|
|
4
|
+
// install install + start the launchd job (human only, from Terminal.app)
|
|
5
|
+
// uninstall stop the job and delete its session files (human only)
|
|
6
|
+
// tick refresh unclaimed slots, refill to K, purge old takes (job / human only)
|
|
7
|
+
// mint-one one real sign-in into the pool (human only; spine check)
|
|
8
|
+
// status metadata only, or --check for one health reason (anyone)
|
|
9
|
+
// probe list_totp_seeds through the botbuddy CLI; never mints a code (anyone)
|
|
10
|
+
// print-plist show the plist install would write (anyone)
|
|
11
|
+
//
|
|
12
|
+
// Agents take sessions only through `bb pw --environment staging <lane> auth`
|
|
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 repo's committed
|
|
15
|
+
// `.botbuddy-agent.json` `staging_session_pool` binding; the installed job keeps a
|
|
16
|
+
// validated snapshot. Node-stdlib-only and self-contained (install copies this
|
|
17
|
+
// directory to <pool home>/app and the job runs that copy).
|
|
18
|
+
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import * as nodeFs from "node:fs/promises";
|
|
21
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
22
|
+
import { homedir, userInfo } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
|
|
27
|
+
import { BindingError, findPersona, readRepoPoolBinding } from "./binding.mjs";
|
|
28
|
+
import { ConfigError, DEFAULT_INTERVAL_SECONDS, buildInstalledConfig, poolHomePaths, readInstalledConfig, serialiseInstalledConfig } from "./config.mjs";
|
|
29
|
+
import { assessPoolHealth, healthReasons } from "./health.mjs";
|
|
30
|
+
import { buildLaunchAgentPlist, createLaunchctl } from "./launchd.mjs";
|
|
31
|
+
import { createEnvFilePasswordSource } from "./password-source.mjs";
|
|
32
|
+
import { MintRefusedError, assertMintAllowed, assertPersonaAllowed, listProcessAncestors, stagingTargetFromBinding } from "./policy.mjs";
|
|
33
|
+
import { PoolEmptyError, createSessionPool } from "./pool.mjs";
|
|
34
|
+
import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
|
|
35
|
+
import { createRedactor } from "./state-file.mjs";
|
|
36
|
+
import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
|
|
37
|
+
import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
|
|
38
|
+
import { findKnownCheckouts, isStableCheckout as defaultIsStableCheckout, recordCheckout } from "./checkouts.mjs";
|
|
39
|
+
import { STAGING_POOL_HELP } from "./help.mjs";
|
|
40
|
+
|
|
41
|
+
export const BUNDLE_FILES = Object.freeze([
|
|
42
|
+
"binding.mjs",
|
|
43
|
+
"checkouts.mjs",
|
|
44
|
+
"cli.mjs",
|
|
45
|
+
"config.mjs",
|
|
46
|
+
"errors.mjs",
|
|
47
|
+
"health.mjs",
|
|
48
|
+
"help.mjs",
|
|
49
|
+
"job.mjs",
|
|
50
|
+
"launchd.mjs",
|
|
51
|
+
"password-source.mjs",
|
|
52
|
+
"policy.mjs",
|
|
53
|
+
"pool.mjs",
|
|
54
|
+
"signin-gotrue-totp.mjs",
|
|
55
|
+
"state-file.mjs",
|
|
56
|
+
"totp-botbuddy.mjs",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const VALUE_FLAGS = new Set(["persona", "home", "botbuddy-bin", "interval", "owner"]);
|
|
60
|
+
const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check"]);
|
|
61
|
+
|
|
62
|
+
export function parseArgs(argv) {
|
|
63
|
+
const [command, ...rest] = argv;
|
|
64
|
+
const options = {};
|
|
65
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
66
|
+
const token = rest[index];
|
|
67
|
+
if (!token.startsWith("--")) throw new ConfigError(`Unexpected argument ${token}.`, "usage");
|
|
68
|
+
const [flag, inline] = token.slice(2).split(/=(.*)/s, 2);
|
|
69
|
+
if (BOOLEAN_FLAGS.has(flag)) {
|
|
70
|
+
options[flag] = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!VALUE_FLAGS.has(flag)) throw new ConfigError(`Unknown flag --${flag}.`, "usage");
|
|
74
|
+
const value = inline ?? rest[(index += 1)];
|
|
75
|
+
if (value === undefined || value === "") throw new ConfigError(`--${flag} needs a value.`, "usage");
|
|
76
|
+
options[flag] = value;
|
|
77
|
+
}
|
|
78
|
+
return { command, options };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function exitCodeFor(error) {
|
|
82
|
+
if (error instanceof MintRefusedError || error?.code === "persona_not_allowed") return 2;
|
|
83
|
+
if (error instanceof PoolEmptyError) return 3;
|
|
84
|
+
if (error instanceof ConfigError || error instanceof BindingError || error?.code === "config_missing") return 4;
|
|
85
|
+
if (error?.code === "pool_unhealthy") return 5;
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Where this invocation's pool lives: the job passes --home; everyone else
|
|
90
|
+
// resolves it from the repo binding (cwd).
|
|
91
|
+
// The checkout a human-facing remedy may name: this repo root or the one install
|
|
92
|
+
// recorded, if stable; else a stable checkout seen earlier for this pool; else none.
|
|
93
|
+
async function stableCheckoutFor({ repoRoot, installed, poolId, home, readBinding, isStable }) {
|
|
94
|
+
if (repoRoot && isStable(repoRoot)) return repoRoot;
|
|
95
|
+
// The install-recorded checkout may since have moved or been deleted: name it only
|
|
96
|
+
// while it still declares this pool's binding.
|
|
97
|
+
const recorded = installed?.checkout;
|
|
98
|
+
if (recorded && isStable(recorded)) {
|
|
99
|
+
const found = await readBinding(recorded).catch(() => null);
|
|
100
|
+
if (found && found.repoRoot === recorded && found.binding.poolId === poolId) return recorded;
|
|
101
|
+
}
|
|
102
|
+
const [candidate] = await findKnownCheckouts({ home, poolId, readBinding, isStable }).catch(() => []);
|
|
103
|
+
return candidate?.path;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
|
|
107
|
+
if (options.home) {
|
|
108
|
+
const paths = poolHomePaths({ home, root: options.home });
|
|
109
|
+
const config = readInstalledConfig(paths.config);
|
|
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 };
|
|
112
|
+
}
|
|
113
|
+
const found = await readBinding(cwd);
|
|
114
|
+
if (!found) {
|
|
115
|
+
throw new ConfigError(`${cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool.`, "pool_not_configured");
|
|
116
|
+
}
|
|
117
|
+
const paths = poolHomePaths({ home, poolId: found.binding.poolId });
|
|
118
|
+
// Remembered so a later refusal run from the wrong directory can name this checkout.
|
|
119
|
+
recordCheckout({ home, path: found.repoRoot, poolId: found.binding.poolId });
|
|
120
|
+
let installed = null;
|
|
121
|
+
try {
|
|
122
|
+
installed = readInstalledConfig(paths.config);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code !== "config_missing") installed = { error };
|
|
125
|
+
}
|
|
126
|
+
const stableCheckout = await stableCheckoutFor({ repoRoot: found.repoRoot, installed: installed?.error ? null : installed, poolId: found.binding.poolId, home, readBinding, isStable: isStableCheckout });
|
|
127
|
+
return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot, stableCheckout };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
131
|
+
// No checkout known → undefined, so a real refusal says where to run it instead of
|
|
132
|
+
// printing the binding's `<your … checkout>` placeholder.
|
|
133
|
+
export const installCommandFor = (repoRoot) =>
|
|
134
|
+
repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : undefined;
|
|
135
|
+
|
|
136
|
+
// The non-secret values every guidance template may name.
|
|
137
|
+
export function guidanceVars(ctx, extra = {}) {
|
|
138
|
+
if (!ctx) return { ...extra };
|
|
139
|
+
return {
|
|
140
|
+
label: ctx.poolBinding.launchdLabel,
|
|
141
|
+
log: ctx.paths.log,
|
|
142
|
+
installCommand: installCommandFor(ctx.stableCheckout),
|
|
143
|
+
docs: ctx.poolBinding.docs,
|
|
144
|
+
configPath: ctx.paths.config,
|
|
145
|
+
poolHome: ctx.paths.root,
|
|
146
|
+
tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
|
|
147
|
+
checkout: ctx.stableCheckout,
|
|
148
|
+
envFile: ctx.poolBinding.passwordEnvFile,
|
|
149
|
+
allowed: Object.keys(ctx.poolBinding.personas ?? {}),
|
|
150
|
+
...extra,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function reasonsFor(ctx) {
|
|
155
|
+
return healthReasons(guidanceVars(ctx));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildPool({ ctx, target, signIn, now, logger, intervalSeconds, fetchImpl, sleep }) {
|
|
159
|
+
return createSessionPool({
|
|
160
|
+
poolDir: ctx.paths.pool,
|
|
161
|
+
target,
|
|
162
|
+
signIn: signIn ?? createGoTrueTotpSignIn({
|
|
163
|
+
fetchImpl,
|
|
164
|
+
totpProvider: async () => {
|
|
165
|
+
throw new Error("take/status never mint");
|
|
166
|
+
},
|
|
167
|
+
passwordFor: async () => {
|
|
168
|
+
throw new Error("take/status never mint");
|
|
169
|
+
},
|
|
170
|
+
logger,
|
|
171
|
+
sleep,
|
|
172
|
+
nowMs: now,
|
|
173
|
+
}),
|
|
174
|
+
now,
|
|
175
|
+
logger,
|
|
176
|
+
...(intervalSeconds ? { intervalMs: intervalSeconds * 1000 } : {}),
|
|
177
|
+
tickIntervalHint: intervalSeconds ? ` (every ${Math.round(intervalSeconds / 60)} min)` : "",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona }) {
|
|
182
|
+
const reasons = reasonsFor(ctx);
|
|
183
|
+
if (!ctx.installed) {
|
|
184
|
+
return assessPoolHealth({ reasons, jobLoaded: false, status: null, personas: {}, intervalSeconds: DEFAULT_INTERVAL_SECONDS, nowMs: now() });
|
|
185
|
+
}
|
|
186
|
+
if (ctx.installed.error) {
|
|
187
|
+
return assessPoolHealth({ reasons, configError: String(ctx.installed.error.message), status: null, personas: {}, intervalSeconds: DEFAULT_INTERVAL_SECONDS, nowMs: now() });
|
|
188
|
+
}
|
|
189
|
+
const jobLoaded = await createLaunchctl({ exec, uid }).isLoaded(ctx.poolBinding.launchdLabel);
|
|
190
|
+
return {
|
|
191
|
+
...assessPoolHealth({
|
|
192
|
+
reasons,
|
|
193
|
+
status: await pool.status(),
|
|
194
|
+
personas: ctx.installed.personas,
|
|
195
|
+
intervalSeconds: ctx.installed.intervalSeconds,
|
|
196
|
+
nowMs: now(),
|
|
197
|
+
jobLoaded,
|
|
198
|
+
requestedPersona,
|
|
199
|
+
}),
|
|
200
|
+
intervalSeconds: ctx.installed.intervalSeconds,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Agent path, used by `bb pw … auth`: claim one verified session for a persona
|
|
206
|
+
* (alias or email) of this repo's binding. Returns { taken, target, binding,
|
|
207
|
+
* discard } — the caller installs taken.statePath into its claimed lane and then
|
|
208
|
+
* MUST call discard() (single use). Throws PoolEmptyError carrying the health
|
|
209
|
+
* reason/agentAction/remedy when nothing is ready.
|
|
210
|
+
*/
|
|
211
|
+
export async function takeForLane({
|
|
212
|
+
persona,
|
|
213
|
+
owner,
|
|
214
|
+
cwd = process.cwd(),
|
|
215
|
+
home = homedir(),
|
|
216
|
+
now = () => Date.now(),
|
|
217
|
+
fetchImpl = globalThis.fetch,
|
|
218
|
+
exec = promisify(execFile),
|
|
219
|
+
uid = userInfo().uid,
|
|
220
|
+
logger = () => {},
|
|
221
|
+
readBinding = readRepoPoolBinding,
|
|
222
|
+
isStableCheckout = defaultIsStableCheckout,
|
|
223
|
+
} = {}) {
|
|
224
|
+
const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
|
|
225
|
+
const entry = findPersona(ctx.poolBinding, persona);
|
|
226
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
227
|
+
if (!entry) assertPersonaAllowed(target, persona);
|
|
228
|
+
const intervalSeconds = ctx.installed && !ctx.installed.error ? ctx.installed.intervalSeconds : undefined;
|
|
229
|
+
const pool = buildPool({ ctx, target, now, logger, intervalSeconds, fetchImpl });
|
|
230
|
+
try {
|
|
231
|
+
const taken = await pool.take({ persona: entry.email, owner });
|
|
232
|
+
return { taken, target, binding: ctx.poolBinding, persona: entry, discard: () => pool.discardTaken(taken.slotId) };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!(error instanceof PoolEmptyError)) throw error;
|
|
235
|
+
const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry.email });
|
|
236
|
+
let effective = health;
|
|
237
|
+
if (health.ok) {
|
|
238
|
+
const g = guidance("pool_empty", guidanceVars(ctx, { persona: entry.alias }));
|
|
239
|
+
effective = { ...health, reason: "pool_empty", agentAction: g.agent_action, remedy: remedyLine(g), message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help };
|
|
240
|
+
}
|
|
241
|
+
for (const field of GUIDANCE_FIELDS) if (effective[field] !== undefined) error[field] = effective[field];
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Fields a refusal carries so a caller can act on it without reading the code.
|
|
247
|
+
export const GUIDANCE_FIELDS = Object.freeze(["reason", "agentAction", "remedy", "message", "cause", "actor", "next", "doc", "help", "detail", "intervalSeconds"]);
|
|
248
|
+
|
|
249
|
+
// The human-only command line to repeat from Terminal.app (no secrets: flags only).
|
|
250
|
+
function humanCommandLine(argv) {
|
|
251
|
+
if (argv[0] === "install") return 'install --botbuddy-bin "$(command -v botbuddy)"';
|
|
252
|
+
return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// What a missing pool binding means for each subcommand (AC-8 follow-up: an
|
|
256
|
+
// install refusal must not talk about taking a session).
|
|
257
|
+
const NO_POOL_CONSEQUENCE = Object.freeze({
|
|
258
|
+
install: "there is no pool definition to install",
|
|
259
|
+
uninstall: "there is no pool to remove",
|
|
260
|
+
tick: "there is no pool to refill",
|
|
261
|
+
"mint-one": "there is no pool to sign a persona in for",
|
|
262
|
+
status: "there is no pool to report on",
|
|
263
|
+
probe: "there are no pool personas to probe",
|
|
264
|
+
"print-plist": "there is no pool job to describe",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 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. The
|
|
270
|
+
* checkout comes from this directory, else from a checkout an install recorded or
|
|
271
|
+
* one seen earlier; with none, the next step says where to run the command.
|
|
272
|
+
*/
|
|
273
|
+
export async function refusalFor({ argv, command, error, options = {}, cwd = process.cwd(), home = homedir(), readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
|
|
274
|
+
const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
|
|
275
|
+
const humanOnly = HUMAN_ONLY_COMMANDS.has(command);
|
|
276
|
+
let ctx = null;
|
|
277
|
+
try {
|
|
278
|
+
ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
279
|
+
} catch {
|
|
280
|
+
ctx = null;
|
|
281
|
+
}
|
|
282
|
+
if (!ctx) {
|
|
283
|
+
const [candidate] = await findKnownCheckouts({ home, readBinding, isStable: isStableCheckout }).catch(() => []);
|
|
284
|
+
if (candidate) {
|
|
285
|
+
try {
|
|
286
|
+
ctx = await resolveContext({ options: {}, cwd: candidate.path, home, readBinding, isStableCheckout });
|
|
287
|
+
} catch {
|
|
288
|
+
ctx = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const line = humanCommandLine(argv ?? [command]);
|
|
293
|
+
const vars = guidanceVars(ctx, {
|
|
294
|
+
command: line,
|
|
295
|
+
retry: `bb staging-pool ${line}`,
|
|
296
|
+
cwd,
|
|
297
|
+
consequence: NO_POOL_CONSEQUENCE[command] ?? "there is no pool to act on",
|
|
298
|
+
markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
|
|
299
|
+
});
|
|
300
|
+
vars.persona = options.persona ?? error?.persona ?? vars.allowed?.[0];
|
|
301
|
+
const actor = humanOnly ? "human" : null;
|
|
302
|
+
const g = guidance(code, vars, { real: true, actor });
|
|
303
|
+
const record = {
|
|
304
|
+
ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
|
|
305
|
+
agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
|
|
306
|
+
};
|
|
307
|
+
// A pool refusal (pool_empty / unhealthy) already carries its health guidance;
|
|
308
|
+
// an ordinary error's own message stays in `detail`.
|
|
309
|
+
if (typeof error?.reason === "string") for (const field of GUIDANCE_FIELDS) if (error[field] !== undefined) record[field] = error[field];
|
|
310
|
+
record.remedy ??= remedyLine(g);
|
|
311
|
+
if (humanOnly) Object.assign(record, { actor: g.actor_label, agentAction: "report_blocker" });
|
|
312
|
+
// An empty pool is explained by its health reason (e.g. job_not_loaded).
|
|
313
|
+
const textG = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars, { real: true, actor }) : g;
|
|
314
|
+
return { record, text: formatGuidance(textG, { agentLine: !humanOnly }), exitCode: exitCodeFor(error) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function runStagingPool(argv, deps = {}) {
|
|
318
|
+
const {
|
|
319
|
+
env = process.env,
|
|
320
|
+
stdout = (line) => process.stdout.write(`${line}\n`),
|
|
321
|
+
stderr = (line) => process.stderr.write(`${line}\n`),
|
|
322
|
+
cwd = process.cwd(),
|
|
323
|
+
home = homedir(),
|
|
324
|
+
uid = userInfo().uid,
|
|
325
|
+
exec = promisify(execFile),
|
|
326
|
+
listAncestors = listProcessAncestors,
|
|
327
|
+
fetchImpl = globalThis.fetch,
|
|
328
|
+
fs = nodeFs,
|
|
329
|
+
now = () => Date.now(),
|
|
330
|
+
scriptDir = dirname(fileURLToPath(import.meta.url)),
|
|
331
|
+
nodeBin = process.execPath,
|
|
332
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
333
|
+
readBinding = readRepoPoolBinding,
|
|
334
|
+
readSecretFile = readFileSync,
|
|
335
|
+
isStableCheckout = defaultIsStableCheckout,
|
|
336
|
+
} = deps;
|
|
337
|
+
|
|
338
|
+
const redactor = createRedactor();
|
|
339
|
+
const emit = (record) => stdout(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
|
|
340
|
+
|
|
341
|
+
function minterFor(ctx, target) {
|
|
342
|
+
const binding = ctx.poolBinding;
|
|
343
|
+
const botbuddyBin = ctx.installed?.botbuddyBin;
|
|
344
|
+
if (!botbuddyBin) throw new ConfigError("The pool job is not installed (no botbuddyBin); the human runs the install first.", "pool_not_installed");
|
|
345
|
+
const password = createEnvFilePasswordSource({ envFile: binding.passwordEnvFile, personas: binding.personas, home, read: readSecretFile });
|
|
346
|
+
// Register the password with the redactor before any log line can carry it.
|
|
347
|
+
const passwordFor = async (args) => {
|
|
348
|
+
const value = await password(args);
|
|
349
|
+
redactor.register(value);
|
|
350
|
+
return value;
|
|
351
|
+
};
|
|
352
|
+
return createGoTrueTotpSignIn({
|
|
353
|
+
fetchImpl,
|
|
354
|
+
totpProvider: createBotBuddyTotpProvider({ exec, botbuddyBin, env, tenant: binding.tenant, appSlug: binding.appSlug }),
|
|
355
|
+
passwordFor,
|
|
356
|
+
logger: emit,
|
|
357
|
+
sleep,
|
|
358
|
+
nowMs: now,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function requireBotbuddyBin(options) {
|
|
363
|
+
const bin = options["botbuddy-bin"];
|
|
364
|
+
if (!bin) throw new ConfigError('install needs --botbuddy-bin "$(command -v botbuddy)".', "botbuddy_bin_required");
|
|
365
|
+
if (!bin.startsWith("/")) throw new ConfigError("--botbuddy-bin must be an absolute path.", "botbuddy_bin_required");
|
|
366
|
+
// fnm's per-shell PATH dirs disappear with the shell; the job outlives it.
|
|
367
|
+
return realpathSync(bin);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function copyBundle(destination) {
|
|
371
|
+
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
|
372
|
+
for (const file of BUNDLE_FILES) {
|
|
373
|
+
await fs.copyFile(join(scriptDir, file), join(destination, file));
|
|
374
|
+
await fs.chmod(join(destination, file), 0o600);
|
|
375
|
+
}
|
|
376
|
+
// The bundle is ESM; mark it so whatever package.json sits above the pool home is irrelevant.
|
|
377
|
+
await fs.writeFile(join(destination, "package.json"), '{ "type": "module" }\n', { mode: 0o600 });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function jobPlist(ctx, intervalSeconds) {
|
|
381
|
+
return buildLaunchAgentPlist({
|
|
382
|
+
label: ctx.poolBinding.launchdLabel,
|
|
383
|
+
programArguments: [nodeBin, join(ctx.paths.app, "job.mjs"), "tick", "--home", ctx.paths.root],
|
|
384
|
+
environment: { PATH: `${dirname(nodeBin)}:/usr/bin:/bin`, HOME: home },
|
|
385
|
+
workingDirectory: ctx.paths.root,
|
|
386
|
+
intervalSeconds,
|
|
387
|
+
stdoutPath: ctx.paths.log,
|
|
388
|
+
stderrPath: ctx.paths.log,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const commands = {
|
|
393
|
+
async install(options) {
|
|
394
|
+
const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
|
|
395
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
396
|
+
assertMintAllowed({ target, env, listAncestors });
|
|
397
|
+
const botbuddyBin = requireBotbuddyBin(options);
|
|
398
|
+
for (const dir of [ctx.paths.root, ctx.paths.logDir]) {
|
|
399
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
400
|
+
await fs.chmod(dir, 0o700);
|
|
401
|
+
}
|
|
402
|
+
await copyBundle(ctx.paths.app);
|
|
403
|
+
const rawBinding = JSON.parse(await fs.readFile(join(ctx.repoRoot, ".botbuddy-agent.json"), "utf8"));
|
|
404
|
+
let existing = null;
|
|
405
|
+
try {
|
|
406
|
+
existing = JSON.parse(await fs.readFile(ctx.paths.config, "utf8"));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error?.code !== "ENOENT") throw error;
|
|
409
|
+
}
|
|
410
|
+
const explicitInterval = options.interval === undefined ? undefined : Number(options.interval);
|
|
411
|
+
const config = buildInstalledConfig({
|
|
412
|
+
rawBinding,
|
|
413
|
+
poolBinding: ctx.poolBinding,
|
|
414
|
+
botbuddyBin,
|
|
415
|
+
intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
|
|
416
|
+
existingPersonas: existing?.personas,
|
|
417
|
+
checkout: ctx.repoRoot,
|
|
418
|
+
installedAt: new Date(now()).toISOString(),
|
|
419
|
+
});
|
|
420
|
+
await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
|
|
421
|
+
await fs.chmod(ctx.paths.config, 0o600);
|
|
422
|
+
const plistPath = join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`);
|
|
423
|
+
await fs.mkdir(ctx.paths.plistDir, { recursive: true });
|
|
424
|
+
await fs.writeFile(plistPath, jobPlist(ctx, config.intervalSeconds), { mode: 0o644 });
|
|
425
|
+
const launchctl = createLaunchctl({ exec, uid });
|
|
426
|
+
await launchctl.bootout(ctx.poolBinding.launchdLabel);
|
|
427
|
+
await launchctl.bootstrap(plistPath);
|
|
428
|
+
emit({
|
|
429
|
+
ok: true,
|
|
430
|
+
command: "install",
|
|
431
|
+
label: ctx.poolBinding.launchdLabel,
|
|
432
|
+
plistPath,
|
|
433
|
+
poolHome: ctx.paths.root,
|
|
434
|
+
log: ctx.paths.log,
|
|
435
|
+
intervalSeconds: config.intervalSeconds,
|
|
436
|
+
personas: config.personas,
|
|
437
|
+
next: "bb staging-pool status --check # the pool fills within one tick",
|
|
438
|
+
});
|
|
439
|
+
return 0;
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
async uninstall(options) {
|
|
443
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
444
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
445
|
+
// The job and its sessions are the human's: an agent never stops or empties them.
|
|
446
|
+
assertMintAllowed({ target, env, listAncestors });
|
|
447
|
+
const launchctl = createLaunchctl({ exec, uid });
|
|
448
|
+
const wasLoaded = await launchctl.bootout(ctx.poolBinding.launchdLabel);
|
|
449
|
+
await fs.rm(join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`), { force: true });
|
|
450
|
+
let removedSessions = 0;
|
|
451
|
+
if (!options["keep-sessions"]) removedSessions = await buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep }).purge();
|
|
452
|
+
emit({ ok: true, command: "uninstall", label: ctx.poolBinding.launchdLabel, wasLoaded, removedSessions });
|
|
453
|
+
return 0;
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
async tick(options) {
|
|
457
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
458
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
459
|
+
assertMintAllowed({ target, env, listAncestors });
|
|
460
|
+
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");
|
|
461
|
+
const pool = buildPool({ ctx, target, signIn: minterFor(ctx, target), now, logger: emit, intervalSeconds: ctx.installed.intervalSeconds, fetchImpl, sleep });
|
|
462
|
+
const summary = await pool.tick({ personas: ctx.installed.personas });
|
|
463
|
+
return summary.mintFailures > 0 ? 1 : 0;
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async "mint-one"(options) {
|
|
467
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
468
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
469
|
+
assertMintAllowed({ target, env, listAncestors });
|
|
470
|
+
const entry = findPersona(ctx.poolBinding, options.persona);
|
|
471
|
+
const persona = entry ? entry.email : assertPersonaAllowed(target, options.persona);
|
|
472
|
+
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");
|
|
473
|
+
const pool = buildPool({ ctx, target, signIn: minterFor(ctx, target), now, logger: emit, intervalSeconds: ctx.installed.intervalSeconds, fetchImpl, sleep });
|
|
474
|
+
const minted = await pool.mintOne({ persona });
|
|
475
|
+
emit({ ok: true, command: "mint-one", target: target.name, signIns: 1, ...minted.meta });
|
|
476
|
+
return 0;
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
async status(options) {
|
|
480
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
481
|
+
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
482
|
+
const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
|
|
483
|
+
if (options.check) {
|
|
484
|
+
const entry = options.persona ? findPersona(ctx.poolBinding, options.persona) : null;
|
|
485
|
+
if (options.persona && !entry) assertPersonaAllowed(target, options.persona);
|
|
486
|
+
const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry?.email });
|
|
487
|
+
emit({ command: "status", check: true, ...health });
|
|
488
|
+
return health.ok ? 0 : 5;
|
|
489
|
+
}
|
|
490
|
+
emit({ ok: true, command: "status", ...(await pool.status()) });
|
|
491
|
+
return 0;
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
async probe(options) {
|
|
495
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
496
|
+
const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
|
|
497
|
+
if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
|
|
498
|
+
const receipt = await probeBotBuddy({
|
|
499
|
+
exec,
|
|
500
|
+
botbuddyBin,
|
|
501
|
+
env,
|
|
502
|
+
tenant: ctx.poolBinding.tenant,
|
|
503
|
+
appSlug: ctx.poolBinding.appSlug,
|
|
504
|
+
environment: ctx.poolBinding.environment,
|
|
505
|
+
personaEmails: Object.values(ctx.poolBinding.personas).map((persona) => persona.email),
|
|
506
|
+
});
|
|
507
|
+
emit({ command: "probe", ...receipt });
|
|
508
|
+
return receipt.ok ? 0 : 5;
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
async "print-plist"(options) {
|
|
512
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
513
|
+
stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
|
|
514
|
+
return 0;
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// One JSON line (machine-readable) then the same guidance as text, on stderr.
|
|
519
|
+
// One JSON line (machine-readable) then the same guidance as text, on stderr.
|
|
520
|
+
async function fail(command, error, options = {}) {
|
|
521
|
+
const { record, text, exitCode } = await refusalFor({ argv, command, error, options, cwd, home, readBinding, isStableCheckout });
|
|
522
|
+
stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
|
|
523
|
+
stderr(redactor.redact(text));
|
|
524
|
+
return exitCode;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = parseArgs(argv);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
await fail(argv[0] ?? null, Object.assign(error, { code: "usage" }));
|
|
532
|
+
return 4;
|
|
533
|
+
}
|
|
534
|
+
if (parsed.command === undefined || parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || parsed.options.help) {
|
|
535
|
+
stdout(STAGING_POOL_HELP);
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
const handler = commands[parsed.command];
|
|
539
|
+
if (!handler) {
|
|
540
|
+
await fail(parsed.command, new ConfigError(`Unknown subcommand ${parsed.command}.`, "usage"));
|
|
541
|
+
return 4;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
return await handler(parsed.options);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return fail(parsed.command, error, parsed.options);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// BOT-2084 (lifted from supplyspark-ent ENT-5120 config.mjs): pool home layout and
|
|
2
|
+
// the installed job's config. Node-stdlib-only and self-contained.
|
|
3
|
+
//
|
|
4
|
+
// ~/.botbuddy/staging-session-pool/<pool_id>/ (0700)
|
|
5
|
+
// config.json binding snapshot + persona counts, botbuddy CLI path, tick interval
|
|
6
|
+
// app/ the installed copy of these modules (the job never runs from a worktree)
|
|
7
|
+
// pool/ ready/ work/ taken/ (see pool.mjs)
|
|
8
|
+
// log/pool.log the job's redacted JSON-lines log
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { MAX_POOL_SIZE, parsePoolBinding } from "./binding.mjs";
|
|
15
|
+
|
|
16
|
+
export class ConfigError extends Error {
|
|
17
|
+
constructor(message, code = "config_missing") {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ConfigError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_INTERVAL_SECONDS = 300;
|
|
25
|
+
|
|
26
|
+
export function poolHomePaths({ home = homedir(), poolId, root } = {}) {
|
|
27
|
+
const base = root ?? join(home, ".botbuddy", "staging-session-pool", String(poolId));
|
|
28
|
+
return {
|
|
29
|
+
root: base,
|
|
30
|
+
config: join(base, "config.json"),
|
|
31
|
+
app: join(base, "app"),
|
|
32
|
+
pool: join(base, "pool"),
|
|
33
|
+
logDir: join(base, "log"),
|
|
34
|
+
log: join(base, "log", "pool.log"),
|
|
35
|
+
plistDir: join(home, "Library", "LaunchAgents"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The installed config: { version, binding: <raw .botbuddy-agent.json subset>,
|
|
40
|
+
// personas: { email: count }, botbuddyBin, intervalSeconds }. Returns it with the
|
|
41
|
+
// parsed binding attached as `poolBinding`.
|
|
42
|
+
export function validateInstalledConfig(config) {
|
|
43
|
+
if (!config || typeof config !== "object") throw new ConfigError("Pool config is not an object.", "config_invalid");
|
|
44
|
+
if (config.version !== 1) throw new ConfigError("Pool config version must be 1.", "config_invalid");
|
|
45
|
+
let poolBinding;
|
|
46
|
+
try {
|
|
47
|
+
poolBinding = parsePoolBinding(config.binding);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new ConfigError(`Pool config binding is invalid: ${error.message}`, "config_invalid");
|
|
50
|
+
}
|
|
51
|
+
if (!poolBinding) throw new ConfigError("Pool config has no staging_session_pool binding.", "config_invalid");
|
|
52
|
+
if (typeof config.botbuddyBin !== "string" || !config.botbuddyBin.startsWith("/")) {
|
|
53
|
+
throw new ConfigError("Pool config botbuddyBin must be an absolute path (re-run install).", "config_invalid");
|
|
54
|
+
}
|
|
55
|
+
if (!Number.isInteger(config.intervalSeconds) || config.intervalSeconds < 60 || config.intervalSeconds > 3600) {
|
|
56
|
+
throw new ConfigError("Pool config intervalSeconds must be an integer 60-3600.", "config_invalid");
|
|
57
|
+
}
|
|
58
|
+
const allowed = new Set(Object.values(poolBinding.personas).map((persona) => persona.email));
|
|
59
|
+
const personas = config.personas;
|
|
60
|
+
if (!personas || typeof personas !== "object" || Object.keys(personas).length === 0) {
|
|
61
|
+
throw new ConfigError("Pool config personas must name at least one persona.", "config_invalid");
|
|
62
|
+
}
|
|
63
|
+
for (const [persona, count] of Object.entries(personas)) {
|
|
64
|
+
if (!allowed.has(persona)) throw new ConfigError(`Pool config persona ${persona} is not allowlisted by the binding.`, "config_invalid");
|
|
65
|
+
if (!Number.isInteger(count) || count < 0 || count > MAX_POOL_SIZE) {
|
|
66
|
+
throw new ConfigError(`Pool size for ${persona} must be an integer 0-${MAX_POOL_SIZE}.`, "config_invalid");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { ...config, poolBinding };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
text = readFile(path, "utf8");
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === "ENOENT") throw new ConfigError(`Pool config ${path} is missing: the pool job is not installed.`, "config_missing");
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new ConfigError(`Pool config ${path} is not valid JSON.`, "config_invalid");
|
|
85
|
+
}
|
|
86
|
+
return validateInstalledConfig(parsed);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// rawBinding: { tenant, staging_session_pool } exactly as committed.
|
|
90
|
+
export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, checkout, installedAt }) {
|
|
91
|
+
const personas = {};
|
|
92
|
+
for (const persona of Object.values(poolBinding.personas)) {
|
|
93
|
+
personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
|
|
94
|
+
}
|
|
95
|
+
return validateInstalledConfig({
|
|
96
|
+
version: 1,
|
|
97
|
+
binding: { tenant: rawBinding.tenant, staging_session_pool: rawBinding.staging_session_pool },
|
|
98
|
+
personas,
|
|
99
|
+
botbuddyBin,
|
|
100
|
+
intervalSeconds,
|
|
101
|
+
// The checkout install ran from (non-secret), so a refusal elsewhere can name it.
|
|
102
|
+
...(typeof checkout === "string" && checkout.startsWith("/") ? { checkout } : {}),
|
|
103
|
+
...(installedAt ? { installedAt } : {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function serialiseInstalledConfig(config) {
|
|
108
|
+
const { poolBinding: _parsed, ...rest } = config;
|
|
109
|
+
return `${JSON.stringify(rest, null, 2)}\n`;
|
|
110
|
+
}
|