@botbuddy/cli 1.62.5 → 1.63.0

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.62.5",
3
- "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
4
- "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
2
+ "release_version": "1.63.0",
3
+ "source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
4
+ "source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
5
5
  "target": "darwin-arm64",
6
- "sha256": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754"
6
+ "sha256": "5d57767abb24be43ba64e17b57d9aa396c4149f4b89101e6a0346da57ec5ea41"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.62.5",
3
- "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
4
- "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
2
+ "release_version": "1.63.0",
3
+ "source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
4
+ "source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
5
5
  "target": "linux-x64",
6
- "sha256": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2"
6
+ "sha256": "33e0e6ba075a7486a02e4a3638bce10e1df41ddfe8315685e285cf144a149ea7"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.62.5",
4
- "source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
5
- "source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
3
+ "release_version": "1.63.0",
4
+ "source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
5
+ "source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
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": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754",
11
+ "sha256": "5d57767abb24be43ba64e17b57d9aa396c4149f4b89101e6a0346da57ec5ea41",
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": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2",
18
+ "sha256": "33e0e6ba075a7486a02e4a3638bce10e1df41ddfe8315685e285cf144a149ea7",
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.62.5",
3
+ "version": "1.63.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,131 @@
1
+ // BOT-2738: live transport evidence belongs to the process holding the tunnel,
2
+ // not a connection file which can outlive it. This private Unix socket is a
3
+ // local capability only; cmdStatus still authenticates the lease server-side.
4
+ import { createServer, get } from "node:http";
5
+ import { chmod, lstat, mkdtemp, rm } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ export const FORWARD_ATTESTATION_ENV = "BOTBUDDY_STACK_FORWARD_ATTESTATION_SOCKET";
11
+ const failure = () => new Error("live stack forward attestation unavailable");
12
+
13
+ export function ownsLsofSocket(output, ownerPid, socketPath, platform = process.platform) {
14
+ if (!["darwin", "linux"].includes(platform)) return false;
15
+ // Linux's native n field appends the socket type; Darwin's does not.
16
+ // Match the complete dialect-specific field, never trim arbitrary suffixes:
17
+ // a literal " type=STREAM" may itself be part of an untrusted pathname.
18
+ const expected = `n${socketPath}${platform === "linux" ? " type=STREAM" : ""}`;
19
+ let ownedProcess = false;
20
+ for (const field of String(output ?? "").split("\n")) {
21
+ if (field.startsWith("p")) ownedProcess = field === `p${ownerPid}`;
22
+ else if (ownedProcess && field === expected) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ // A caller can replace an environment value or create its own socket. Require
28
+ // kernel evidence that this endpoint belongs to a live ancestor of the status
29
+ // process, rather than granting trust to the capability path or response alone.
30
+ function assertAncestorEndpoint(socketPath, ownerPid) {
31
+ if (!Number.isInteger(ownerPid) || ownerPid <= 1 || /[\r\n]/.test(socketPath)) throw failure();
32
+ let pid = process.ppid;
33
+ let found = false;
34
+ for (let depth = 0; depth < 32 && pid > 1; depth++) {
35
+ if (pid === ownerPid) { found = true; break; }
36
+ const parent = spawnSync("/bin/ps", ["-p", String(pid), "-o", "ppid="], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 });
37
+ if (parent.status !== 0 || !/^\s*\d+\s*$/.test(parent.stdout)) throw failure();
38
+ const next = Number(parent.stdout.trim());
39
+ if (next === pid) throw failure();
40
+ pid = next;
41
+ }
42
+ if (!found) throw failure();
43
+ const lsof = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
44
+ const held = spawnSync(lsof, ["-nP", "-a", "-U", "-p", String(ownerPid), "-F", "pn"], { encoding: "utf8", timeout: 2000, maxBuffer: 256 * 1024 });
45
+ if (held.status !== 0 || !ownsLsofSocket(held.stdout, ownerPid, socketPath)) throw failure();
46
+ }
47
+
48
+ function validForwards(rows) {
49
+ return Array.isArray(rows) && rows.length > 0 && rows.length <= 32
50
+ && rows.every((row) => typeof row?.name === "string" && row.name.length > 0 && row.name.length <= 64
51
+ && [row.local, row.remote].every((p) => Number.isInteger(p) && p > 0 && p <= 65535))
52
+ && ["name", "local", "remote"].every((key) => new Set(rows.map((r) => r[key])).size === rows.length);
53
+ }
54
+
55
+ export async function startForwardAttestation({ leaseId, sshTarget, forwards, isLive }) {
56
+ if (typeof leaseId !== "string" || !leaseId || typeof sshTarget !== "string" || !sshTarget
57
+ || !validForwards(forwards) || typeof isLive !== "function" || isLive() !== true) throw failure();
58
+ const snapshot = {
59
+ schema_version: 1, lease_id: leaseId, ssh_target: sshTarget, owner_pid: process.pid,
60
+ forwards: forwards.map(({ name, local, remote }) => ({ name, local, remote })),
61
+ };
62
+ const root = await mkdtemp(join(tmpdir(), "bb-f-"));
63
+ const socketPath = join(root, "f");
64
+ const server = createServer((request, response) => {
65
+ let allowed = false;
66
+ try {
67
+ const url = new URL(request.url, "http://local");
68
+ allowed = request.method === "GET" && url.pathname === "/forwards"
69
+ && url.searchParams.size === 1 && url.searchParams.get("lease_id") === leaseId && isLive() === true;
70
+ } catch { /* Unknown evidence is a refusal. */ }
71
+ response.writeHead(allowed ? 200 : 409, { "content-type": "application/json", "connection": "close" });
72
+ response.end(JSON.stringify(allowed ? snapshot : { error: "forward attestation unavailable" }));
73
+ });
74
+ try {
75
+ await chmod(root, 0o700);
76
+ await new Promise((resolve, reject) => {
77
+ server.once("error", reject);
78
+ server.listen(socketPath, resolve);
79
+ });
80
+ await chmod(socketPath, 0o600);
81
+ server.unref();
82
+ } catch {
83
+ server.closeAllConnections();
84
+ server.close();
85
+ await rm(root, { recursive: true, force: true });
86
+ throw failure();
87
+ }
88
+ let closed;
89
+ return {
90
+ socketPath,
91
+ close() {
92
+ return closed ??= (async () => {
93
+ server.closeAllConnections();
94
+ await new Promise((resolve) => server.close(resolve));
95
+ await rm(root, { recursive: true, force: true });
96
+ })();
97
+ },
98
+ };
99
+ }
100
+
101
+ export async function readForwardAttestation({ socketPath, leaseId, sshTarget }) {
102
+ if (!socketPath) return null;
103
+ try {
104
+ const [socket, directory] = await Promise.all([lstat(socketPath), lstat(dirname(socketPath))]);
105
+ if (!socket.isSocket() || !directory.isDirectory()
106
+ || socket.uid !== process.getuid() || directory.uid !== process.getuid()
107
+ || (socket.mode & 0o777) !== 0o600 || (directory.mode & 0o777) !== 0o700) throw failure();
108
+ const payload = await new Promise((resolve, reject) => {
109
+ const request = get({ socketPath, path: `/forwards?lease_id=${encodeURIComponent(leaseId)}` }, (response) => {
110
+ if (response.statusCode !== 200) { response.resume(); reject(failure()); return; }
111
+ let body = "";
112
+ response.setEncoding("utf8");
113
+ response.on("data", (chunk) => {
114
+ body += chunk;
115
+ if (body.length > 16 * 1024) request.destroy(failure());
116
+ });
117
+ response.on("error", reject);
118
+ response.on("end", () => {
119
+ try { resolve(JSON.parse(body)); } catch { reject(failure()); }
120
+ });
121
+ });
122
+ const deadline = setTimeout(() => request.destroy(failure()), 2000);
123
+ request.on("close", () => clearTimeout(deadline));
124
+ request.on("error", reject);
125
+ });
126
+ if (payload?.schema_version !== 1 || payload.lease_id !== leaseId
127
+ || payload.ssh_target !== sshTarget || !validForwards(payload.forwards)) throw failure();
128
+ assertAncestorEndpoint(socketPath, payload.owner_pid);
129
+ return payload.forwards;
130
+ } catch { throw failure(); }
131
+ }
@@ -286,6 +286,7 @@ export async function openSshForwards({
286
286
  }
287
287
  return {
288
288
  pid: child.pid,
289
+ isLive: () => !exited && !closing,
289
290
  argv,
290
291
  exited: exitPromise,
291
292
  onExit: (fn) => { listeners.push(fn); if (exited && !closing) fn({ ...exited, stderr: stderrText() }); },
package/src/stack.mjs CHANGED
@@ -49,6 +49,7 @@ import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
49
49
  import { machineUuid } from "./machine-id.mjs";
50
50
  import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
51
51
  import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
52
+ import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestation } from "./stack-forward-attestation.mjs";
52
53
  import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
53
54
  import { bold, dim, yellow } from "./utils.mjs";
54
55
  import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
@@ -108,7 +109,8 @@ export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch
108
109
 
109
110
  ${bold("USAGE")}
110
111
  botbuddy stack up [options] Request a lease; park if the host is full; hold it active
111
- botbuddy stack status <lease_id> Show a lease's state + connection
112
+ botbuddy stack status <lease_id> Show a lease's state + connection; within a live remote run,
113
+ include the owner's attested local/remote forwards
112
114
  botbuddy stack touch <lease_id> Bump the idle clock (and report measured use) so the reaper doesn't stop it
113
115
  botbuddy stack done <lease_id> Release (done): idle the stack for re-adoption (--reap tears it down)
114
116
  botbuddy stack cancel <lease_id> Give a queued lease's place up (= done --reap; BOT-2400)
@@ -2960,6 +2962,7 @@ export async function cmdUp(opts, {
2960
2962
 
2961
2963
  export async function cmdStatus(leaseId, opts, {
2962
2964
  rpc = retryingStackCall(), emitResult = emit, stderr = (line) => process.stderr.write(`${line}\n`),
2965
+ forwardSocket = process.env[FORWARD_ATTESTATION_ENV],
2963
2966
  } = {}) {
2964
2967
  const auth = await rpc.init();
2965
2968
  if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
@@ -2969,6 +2972,14 @@ export async function cmdStatus(leaseId, opts, {
2969
2972
  : emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
2970
2973
  const g = got.data;
2971
2974
  if (!g.success) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
2975
+ let forwards;
2976
+ if (g.state === "active" && forwardSocket) {
2977
+ try {
2978
+ forwards = await readForwardAttestation({ socketPath: forwardSocket, leaseId, sshTarget: g.connection?.ssh_target ?? g.ssh_target });
2979
+ } catch {
2980
+ return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: "FORWARD_ATTESTATION_UNAVAILABLE", error: "live stack forward attestation unavailable" }), opts, EXIT.BACKEND);
2981
+ }
2982
+ }
2972
2983
  // BOT-2019: a queued lease names its server dispatch rank (or why it has none) and the
2973
2984
  // ticket it is scored under; a null position is reported, never a crash.
2974
2985
  const queued = g.state === "queued";
@@ -2976,7 +2987,9 @@ export async function cmdStatus(leaseId, opts, {
2976
2987
  if (queued) for (const line of describeUnitLedger(g.ledger)) stderr(`stack: ${line}`);
2977
2988
  return emitResult(buildReceipt({
2978
2989
  command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
2979
- host_key: g.host_key, slot: g.slot, connection: g.connection,
2990
+ host_key: g.host_key, slot: g.slot,
2991
+ connection: forwards ? { ...g.connection, ssh_target: g.connection?.ssh_target ?? g.ssh_target } : g.connection,
2992
+ ...(forwards ? { forwards } : {}),
2980
2993
  ...(queued ? queueReceiptFields(g) : { queue_position: g.queue_position }),
2981
2994
  idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
2982
2995
  // BOT-2099 (Codex #1057 R3): machine callers get the unit ledger of a queued lease too.
@@ -3621,6 +3634,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3621
3634
  let activeWaitAbort = null;
3622
3635
  // BOT-2142: the remote lease's forwards, and whether they died under a running child.
3623
3636
  let tunnel = null;
3637
+ let forwardAttestation = null;
3624
3638
  let tunnelLost = null;
3625
3639
  const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
3626
3640
  incidentBox.execution = placement;
@@ -3633,10 +3647,19 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3633
3647
  // BOT-1803: a heartbeat touch found the lease preempted under the running child.
3634
3648
  let preempted = null;
3635
3649
  const closeTunnel = async () => {
3636
- if (!tunnel) return;
3637
- const open = tunnel;
3638
- tunnel = null;
3639
- try { await open.close(); } catch { /* best effort: the process is gone either way */ }
3650
+ try {
3651
+ if (forwardAttestation) {
3652
+ const owner = forwardAttestation;
3653
+ forwardAttestation = null;
3654
+ await owner.close();
3655
+ }
3656
+ } finally {
3657
+ if (tunnel) {
3658
+ const open = tunnel;
3659
+ tunnel = null;
3660
+ try { await open.close(); } catch { /* best effort: the process is gone either way */ }
3661
+ }
3662
+ }
3640
3663
  };
3641
3664
 
3642
3665
  // BOT-1803: `idle` (only the normal end of a run) asks the server to idle the ACTIVE lease
@@ -4352,13 +4375,17 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4352
4375
  placement.transport = "ssh-forward";
4353
4376
  placement.forwards = tunnel.forwards.map(({ name, remote, local }) => ({ name, remote, local }));
4354
4377
  for (const f of tunnel.forwards.filter((x) => x.remapped)) stderrLine(`stack: forward ${f.name} remapped: local ${f.local} → ${placement.hostKey ?? "remote"} ${f.remote}`);
4355
- childConnection = tunnel.connection;
4378
+ childConnection = { ...tunnel.connection, ssh_target: sshTarget };
4356
4379
  const forwardTarget = sshTarget;
4357
4380
  tunnel.onExit((exit) => {
4358
4381
  if (childResult) return;
4359
4382
  tunnelLost = { ...exit, target: forwardTarget };
4360
4383
  stopChild("SIGTERM", EXIT.LEASE_FAILED);
4361
4384
  });
4385
+ forwardAttestation = await startForwardAttestation({
4386
+ leaseId, sshTarget, forwards: placement.forwards,
4387
+ isLive: () => Boolean(tunnel && !tunnelLost && !receivedSignal && tunnel.isLive()),
4388
+ });
4362
4389
  }
4363
4390
  connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), childConnection);
4364
4391
  if (receivedSignal) {
@@ -4376,6 +4403,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4376
4403
  ...process.env,
4377
4404
  BOTBUDDY_STACK_LEASE_ID: leaseId,
4378
4405
  BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
4406
+ // A nested co-located run must not inherit its parent's capability.
4407
+ [FORWARD_ATTESTATION_ENV]: forwardAttestation?.socketPath ?? "",
4379
4408
  // BOT-2142: lets a lane's own checks (e.g. the port-owner preflight) know the
4380
4409
  // listeners are SSH forwards to another host, not this machine's Docker.
4381
4410
  BOTBUDDY_STACK_EXECUTION_MODE: servedRemote ? "remote" : (placement.mode ?? "local"),