@botbuddy/cli 1.61.0 → 1.61.2
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/commands.mjs +2 -0
- package/src/pw/staging-auth.mjs +4 -1
- package/src/staging-pool/binding.mjs +6 -1
- package/src/staging-pool/cli.mjs +9 -0
- package/src/staging-pool/help.mjs +7 -0
- package/src/staging-pool/refresh-runtime.mjs +135 -0
- package/src/staging-pool/registry.mjs +3 -2
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.61.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.61.2",
|
|
3
|
+
"source_commit": "dd1a661ff53e723ffff2ac338d38254016838555",
|
|
4
|
+
"source_sha256": "d6f135948d8106303c7cabdc54e507e93c84c2d96a11bf457eaaf6c3a5f49898",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "a3210e7bf1e6d2f7d181665b272d7b61dc85e3502612126e007cd2e0296ab469"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.61.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.61.2",
|
|
3
|
+
"source_commit": "dd1a661ff53e723ffff2ac338d38254016838555",
|
|
4
|
+
"source_sha256": "d6f135948d8106303c7cabdc54e507e93c84c2d96a11bf457eaaf6c3a5f49898",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "acb7cd4a38d41d3865cfa774263fd3681e02ff1ff22fa94046332cda0159d0de"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.61.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.61.2",
|
|
4
|
+
"source_commit": "dd1a661ff53e723ffff2ac338d38254016838555",
|
|
5
|
+
"source_sha256": "d6f135948d8106303c7cabdc54e507e93c84c2d96a11bf457eaaf6c3a5f49898",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "a3210e7bf1e6d2f7d181665b272d7b61dc85e3502612126e007cd2e0296ab469",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "acb7cd4a38d41d3865cfa774263fd3681e02ff1ff22fa94046332cda0159d0de",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -278,6 +278,8 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
|
|
|
278
278
|
persona, no password (${cyan("bb pw auth --help")})
|
|
279
279
|
staging-pool status [--check]
|
|
280
280
|
Pool health: one reason, who acts, the next command
|
|
281
|
+
staging-pool refresh-runtime --tenant <slug> --pool <id>
|
|
282
|
+
Refresh an existing producer; never signs in
|
|
281
283
|
staging-pool install --tenant <slug> | tick | mint-one | uninstall
|
|
282
284
|
Human only, any directory (${cyan("bb staging-pool --help")})
|
|
283
285
|
help symptom staging-pool Every refusal code with its cause and next command
|
package/src/pw/staging-auth.mjs
CHANGED
|
@@ -314,7 +314,10 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
|
|
|
314
314
|
claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions });
|
|
315
315
|
} catch (error) {
|
|
316
316
|
if (error?.code === "pool_empty") {
|
|
317
|
-
|
|
317
|
+
// The refusal's code is the pool's health reason when it is unhealthy (e.g. a
|
|
318
|
+
// count-0 persona is persona_not_configured), so code, message and help agree.
|
|
319
|
+
const reasonCode = typeof error.reason === "string" && error.reason !== "healthy" && isKnownCode(error.reason) ? error.reason : "pool_empty";
|
|
320
|
+
return refuse(io, reasonCode, 3, Object.fromEntries(["reason", "detail", ...GUIDED_FIELDS].filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
|
|
318
321
|
}
|
|
319
322
|
const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
|
|
320
323
|
return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, vars);
|
|
@@ -73,7 +73,12 @@ export function parsePoolBinding(raw) {
|
|
|
73
73
|
for (const [alias, persona] of Object.entries(personas)) {
|
|
74
74
|
need(ALIAS_RE.test(alias), `persona alias "${alias}" must be a short lowercase slug`);
|
|
75
75
|
need(persona && typeof persona.email === "string" && EMAIL_RE.test(persona.email), `persona ${alias} needs a seeded email`);
|
|
76
|
-
|
|
76
|
+
// BOT-2673: mirror the authenticated registry's single inbox exception.
|
|
77
|
+
const approvedInbox = raw.tenant === "supply-guard"
|
|
78
|
+
&& section.app_origin === "https://enterprise-staging.vercel.app"
|
|
79
|
+
&& section.supabase_url === "https://ikdoydgubernmxzdqtxz.supabase.co"
|
|
80
|
+
&& persona.email.toLowerCase() === "staging+ent4086ac6@dorouarkii.resend.app";
|
|
81
|
+
need(/\.(local|test)$/i.test(persona.email) || approvedInbox, `persona ${alias} must use a non-routable .local/.test seed or the approved inbox for its staging target`);
|
|
77
82
|
need(!emails.has(persona.email.toLowerCase()), `persona email ${persona.email} is listed twice`);
|
|
78
83
|
emails.add(persona.email.toLowerCase());
|
|
79
84
|
need(typeof persona.password_env === "string" && ENV_RE.test(persona.password_env), `persona ${alias} needs password_env (an UPPER_SNAKE env var name in password_env_file)`);
|
package/src/staging-pool/cli.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
|
|
|
40
40
|
import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode, remedyLine } from "./errors.mjs";
|
|
41
41
|
import { RegistryError, choosePool, fetchRegisteredPools, whoamiTenant } from "./registry.mjs";
|
|
42
42
|
import { STAGING_POOL_HELP } from "./help.mjs";
|
|
43
|
+
import { refreshExistingRuntime } from "./refresh-runtime.mjs";
|
|
43
44
|
|
|
44
45
|
export const BUNDLE_FILES = Object.freeze([
|
|
45
46
|
"binding.mjs",
|
|
@@ -54,6 +55,7 @@ export const BUNDLE_FILES = Object.freeze([
|
|
|
54
55
|
"policy.mjs",
|
|
55
56
|
"pool.mjs",
|
|
56
57
|
"registry.mjs",
|
|
58
|
+
"refresh-runtime.mjs",
|
|
57
59
|
"signin-gotrue-totp.mjs",
|
|
58
60
|
"state-file.mjs",
|
|
59
61
|
"totp-botbuddy.mjs",
|
|
@@ -359,6 +361,8 @@ export async function refusalFor({ argv, command, error, options = {}, home = ho
|
|
|
359
361
|
// A pool refusal (pool_empty / unhealthy) already carries its health guidance;
|
|
360
362
|
// an ordinary error's own message stays in `detail`.
|
|
361
363
|
if (typeof error?.reason === "string") for (const field of GUIDANCE_FIELDS) if (error[field] !== undefined) record[field] = error[field];
|
|
364
|
+
// An unhealthy pool's refusal carries its health reason's guidance, so its code is that reason.
|
|
365
|
+
if (typeof error?.reason === "string" && error.reason !== code && error.reason !== "healthy" && isKnownCode(error.reason)) record.code = error.reason;
|
|
362
366
|
record.remedy ??= remedyLine(g);
|
|
363
367
|
if (humanOnly) Object.assign(record, { actor: g.actor_label, agentAction: "report_blocker" });
|
|
364
368
|
// An empty pool is explained by its health reason (e.g. job_not_loaded).
|
|
@@ -453,6 +457,11 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
const commands = {
|
|
460
|
+
async "refresh-runtime"(options) {
|
|
461
|
+
emit(await refreshExistingRuntime({ options, home, uid, env, exec, bundleFiles: BUNDLE_FILES }));
|
|
462
|
+
return 0;
|
|
463
|
+
},
|
|
464
|
+
|
|
456
465
|
async install(options) {
|
|
457
466
|
// Human-only: refuse an agent session before any network or file call.
|
|
458
467
|
assertMintAllowed({ target: { name: "staging", appOrigin: "the tenant's staging app", loopback: false }, env, listAncestors });
|
|
@@ -90,6 +90,13 @@ ANYONE (agents too) — read-only
|
|
|
90
90
|
Prints the launchd plist install would write. Writes nothing.
|
|
91
91
|
e.g. bb staging-pool print-plist
|
|
92
92
|
|
|
93
|
+
ANYONE (agents too) — existing runtime maintenance, never sign-in
|
|
94
|
+
refresh-runtime --tenant <slug> --pool <pool_id>
|
|
95
|
+
Refreshes an already installed pool from its authenticated registration
|
|
96
|
+
and this published CLI package. Preserves credentials, sessions, counts,
|
|
97
|
+
interval and plist. The existing launchd job refills on its next tick.
|
|
98
|
+
No caller path or runtime content is accepted; no sign-in is performed.
|
|
99
|
+
|
|
93
100
|
HUMAN ONLY (Jono, from Terminal.app or iTerm) — refused inside a Claude or Codex session
|
|
94
101
|
These sign in with the persona password or change the standing sessions.
|
|
95
102
|
install --tenant <slug> [--pool <pool_id>] --botbuddy-bin <absolute path> [--interval <seconds>]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// BOT-2673: maintenance of an existing pool, never session production.
|
|
2
|
+
// The shipped package is the only runtime source. The existing launchd job keeps
|
|
3
|
+
// its stable entrypoint; a versioned runtime is published before its new config.
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import * as fs from "node:fs/promises";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { ConfigError, serialiseInstalledConfig, validateInstalledConfig } from "./config.mjs";
|
|
11
|
+
import { parsePoolBinding } from "./binding.mjs";
|
|
12
|
+
import { stagingTargetFromBinding } from "./policy.mjs";
|
|
13
|
+
import { choosePool, fetchRegisteredPools } from "./registry.mjs";
|
|
14
|
+
|
|
15
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
16
|
+
const denied = (message) => { throw new ConfigError(message, "config_invalid"); };
|
|
17
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
18
|
+
|
|
19
|
+
export async function refreshExistingRuntime({ options, home, uid, env, exec, bundleFiles }) {
|
|
20
|
+
if (Object.keys(options).some(key => !["tenant", "pool"].includes(key))) throw new ConfigError("refresh-runtime accepts only --tenant and --pool; it cannot select a path, binary, interval or persona.", "usage");
|
|
21
|
+
const { tenant, pool: poolId } = options;
|
|
22
|
+
if (!SLUG.test(tenant ?? "") || !SLUG.test(poolId ?? "")) throw new ConfigError("refresh-runtime requires --tenant <slug> and --pool <pool_id>.", "usage");
|
|
23
|
+
const root = join(home, ".botbuddy", "staging-session-pool", poolId);
|
|
24
|
+
const app = join(root, "app");
|
|
25
|
+
const configPath = join(root, "config.json");
|
|
26
|
+
const jobPath = join(app, "job.mjs");
|
|
27
|
+
async function owned(path, directory = false, privateFile = false) {
|
|
28
|
+
const info = await fs.lstat(path);
|
|
29
|
+
if (info.isSymbolicLink() || info.uid !== uid || (directory ? !info.isDirectory() : !info.isFile()) || (info.mode & (privateFile ? 0o077 : 0o022))) denied("Existing pool paths must be owned by this user, non-symlink and not writable by others.");
|
|
30
|
+
return info;
|
|
31
|
+
}
|
|
32
|
+
for (const path of [home, join(home, ".botbuddy"), join(home, ".botbuddy", "staging-session-pool"), root, app]) await owned(path, true);
|
|
33
|
+
await owned(jobPath);
|
|
34
|
+
async function configBytes() {
|
|
35
|
+
const handle = await fs.open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
36
|
+
try {
|
|
37
|
+
const info = await handle.stat();
|
|
38
|
+
if (!info.isFile() || info.uid !== uid || (info.mode & 0o077)) denied("Existing pool config must be an owner-only regular file.");
|
|
39
|
+
return await handle.readFile("utf8");
|
|
40
|
+
} finally { await handle.close(); }
|
|
41
|
+
}
|
|
42
|
+
const before = await configBytes();
|
|
43
|
+
const installed = validateInstalledConfig(JSON.parse(before));
|
|
44
|
+
const previous = installed.poolBinding;
|
|
45
|
+
if (previous.tenant !== tenant || previous.poolId !== poolId || (installed.tenant && installed.tenant !== tenant) || (installed.poolId && installed.poolId !== poolId)) denied("The existing pool identity does not match the requested authenticated tenant and pool.");
|
|
46
|
+
if (installed.source !== "control-plane") denied("refresh-runtime requires an existing control-plane pool; legacy installation migration remains human-owned.");
|
|
47
|
+
stagingTargetFromBinding(previous);
|
|
48
|
+
|
|
49
|
+
// No caller flag/dependency can choose the runtime source or CLI entrypoint.
|
|
50
|
+
const source = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const packageRoot = resolve(source, "../..");
|
|
52
|
+
const metadata = JSON.parse(await fs.readFile(join(packageRoot, "package.json"), "utf8"));
|
|
53
|
+
if (metadata.name !== "@botbuddy/cli" || !/^\d+\.\d+\.\d+$/.test(metadata.version)) denied("refresh-runtime requires an installed stable @botbuddy/cli package.");
|
|
54
|
+
for (const path of [join(packageRoot, ".git"), join(dirname(packageRoot), ".git")]) {
|
|
55
|
+
try { await fs.lstat(path); denied("Run refresh-runtime from the published CLI package, never a repository checkout."); }
|
|
56
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
57
|
+
}
|
|
58
|
+
const cliEntry = join(packageRoot, "bin", "botbuddy.mjs");
|
|
59
|
+
const cliInfo = await fs.lstat(cliEntry);
|
|
60
|
+
if (!cliInfo.isFile() || cliInfo.isSymbolicLink() || (cliInfo.mode & 0o022)) denied("The package CLI entrypoint is not a safe regular file.");
|
|
61
|
+
const rows = await fetchRegisteredPools({
|
|
62
|
+
exec: (file, args, callOptions) => exec(process.execPath, [file, ...args], callOptions),
|
|
63
|
+
botbuddyBin: cliEntry, env, tenant, poolId, preserveAgentContext: true, requireTenantMatch: true,
|
|
64
|
+
});
|
|
65
|
+
const row = choosePool(rows, { tenant, poolId });
|
|
66
|
+
const rawBinding = { tenant, staging_session_pool: row.binding };
|
|
67
|
+
const next = parsePoolBinding(rawBinding);
|
|
68
|
+
stagingTargetFromBinding(next);
|
|
69
|
+
for (const field of ["tenant", "poolId", "environment", "appSlug", "adapter", "appOrigin", "supabaseUrl", "publishableKey", "storageKey", "passwordEnvFile", "launchdLabel"]) if (next[field] !== previous[field]) denied(`The registered pool changed its existing ${field}; refresh-runtime cannot retarget or reconfigure a standing job.`);
|
|
70
|
+
if (JSON.stringify([...next.productionOrigins].sort()) !== JSON.stringify([...previous.productionOrigins].sort())) denied("The registered production-origin policy changed; refresh-runtime cannot change a standing target policy.");
|
|
71
|
+
for (const [alias, persona] of Object.entries(previous.personas)) {
|
|
72
|
+
const registered = next.personas[alias];
|
|
73
|
+
if (!registered || registered.email !== persona.email || registered.passwordEnv !== persona.passwordEnv) denied("Existing persona aliases, emails and password variable names must be preserved.");
|
|
74
|
+
}
|
|
75
|
+
// Omission is an operator disable, not a missing default (Codex review).
|
|
76
|
+
const previousEmails = new Set(Object.values(previous.personas).map(persona => persona.email));
|
|
77
|
+
const personas = { ...installed.personas };
|
|
78
|
+
for (const persona of Object.values(next.personas)) if (!previousEmails.has(persona.email)) personas[persona.email] = persona.defaultCount;
|
|
79
|
+
const files = new Map();
|
|
80
|
+
const hash = createHash("sha256").update(metadata.version);
|
|
81
|
+
for (const name of bundleFiles) {
|
|
82
|
+
if (!/^[a-z0-9][a-z0-9.-]*\.mjs$/.test(name)) denied("The package runtime manifest is invalid.");
|
|
83
|
+
const path = join(source, name);
|
|
84
|
+
const info = await fs.lstat(path);
|
|
85
|
+
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o022)) denied("Package runtime files must be safe regular files.");
|
|
86
|
+
const bytes = await fs.readFile(path);
|
|
87
|
+
files.set(name, bytes);
|
|
88
|
+
hash.update(name).update("\0").update(bytes);
|
|
89
|
+
}
|
|
90
|
+
files.set("package.json", Buffer.from('{"type":"module"}\n'));
|
|
91
|
+
const contentDigest = hash.digest("hex");
|
|
92
|
+
const runtimeName = `runtime-${metadata.version}-${contentDigest}`;
|
|
93
|
+
const runtime = join(root, runtimeName);
|
|
94
|
+
const config = validateInstalledConfig({ ...installed, binding: rawBinding, personas, source: "control-plane", tenant, poolId, registry: { commit_sha: row.commit_sha ?? null, updated_at: row.updated_at ?? null }, runtime: { version: metadata.version, sha256: contentDigest } });
|
|
95
|
+
const token = randomUUID();
|
|
96
|
+
const lockPath = join(root, ".runtime-refresh.lock");
|
|
97
|
+
const lock = await fs.open(lockPath, "wx", 0o600);
|
|
98
|
+
const lockInfo = await lock.stat();
|
|
99
|
+
let stage;
|
|
100
|
+
const configTemp = join(root, `.refresh-${token}.config.json`);
|
|
101
|
+
const jobTemp = join(app, `.refresh-${token}.job.mjs`);
|
|
102
|
+
try {
|
|
103
|
+
await lock.writeFile(token);
|
|
104
|
+
if (digest(await configBytes()) !== digest(before)) denied("The existing pool config changed during registry validation; no stale configuration is published.");
|
|
105
|
+
stage = await fs.mkdtemp(join(root, ".runtime-stage-"));
|
|
106
|
+
await fs.chmod(stage, 0o700);
|
|
107
|
+
for (const [name, bytes] of files) await fs.writeFile(join(stage, name), bytes, { mode: 0o600, flag: "wx" });
|
|
108
|
+
try { await fs.rename(stage, runtime); stage = undefined; }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error?.code)) throw error;
|
|
111
|
+
await owned(runtime, true);
|
|
112
|
+
if (JSON.stringify((await fs.readdir(runtime)).sort()) !== JSON.stringify([...files.keys()].sort())) denied("Existing versioned runtime differs from the package manifest.");
|
|
113
|
+
for (const [name, bytes] of files) {
|
|
114
|
+
await owned(join(runtime, name), false, true);
|
|
115
|
+
if (!(await fs.readFile(join(runtime, name))).equals(bytes)) denied("Existing versioned runtime differs from the package contents.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await fs.writeFile(jobTemp, `// BotBuddy published runtime ${metadata.version} (${contentDigest})\nimport "../${runtimeName}/job.mjs";\n`, { mode: 0o600, flag: "wx" });
|
|
119
|
+
await owned(jobPath);
|
|
120
|
+
await fs.rename(jobTemp, jobPath);
|
|
121
|
+
// New code accepts the old config too. A crash between the two atomic
|
|
122
|
+
// publications leaves a valid standing job, never a partial JSON config.
|
|
123
|
+
if (digest(await configBytes()) !== digest(before)) denied("The pool config changed before publication; the existing config is preserved.");
|
|
124
|
+
await fs.writeFile(configTemp, serialiseInstalledConfig(config), { mode: 0o600, flag: "wx" });
|
|
125
|
+
await fs.rename(configTemp, configPath);
|
|
126
|
+
return { ok: true, command: "refresh-runtime", tenant, poolId, runtimeVersion: metadata.version, runtimeSha256: contentDigest, personaAliases: Object.keys(next.personas), producer: "existing launchd job; no sign-in or launchctl command was invoked" };
|
|
127
|
+
} finally {
|
|
128
|
+
if (stage) await fs.rm(stage, { recursive: true, force: true });
|
|
129
|
+
await fs.rm(configTemp, { force: true });
|
|
130
|
+
await fs.rm(jobTemp, { force: true });
|
|
131
|
+
const current = await fs.lstat(lockPath).catch(error => { if (error?.code === "ENOENT") return null; throw error; });
|
|
132
|
+
if (current?.ino === lockInfo.ino && current.dev === lockInfo.dev) await fs.unlink(lockPath);
|
|
133
|
+
await lock.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -28,12 +28,12 @@ function childEnv(env) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** The tenant's registered pools: [{ pool_id, binding, commit_sha, updated_at }]. */
|
|
31
|
-
export async function fetchRegisteredPools({ exec = defaultExec, botbuddyBin, env = process.env, tenant, poolId = null }) {
|
|
31
|
+
export async function fetchRegisteredPools({ exec = defaultExec, botbuddyBin, env = process.env, tenant, poolId = null, preserveAgentContext = false, requireTenantMatch = false }) {
|
|
32
32
|
if (!SLUG_RE.test(String(tenant ?? ""))) throw new RegistryError("tenant_required", "A --tenant slug is required.");
|
|
33
33
|
let stdout;
|
|
34
34
|
try {
|
|
35
35
|
({ stdout } = await exec(botbuddyBin, ["call", "get_staging_pool", "--pin", tenant, "--json", JSON.stringify(poolId ? { pool_id: poolId } : {})], {
|
|
36
|
-
env: childEnv(env),
|
|
36
|
+
env: preserveAgentContext ? { ...env } : childEnv(env),
|
|
37
37
|
timeout: 60_000,
|
|
38
38
|
maxBuffer: 1024 * 1024,
|
|
39
39
|
}));
|
|
@@ -51,6 +51,7 @@ export async function fetchRegisteredPools({ exec = defaultExec, botbuddyBin, en
|
|
|
51
51
|
const code = body.code === "tenant_required" ? "tenant_required" : "pool_registry_unavailable";
|
|
52
52
|
throw new RegistryError(code, `get_staging_pool refused: ${String(body.code ?? "error")}`, { tenant });
|
|
53
53
|
}
|
|
54
|
+
if (requireTenantMatch && body?.tenant !== tenant) throw new RegistryError("pool_registry_unavailable", "get_staging_pool returned a different or unknown authenticated tenant.", { tenant });
|
|
54
55
|
if (!Array.isArray(body?.pools)) throw new RegistryError("pool_registry_unavailable", "get_staging_pool returned no pool list.", { tenant });
|
|
55
56
|
return body.pools.filter((row) => row && typeof row.pool_id === "string" && row.binding && typeof row.binding === "object");
|
|
56
57
|
}
|