@botbuddy/cli 1.63.44 → 1.64.1

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.63.44",
3
- "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
4
- "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
2
+ "release_version": "1.64.1",
3
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
4
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
5
5
  "target": "darwin-arm64",
6
- "sha256": "1740585f4d9e5ce03d798353c1c473b6b818191f83d3a6e63b8e94f6e5d975bb"
6
+ "sha256": "929631f9c2d05a901a16e8ba132c4166ca62b2c7b30ab5b8317cbcd59c49ee79"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.44",
3
- "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
4
- "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
2
+ "release_version": "1.64.1",
3
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
4
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
5
5
  "target": "linux-x64",
6
- "sha256": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4"
6
+ "sha256": "fc806cb0d6193a2c6c55eb076a28575b1fabc80955f4f9669b9f15269cbcdabb"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.44",
4
- "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
5
- "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
3
+ "release_version": "1.64.1",
4
+ "source_commit": "fd01ca263ebebf1dda5ec3648d5300a788ee7887",
5
+ "source_sha256": "75f8bdc7954b24d97d7da80abe41f3814bf9bc9f9c00558d26cb78c030b58475",
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": "1740585f4d9e5ce03d798353c1c473b6b818191f83d3a6e63b8e94f6e5d975bb",
11
+ "sha256": "929631f9c2d05a901a16e8ba132c4166ca62b2c7b30ab5b8317cbcd59c49ee79",
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": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4",
18
+ "sha256": "fc806cb0d6193a2c6c55eb076a28575b1fabc80955f4f9669b9f15269cbcdabb",
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.63.44",
3
+ "version": "1.64.1",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,81 @@
1
+ import { callToolJson, resolveCallAuth } from "../api.mjs";
2
+ import {
3
+ assertSshHost,
4
+ installedBinaryPath,
5
+ stageRemoteDaemonRelease,
6
+ } from "./installer.mjs";
7
+ import { validateDaemonRelease } from "./release.mjs";
8
+ import { nativeHandoverCommand } from "./handover.mjs";
9
+ const fail = (code) => {
10
+ throw new Error(code);
11
+ };
12
+ // One explicit administrator installation grants only the fixed native
13
+ // coordinator/schedule. Persisted operator opt-in remains a separate drawer act.
14
+ export async function installAutomaticDaemonUpdater(spec, {
15
+ validateRelease = validateDaemonRelease,
16
+ resolveAuth = resolveCallAuth,
17
+ identity = callToolJson,
18
+ stage = stageRemoteDaemonRelease,
19
+ native = nativeHandoverCommand,
20
+ } = {}) {
21
+ if (
22
+ !spec || typeof spec !== "object" || Array.isArray(spec) ||
23
+ Object.keys(spec).some((key) =>
24
+ ![
25
+ "platform",
26
+ "arch",
27
+ "sshHost",
28
+ "serviceUser",
29
+ "releaseRoot",
30
+ "expectedHostKey",
31
+ "tenant",
32
+ ].includes(key)
33
+ ) || !["linux", "darwin"].includes(spec.platform) ||
34
+ typeof spec.serviceUser !== "string" || spec.serviceUser === "root" ||
35
+ !/^[a-z_][a-z0-9_-]*$/i.test(spec.serviceUser) ||
36
+ !/^bbh-[0-9a-f]{24}$/.test(spec.expectedHostKey) ||
37
+ typeof spec.tenant !== "string" || !spec.tenant
38
+ ) fail("daemon_automatic_install_spec_invalid");
39
+ assertSshHost(spec.sshHost);
40
+ const release = await validateRelease({ releaseRoot: spec.releaseRoot }, {
41
+ platform: spec.platform,
42
+ arch: spec.arch,
43
+ });
44
+ const resolved = await resolveAuth({
45
+ toolName: "daemon_handover",
46
+ preferSession: false,
47
+ });
48
+ if (resolved?.error || !resolved?.auth) {
49
+ fail("daemon_automatic_install_auth_required");
50
+ }
51
+ const who = await identity("whoami", {}, {
52
+ auth: resolved.auth,
53
+ tenant: spec.tenant,
54
+ });
55
+ if (
56
+ !who?.ok || who.isError || typeof who.data?.owner_email !== "string" ||
57
+ !who.data.owner_email.includes("@") || who.data.tenant_id !== spec.tenant
58
+ ) fail("daemon_automatic_install_owner_unavailable");
59
+ const staged = await stage({
60
+ platform: spec.platform,
61
+ host: spec.sshHost,
62
+ user: spec.serviceUser,
63
+ release,
64
+ });
65
+ const binary = installedBinaryPath(release);
66
+ if (staged?.binary !== binary) fail("daemon_automatic_install_stage_invalid");
67
+ const result = await native({
68
+ host: spec.sshHost,
69
+ binary,
70
+ mode: "automatic-install",
71
+ input: {
72
+ host_key: spec.expectedHostKey,
73
+ owner_email: who.data.owner_email,
74
+ tenant_id: who.data.tenant_id,
75
+ },
76
+ });
77
+ if (result?.installed !== true || result.protocol !== 2) {
78
+ fail("daemon_automatic_install_receipt_invalid");
79
+ }
80
+ return { installed: true, protocol: 2, host_key: spec.expectedHostKey };
81
+ }
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { registerContainerHost } from "../api.mjs";
8
+ import { installAutomaticDaemonUpdater } from "./automatic.mjs";
8
9
  import { upgradeDaemonHost } from "./handover.mjs";
9
10
  import { awaitHostApproval } from "./approval-wait.mjs";
10
11
  import { bootstrapFreshDaemon } from "./bootstrap.mjs";
@@ -49,6 +50,7 @@ export const daemonHelp = `Usage:
49
50
  bb daemon config-render '<json host policy>'
50
51
  bb daemon enroll '<json host-bound enrollment spec>'
51
52
  bb daemon bootstrap '<json fresh-host bootstrap spec>'
53
+ bb daemon updater-install '<json automatic updater installation spec>'
52
54
  bb daemon upgrade '<json controlled host upgrade spec>'
53
55
 
54
56
  The Go payload is verified from the extracted universal package before sudo.
@@ -293,6 +295,7 @@ export async function cmdDaemon(args, {
293
295
  verifyRemoteHelperIdentity = verifyRemoteHelperIdentityOnTarget,
294
296
  bootstrap = bootstrapFreshDaemon,
295
297
  upgrade = upgradeDaemonHost,
298
+ installAutomaticUpdater = installAutomaticDaemonUpdater,
296
299
  registerHost = registerContainerHost,
297
300
  createBootstrapIdentity = createRemoteBootstrapIdentity,
298
301
  inspectBootstrapIdentity = inspectRemoteBootstrapIdentity,
@@ -305,6 +308,11 @@ export async function cmdDaemon(args, {
305
308
  log(daemonHelp);
306
309
  return 0;
307
310
  }
311
+ if(args[0]==="updater-install"){
312
+ if(args.length!==2)fail("Usage: bb daemon updater-install '<json automatic updater installation spec>'");
313
+ let spec;try{spec=JSON.parse(args[1]);}catch{fail("daemon_automatic_install_spec_invalid");}
314
+ log(JSON.stringify(await installAutomaticUpdater(spec)));return 0;
315
+ }
308
316
  if(args[0]==="upgrade"){
309
317
  if(args.length!==2)fail("Usage: bb daemon upgrade '<json controlled host upgrade spec>'");
310
318
  let spec;try{spec=JSON.parse(args[1]);}catch{fail("daemon_handover_spec_invalid");}
@@ -27,9 +27,9 @@ export function nativeHandoverCommand(
27
27
  { spawnImpl = spawn } = {},
28
28
  ) {
29
29
  if (
30
- !immutableBinaryRE.test(binary) || !["inspect", "submit"].includes(mode)
30
+ !immutableBinaryRE.test(binary) || !["inspect", "submit", "automatic-install"].includes(mode)
31
31
  ) fail("daemon_handover_command_invalid");
32
- const flag = mode === "inspect"
32
+ const flag = mode === "automatic-install" ? "--native-automatic-update-install" : mode === "inspect"
33
33
  ? "--native-handover-inspect"
34
34
  : "--native-handover-submit";
35
35
  let command = "sudo", args = ["-n", binary, flag];
@@ -263,6 +263,35 @@ export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars
263
263
  async function loadPool() { return import("../staging-pool/cli.mjs"); }
264
264
  async function loadBinding() { return import("../staging-pool/binding.mjs"); }
265
265
 
266
+ // BOT-2894: pool_empty is an agent-visible symptom, but recovery is owned by
267
+ // the control plane. This fixed call sends only the registered tenant/pool and
268
+ // persona; no host, path, command, credential or retry policy can cross it.
269
+ async function requestPoolRefill({ ctxPool, persona, deps, env }) {
270
+ try {
271
+ if (deps.requestPoolRefill) return await deps.requestPoolRefill({
272
+ tenant: ctxPool.tenant, poolId: ctxPool.poolId, persona,
273
+ });
274
+ const bin = ctxPool.installed?.botbuddyBin;
275
+ if (!bin || !ctxPool.tenant) return { ok: false, code: "POOL_REFILL_UNAVAILABLE" };
276
+ const run = deps.poolRefillExec ?? execFileSync;
277
+ const stdout = String(run(bin, ["call", "request_staging_pool_refill", "--pin", ctxPool.tenant, "--json", JSON.stringify({ pool_id: ctxPool.poolId, persona })], {
278
+ encoding: "utf8", timeout: 10_000, env, stdio: ["ignore", "pipe", "ignore"],
279
+ }));
280
+ const parsed = JSON.parse(stdout);
281
+ return {
282
+ ok: parsed?.ok === true,
283
+ ...(typeof parsed?.job_id === "string" ? { job_id: parsed.job_id } : {}),
284
+ ...(typeof parsed?.status === "string" ? { status: parsed.status } : {}),
285
+ ...(typeof parsed?.code === "string" ? { code: parsed.code } : {}),
286
+ };
287
+ } catch {
288
+ // Recovery request failure does not turn an already truthful pool_empty
289
+ // into a hidden success. The one wait/retry remains bounded and reports its
290
+ // original symptom; the next attempt will request recovery again.
291
+ return { ok: false, code: "POOL_REFILL_UNAVAILABLE" };
292
+ }
293
+ }
294
+
266
295
  /**
267
296
  * `auth --persona <alias>` after the wrapper has proven: environment=staging, a
268
297
  * lock-gated lane held by this exact agent, and a staging (or untracked) browser
@@ -316,13 +345,23 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
316
345
  claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions });
317
346
  } catch (error) {
318
347
  if (error?.code === "pool_empty") {
348
+ const refill = await requestPoolRefill({ ctxPool, persona: persona.email, deps, env });
319
349
  // The refusal's code is the pool's health reason when it is unhealthy (e.g. a
320
350
  // count-0 persona is persona_not_configured), so code, message and help agree.
321
351
  const reasonCode = typeof error.reason === "string" && error.reason !== "healthy" && isKnownCode(error.reason) ? error.reason : "pool_empty";
352
+ // A terminal refill receipt is a real, durable recovery boundary. Keep
353
+ // the old bounded timer only when the request could not return a job id.
354
+ const refillNext = refill.ok === true && typeof refill.job_id === "string"
355
+ ? `bb wait 'staging-pool-refill:id=${refill.job_id}' && ${vars.retry ?? authLine} # once; if it fails, report this code`
356
+ : undefined;
322
357
  // BOT-2836: a healthy empty pool is the agent's own wait-and-retry, so it is
323
358
  // rendered here with this lane; an unhealthy pool keeps the pool's health guidance.
324
359
  const forwarded = reasonCode === "pool_empty" ? ["reason", "detail"] : ["reason", "detail", ...GUIDED_FIELDS];
325
- return refuse(io, reasonCode, 3, Object.fromEntries(forwarded.filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
360
+ return refuse(io, reasonCode, 3, {
361
+ ...Object.fromEntries(forwarded.filter((key) => error[key] !== undefined).map((key) => [key, error[key]])),
362
+ ...(refillNext ? { next: refillNext } : {}),
363
+ automatic_refill: refill.ok === true ? { requested: true, ...(refill.job_id ? { job_id: refill.job_id } : {}), ...(refill.status ? { status: refill.status } : {}) } : { requested: false, code: refill.code },
364
+ }, vars);
326
365
  }
327
366
  const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
328
367
  return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, vars);
@@ -514,6 +514,7 @@ export async function runStagingPool(argv, deps = {}) {
514
514
  rawBinding,
515
515
  poolBinding,
516
516
  botbuddyBin,
517
+ nodeBin,
517
518
  intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
518
519
  existingPersonas: existing?.personas,
519
520
  tenant,
@@ -37,7 +37,7 @@ export function poolHomePaths({ home = homedir(), poolId, root } = {}) {
37
37
  }
38
38
 
39
39
  // The installed config: { version, binding: <raw .botbuddy-agent.json subset>,
40
- // personas: { email: count }, botbuddyBin, intervalSeconds }. Returns it with the
40
+ // personas: { email: count }, botbuddyBin, nodeBin, intervalSeconds }. Returns it with the
41
41
  // parsed binding attached as `poolBinding`.
42
42
  export function validateInstalledConfig(config) {
43
43
  if (!config || typeof config !== "object") throw new ConfigError("Pool config is not an object.", "config_invalid");
@@ -52,6 +52,11 @@ export function validateInstalledConfig(config) {
52
52
  if (typeof config.botbuddyBin !== "string" || !config.botbuddyBin.startsWith("/")) {
53
53
  throw new ConfigError("Pool config botbuddyBin must be an absolute path (re-run install).", "config_invalid");
54
54
  }
55
+ // `nodeBin` is added by BOT-2894. Older human-installed pools remain valid,
56
+ // but an autonomous Helper may only advertise a pool that pins its runtime.
57
+ if (config.nodeBin !== undefined && (typeof config.nodeBin !== "string" || !config.nodeBin.startsWith("/"))) {
58
+ throw new ConfigError("Pool config nodeBin must be an absolute path (re-run install).", "config_invalid");
59
+ }
55
60
  if (!Number.isInteger(config.intervalSeconds) || config.intervalSeconds < 60 || config.intervalSeconds > 3600) {
56
61
  throw new ConfigError("Pool config intervalSeconds must be an integer 60-3600.", "config_invalid");
57
62
  }
@@ -87,7 +92,7 @@ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
87
92
  }
88
93
 
89
94
  // rawBinding: { tenant, staging_session_pool } exactly as committed.
90
- export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, tenant, registry, installedAt }) {
95
+ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, nodeBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, tenant, registry, installedAt }) {
91
96
  const personas = {};
92
97
  for (const persona of Object.values(poolBinding.personas)) {
93
98
  personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
@@ -97,6 +102,7 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
97
102
  binding: { tenant: rawBinding.tenant, staging_session_pool: rawBinding.staging_session_pool },
98
103
  personas,
99
104
  botbuddyBin,
105
+ ...(nodeBin ? { nodeBin } : {}),
100
106
  intervalSeconds,
101
107
  // BOT-2648: installed from the tenant's control-plane registration.
102
108
  ...(tenant ? { tenant, poolId: poolBinding.poolId, source: "control-plane" } : {}),
@@ -91,7 +91,7 @@ export async function refreshExistingRuntime({ options, home, uid, env, exec, bu
91
91
  const contentDigest = hash.digest("hex");
92
92
  const runtimeName = `runtime-${metadata.version}-${contentDigest}`;
93
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 } });
94
+ const config = validateInstalledConfig({ ...installed, binding: rawBinding, personas, nodeBin: installed.nodeBin ?? await fs.realpath(process.execPath), 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
95
  const token = randomUUID();
96
96
  const lockPath = join(root, ".runtime-refresh.lock");
97
97
  const lock = await fs.open(lockPath, "wx", 0o600);
package/src/wait-core.mjs CHANGED
@@ -216,6 +216,15 @@ const VALIDATORS = {
216
216
  }
217
217
  return { runId };
218
218
  },
219
+ "staging-pool-refill": (p, raw) => {
220
+ const pair = /^\s*id\s*=\s*([0-9A-Fa-f-]+)\s*$/.exec(raw ?? "");
221
+ if (!pair || Object.keys(p).length !== 1 || p.id !== pair[1]) {
222
+ throw new Error("staging-pool-refill needs exactly one id=<refill job UUID>");
223
+ }
224
+ const jobId = pair[1].toLowerCase();
225
+ if (!CANONICAL_UUID_RE.test(jobId)) throw new Error("staging-pool-refill id must be a canonical UUID");
226
+ return { jobId };
227
+ },
219
228
  // BOT-2629: this is deliberately a dedicated condition rather than a raw
220
229
  // event escape hatch. The UUID is chosen before dispatch and the exact subject
221
230
  // prevents another run on the same idle/re-adopted lease waking this waiter.
@@ -1295,6 +1304,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1295
1304
  signal.subject_key === `run:${params.runId}` &&
1296
1305
  signal.payload?.run_id === params.runId &&
1297
1306
  COMMAND_RUN_TERMINAL_STATUSES.has(signal.payload?.status);
1307
+ case "staging-pool-refill":
1308
+ return signal.signal_type === "staging_pool_refill" &&
1309
+ signal.subject_key === `staging-pool-refill:${params.jobId}` &&
1310
+ signal.payload?.job_id === params.jobId &&
1311
+ ["completed", "failed"].includes(signal.payload?.status);
1298
1312
  case "stack-run":
1299
1313
  return signal.signal_type === "stack_run" && signal.subject_key === `stack-run:${params.id}` &&
1300
1314
  signal.payload?.run_id === params.id && Number.isInteger(signal.payload?.exit_code);
package/src/wait.mjs CHANGED
@@ -165,6 +165,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
165
165
  condition, and keep this wait's result pending: its
166
166
  return is what resumes your task. A run whose worker is
167
167
  lost ends owner_lost (bb run inspect <run_id>).
168
+ staging-pool-refill:id=<job UUID> BOT-2894: a requested staging-pool refill reaching
169
+ completed or failed. This is the only condition of its
170
+ wait and is available to each tenant agent that requested
171
+ the deduped job; its terminal receipt is safe to display.
168
172
  stack-run:id=<run UUID> one named bb stack run reaching its terminal outcome
169
173
  (BOT-2629). Choose the UUID BEFORE dispatch and pass it as
170
174
  bb stack run --run-id <UUID>; the receipt carries exit_code,
@@ -648,6 +652,8 @@ async function registerWait(opts, conditions, deadlineIso) {
648
652
  // back to an untracked wait would only hide that error behind a timeout.
649
653
  "command_run_unavailable",
650
654
  "command_run_single_condition_required",
655
+ "staging_pool_refill_unavailable",
656
+ "staging_pool_refill_single_condition_required",
651
657
  ]);
652
658
  if (INVALID_CONDITION_CODES.has(body.error)) {
653
659
  const err = new Error(body.detail || body.error);