@botbuddy/cli 1.61.1 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.61.1",
3
- "source_commit": "09c797743bed1572794a38c60b99ef0c16fed99e",
4
- "source_sha256": "d32701d3d5eb48c0702d79aaee49bd699754b1c9ccff5eb6df5a845db4e9b090",
2
+ "release_version": "1.61.2",
3
+ "source_commit": "dd1a661ff53e723ffff2ac338d38254016838555",
4
+ "source_sha256": "d6f135948d8106303c7cabdc54e507e93c84c2d96a11bf457eaaf6c3a5f49898",
5
5
  "target": "darwin-arm64",
6
- "sha256": "d2c3085339320a7911f2e546705f6dbabed4f8a817a85530bcce0db6e3ec1d70"
6
+ "sha256": "a3210e7bf1e6d2f7d181665b272d7b61dc85e3502612126e007cd2e0296ab469"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.61.1",
3
- "source_commit": "09c797743bed1572794a38c60b99ef0c16fed99e",
4
- "source_sha256": "d32701d3d5eb48c0702d79aaee49bd699754b1c9ccff5eb6df5a845db4e9b090",
2
+ "release_version": "1.61.2",
3
+ "source_commit": "dd1a661ff53e723ffff2ac338d38254016838555",
4
+ "source_sha256": "d6f135948d8106303c7cabdc54e507e93c84c2d96a11bf457eaaf6c3a5f49898",
5
5
  "target": "linux-x64",
6
- "sha256": "7efe991465e7815eed329c1eaace0f7d130b64416034ce9f62863ea2147d6c6c"
6
+ "sha256": "acb7cd4a38d41d3865cfa774263fd3681e02ff1ff22fa94046332cda0159d0de"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.61.1",
4
- "source_commit": "09c797743bed1572794a38c60b99ef0c16fed99e",
5
- "source_sha256": "d32701d3d5eb48c0702d79aaee49bd699754b1c9ccff5eb6df5a845db4e9b090",
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": "d2c3085339320a7911f2e546705f6dbabed4f8a817a85530bcce0db6e3ec1d70",
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": "7efe991465e7815eed329c1eaace0f7d130b64416034ce9f62863ea2147d6c6c",
18
+ "sha256": "acb7cd4a38d41d3865cfa774263fd3681e02ff1ff22fa94046332cda0159d0de",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.61.1",
3
+ "version": "1.61.2",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
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
@@ -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
- need(/\.(local|test)$/i.test(persona.email), `persona ${alias} must use a non-routable .local/.test seeded email`);
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)`);
@@ -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",
@@ -455,6 +457,11 @@ export async function runStagingPool(argv, deps = {}) {
455
457
  }
456
458
 
457
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
+
458
465
  async install(options) {
459
466
  // Human-only: refuse an agent session before any network or file call.
460
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
  }