@botbuddy/cli 1.47.0 → 1.47.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.47.0",
3
+ "version": "1.47.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -353,7 +353,7 @@ export async function resolveCallAuth({
353
353
  if (typeof session.token !== "string" || !AGENT_KEY_RE.test(session.token)) {
354
354
  return { error: { ok: false, auth: true, error: "session_token_malformed", source: session.source ?? null } };
355
355
  }
356
- return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null };
356
+ return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null, sessionToken: true };
357
357
  }
358
358
  }
359
359
  const owner = await resolveOwner();
@@ -392,12 +392,20 @@ function toolEndpoint(tenant) {
392
392
  return url.toString();
393
393
  }
394
394
 
395
- export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
395
+ export async function callToolJson(toolName, args = {}, {
396
+ fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin,
397
+ requireSessionToken = false, resolveAuth = resolveCallAuth,
398
+ } = {}) {
396
399
  let auth = pinnedAuth;
400
+ let sessionTokenSelected = false;
397
401
  if (!auth) {
398
- const resolved = await resolveCallAuth();
402
+ const resolved = await resolveAuth({ toolName, preferSession: requireSessionToken });
399
403
  if (resolved.error) return resolved.error;
400
404
  auth = resolved.auth;
405
+ sessionTokenSelected = resolved.sessionToken === true;
406
+ }
407
+ if (requireSessionToken && !sessionTokenSelected) {
408
+ return { ok: false, auth: true, error: "session_token_required" };
401
409
  }
402
410
  // BOT-1571 (Codex P1, round 2): `botbuddy stack` and every other
403
411
  // callToolJson caller that passes no explicit tenant would send a USER token
package/src/commands.mjs CHANGED
@@ -13,6 +13,7 @@ import { agentProfileKeyLabel } from "./credential-kinds.mjs";
13
13
  import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
14
14
  import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
15
15
  import { cmdStack } from "./stack.mjs";
16
+ import { cmdManagedAllocation } from "./managed-allocation.mjs";
16
17
  import { cmdDocker } from "./docker-hygiene.mjs";
17
18
  import { cmdRun } from "./run.mjs";
18
19
  import { cmdTest } from "./test-lane.mjs";
@@ -59,6 +60,7 @@ export async function run(argv, {
59
60
  warnStale = maybeWarnStale,
60
61
  errorLog = (line) => console.error(line),
61
62
  bootstrap = cmdBootstrap,
63
+ managedAllocation = cmdManagedAllocation,
62
64
  } = {}) {
63
65
  const [command, ...args] = argv;
64
66
  // BOT-2069: `bootstrap close` can retire a live server session. Parse its
@@ -89,6 +91,7 @@ export async function run(argv, {
89
91
  case "unlock": return cmdUnlock(args);
90
92
  // BOT-1220: batch-scoped local stack leases (request/park/status/touch/done).
91
93
  case "stack": return cmdStack(args);
94
+ case "allocation": return managedAllocation(args);
92
95
  case "docker": return cmdDocker(args);
93
96
  case "run": return cmdRun(args);
94
97
  case "telemetry": return cmdTelemetry(args);
@@ -209,6 +212,7 @@ ${bold("STACK LEASES")}
209
212
  stack touch <lease_id> Bump the lease idle clock
210
213
  stack done <lease_id> Release (done) a lease — reap the stack
211
214
  stack help Full stack usage + exit codes
215
+ allocation status <id> <generation> <target_revision> Verify managed placement before Compose
212
216
 
213
217
  ${bold("DOCKER HYGIENE")}
214
218
  docker hygiene [options] Dry-run/apply safe stale Supabase cleanup on OrbStack
@@ -0,0 +1,77 @@
1
+ import { callToolJson, CALL_EXIT } from "./api.mjs";
2
+ import { readMachineUuid } from "./machine-id.mjs";
3
+
4
+ export function parseManagedAllocationArgs(args) {
5
+ if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) return { help: true };
6
+ if (args.length !== 4 || args[0] !== "status") {
7
+ return { error: "Usage: bb allocation status <allocation_uuid> <generation> <target_revision>" };
8
+ }
9
+ const [, id, rawGeneration, rawRevision] = args;
10
+ const uuid = String(id).trim().toLowerCase();
11
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid)) {
12
+ return { error: "allocation id must be a canonical UUID" };
13
+ }
14
+ const generation = Number(rawGeneration);
15
+ const targetRevision = Number(rawRevision);
16
+ if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
17
+ return { error: "generation and target_revision must be positive integers" };
18
+ }
19
+ return { command: "status", allocationId: uuid, generation, targetRevision };
20
+ }
21
+
22
+ export function evaluateLocalStartEligibility(receipt, localMachineUuid) {
23
+ const server = receipt?.server_start_eligibility;
24
+ if (server?.eligible !== true) return { eligible: false, reason: server?.reason ?? "server_placement_unverified" };
25
+ if (typeof localMachineUuid !== "string" || !localMachineUuid.trim()) {
26
+ return { eligible: false, reason: "local_machine_identity_unavailable" };
27
+ }
28
+ const selected = receipt?.placement?.hardware_uuid;
29
+ if (typeof selected !== "string" || !selected.trim()) {
30
+ return { eligible: false, reason: "machine_binding_unavailable" };
31
+ }
32
+ if (selected.trim().toLowerCase() !== localMachineUuid.trim().toLowerCase()) {
33
+ return { eligible: false, reason: "selected_machine_mismatch" };
34
+ }
35
+ return { eligible: true, reason: null };
36
+ }
37
+
38
+ export const MANAGED_ALLOCATION_HELP = `bb allocation status <allocation_uuid> <generation> <target_revision>
39
+
40
+ Read the exact managed-container placement and cleanup receipt using the current
41
+ bb_agent_ work-session token, then compare its enrolled machine binding with this
42
+ machine's OS-reported identity. An active registered session is required. This
43
+ command is read-only; it never starts Docker or Compose.`;
44
+
45
+ export async function cmdManagedAllocation(args, {
46
+ call = callToolJson,
47
+ machineUuid = () => readMachineUuid({ env: {} }),
48
+ log = (line) => process.stdout.write(`${line}\n`),
49
+ errorLog = (line) => process.stderr.write(`${line}\n`),
50
+ } = {}) {
51
+ const parsed = parseManagedAllocationArgs(args);
52
+ if (parsed.help) { log(MANAGED_ALLOCATION_HELP); return 0; }
53
+ if (parsed.error) { errorLog(parsed.error); return 4; }
54
+ const response = await call("get_managed_container_allocation_status", {
55
+ allocation_id: parsed.allocationId,
56
+ generation: parsed.generation,
57
+ target_revision: parsed.targetRevision,
58
+ }, { requireSessionToken: true });
59
+ if (!response?.ok || response.isError) {
60
+ const sessionRequired = response?.error === "session_token_required";
61
+ const payload = { schema_version: 1, outcome: "error",
62
+ code: response?.code ?? response?.error_code ?? response?.data?.code ?? (sessionRequired ? "session_token_required" : response?.isError ? "allocation_status_denied" : "status_unavailable"),
63
+ error: response?.error ?? response?.data?.error ?? (response?.isError ? "managed allocation status was refused by the server" : "managed allocation status unavailable") };
64
+ log(JSON.stringify(payload));
65
+ return response?.auth ? CALL_EXIT.AUTH : response?.isError ? CALL_EXIT.ERROR
66
+ : response?.status == null || response.status >= 500 ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
67
+ }
68
+ const receipt = response.data;
69
+ const result = {
70
+ ...receipt,
71
+ local_start_eligibility: evaluateLocalStartEligibility(receipt, machineUuid()),
72
+ };
73
+ log(JSON.stringify(result));
74
+ // Keep shell chaining safe: a status receipt that shows the wrong/unknown
75
+ // machine must not look like a successful preflight to `&&` callers.
76
+ return result.local_start_eligibility.eligible ? 0 : CALL_EXIT.ERROR;
77
+ }
package/src/wait-core.mjs CHANGED
@@ -28,6 +28,9 @@ export const EXIT = Object.freeze({
28
28
  // reap_failed) before it was granted — re-request the lease. BOT-1754 reserves the name
29
29
  // LEASE_LOST and forbids reusing 2-8; 9 is the first free code.
30
30
  LEASE_LOST: 9,
31
+ // BOT-2330: an exact managed-allocation cleanup failed. The waiter must not
32
+ // report a successful terminal match when the signed Helper outcome failed.
33
+ MANAGED_ALLOCATION_FAILED: 10,
31
34
  });
32
35
 
33
36
  // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
@@ -50,6 +53,16 @@ export function leaseLostDetail(signal, condition) {
50
53
  };
51
54
  }
52
55
 
56
+ function managedAllocationFailureDetail(signal, condition) {
57
+ if (condition?.type !== "managed-allocation" || signal?.signal_type !== "managed_allocation" || signal.payload?.state !== "cleanup_failed") return null;
58
+ return {
59
+ allocation_id: signal.payload.allocation_id,
60
+ generation: signal.payload.generation,
61
+ target_revision: signal.payload.target_revision,
62
+ cleanup_error: typeof signal.payload.cleanup_error === "string" ? signal.payload.cleanup_error : "cleanup_failed",
63
+ };
64
+ }
65
+
53
66
  // BOT-1249: the internal CONTROL signal_type the reconciler emits when a wait's
54
67
  // resource identity is canonicalized away. runWaitLoop treats a wait_superseded frame
55
68
  // targeting its OWN wait_session as terminal (exit 8) before matchFrame sees it. It is
@@ -190,6 +203,24 @@ const VALIDATORS = {
190
203
  const id = hex ? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` : raw;
191
204
  return { id };
192
205
  },
206
+ // BOT-2330: terminal state for one owner/session-bound managed allocation.
207
+ // Both fences are required so a stale wait cannot observe a later reuse.
208
+ "managed-allocation"(p) {
209
+ const unexpected = Object.keys(p).filter((key) => !["id", "generation", "target_revision"].includes(key));
210
+ if (unexpected.length) throw new Error(`managed-allocation does not accept ${unexpected.join(", ")}`);
211
+ const id = String(p.id ?? "").trim().toLowerCase();
212
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
213
+ throw new Error("managed-allocation needs id=<canonical allocation UUID>");
214
+ }
215
+ const generation = Number(p.generation);
216
+ const targetRevision = Number(p.target_revision);
217
+ if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
218
+ throw new Error("managed-allocation needs positive integer generation=<n> and target_revision=<n>");
219
+ }
220
+ // Keep the condition parameters in the event-stream wire shape. The server
221
+ // validates `target_revision` before it normalizes this saved condition.
222
+ return { id, generation, target_revision: targetRevision };
223
+ },
193
224
  // BOT-1064 / BOT-2112 — linear: wake on a logical state change of a named
194
225
  // issue in any Linear team bound to the authenticated tenant.
195
226
  // The spine subject_key is the issue identifier as stored on the tickets
@@ -933,6 +964,14 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
933
964
  // →active). The relay only delivers it to the lease's owning agent, so any
934
965
  // stack_lease frame for this lease id is ours to wake on.
935
966
  return signal.signal_type === "stack_lease" && signal.subject_key === `lease:${params.id}`;
967
+ case "managed-allocation": {
968
+ const p = signal.payload;
969
+ return signal.signal_type === "managed_allocation" && signal.subject_key === `allocation:${params.id}` &&
970
+ p?.allocation_id === params.id && p?.generation === params.generation &&
971
+ p?.target_revision === params.target_revision &&
972
+ ((p?.state === "released" && p?.permit_count === 0 && p?.cleanup_receipt_accepted === true) ||
973
+ (p?.state === "cleanup_failed" && p?.cleanup_receipt_accepted === true));
974
+ }
936
975
  case "linear": {
937
976
  if (signal.signal_type !== "linear_issue") return false;
938
977
  // Identity (BOT-1260): prefer the STABLE Linear issue UUID when BOTH the wait
@@ -1355,10 +1394,15 @@ export async function runWaitLoop({
1355
1394
  // reaped: that wait ends lease_lost (BOT-2013) so the caller can re-request instead of
1356
1395
  // provisioning against a dead lease.
1357
1396
  const finalizeMatch = async (entry, signal) => {
1358
- const lost = leaseLostDetail(signal, conditions.find((c) => c.id === entry.condition_id));
1397
+ const condition = conditions.find((c) => c.id === entry.condition_id);
1398
+ const lost = leaseLostDetail(signal, condition);
1359
1399
  if (lost) {
1360
1400
  return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
1361
1401
  }
1402
+ const allocationFailure = managedAllocationFailureDetail(signal, condition);
1403
+ if (allocationFailure) {
1404
+ return finalize("error", { exitCode: EXIT.MANAGED_ALLOCATION_FAILED, matched: [entry], state: allocationFailure, error: allocationFailure.cleanup_error });
1405
+ }
1362
1406
  return finalize("matched", { exitCode: EXIT.MATCHED, matched: [entry] });
1363
1407
  };
1364
1408