@botbuddy/cli 1.62.4 → 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.4",
3
- "source_commit": "1cc371424a95b39915a3e882a428688bced7386a",
4
- "source_sha256": "8d9dd0daf489c418b04d34b57696a6f97a9346d141f9266dda3a74e7ca87d4cf",
2
+ "release_version": "1.63.0",
3
+ "source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
4
+ "source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
5
5
  "target": "darwin-arm64",
6
- "sha256": "4ce65712c580403552a1d15f9dce56149dfdf48d121c634a6f5c748cb48e85fd"
6
+ "sha256": "5d57767abb24be43ba64e17b57d9aa396c4149f4b89101e6a0346da57ec5ea41"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.62.4",
3
- "source_commit": "1cc371424a95b39915a3e882a428688bced7386a",
4
- "source_sha256": "8d9dd0daf489c418b04d34b57696a6f97a9346d141f9266dda3a74e7ca87d4cf",
2
+ "release_version": "1.63.0",
3
+ "source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
4
+ "source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
5
5
  "target": "linux-x64",
6
- "sha256": "4993e29705d75254afd21fadcbef568faf8650471df1bbcd9a96a6c5d3d2a254"
6
+ "sha256": "33e0e6ba075a7486a02e4a3638bce10e1df41ddfe8315685e285cf144a149ea7"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.62.4",
4
- "source_commit": "1cc371424a95b39915a3e882a428688bced7386a",
5
- "source_sha256": "8d9dd0daf489c418b04d34b57696a6f97a9346d141f9266dda3a74e7ca87d4cf",
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": "4ce65712c580403552a1d15f9dce56149dfdf48d121c634a6f5c748cb48e85fd",
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": "4993e29705d75254afd21fadcbef568faf8650471df1bbcd9a96a6c5d3d2a254",
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.4",
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
+ }
@@ -20,8 +20,9 @@
20
20
  // Supply Guard's `scripts/local-supabase-isolated.sh`, ported here because the
21
21
  // published CLI cannot import repo scripts and stays Node-stdlib only.
22
22
  import { createHash, randomUUID } from "node:crypto";
23
+ import { spawnSync } from "node:child_process";
23
24
  import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
- import { homedir, hostname } from "node:os";
25
+ import { homedir, hostname, platform } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
26
27
  import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
27
28
 
@@ -45,6 +46,75 @@ export const LEASED_PORT_STRIDE = 20;
45
46
  * never a silently shared stack. */
46
47
  export const LEASED_PORT_SLOTS = 200;
47
48
 
49
+ // BOT-2490: identical hint name, epoch-millisecond format and ten-minute window
50
+ // to Helper ServingPortAllocation.swift. Hints never override a live claim.
51
+ const RELEASE_COOLDOWN_PREFIX = ".helper-released-";
52
+ const RELEASE_COOLDOWN_MS = 600_000;
53
+
54
+ /** Actual host TCP listeners, not merely the managed claim registry. */
55
+ export function listeningTcpPorts({ run = spawnSync } = {}) {
56
+ const fail = (detail) => Object.assign(new Error(`refusing port allocation: lsof host TCP listener census failed (${detail}). Verify lsof is installed and can enumerate TCP listeners.`), { code: "stack_port_census_failed" });
57
+ let result;
58
+ try {
59
+ result = run(platform() === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "n"], { encoding: "utf8", timeout: 10_000 });
60
+ } catch (error) { throw fail(error?.code ?? "lsof unavailable"); }
61
+ const { status, stdout, stderr, error } = result ?? {};
62
+ if (error) throw fail(error.code ?? "lsof execution error");
63
+ if (status === 1 && stdout === "" && stderr === "") return new Set();
64
+ if (status !== 0 || typeof stdout !== "string") throw fail(`lsof exit ${status ?? "unknown"}`);
65
+ // An exit-zero partial listing with diagnostics cannot establish that the
66
+ // omitted ports are free. Never turn it into a successful census.
67
+ if (typeof stderr !== "string" || stderr.trim()) throw fail("lsof diagnostics or unavailable stderr");
68
+ const ports = new Set();
69
+ for (const line of stdout.split(/\r?\n/)) {
70
+ if (!line) continue;
71
+ if (!line.startsWith("n")) {
72
+ // The captured `-F n` counterpart and lsof's field-output contract have
73
+ // mandatory process/file records in addition to the requested names.
74
+ if (!/^[pf]/.test(line)) throw fail("unexpected census record");
75
+ continue;
76
+ }
77
+ const colon = line.lastIndexOf(":");
78
+ if (colon < 2) throw fail("malformed listener record");
79
+ const token = line.slice(colon + 1);
80
+ const port = /^\d+$/.test(token) ? Number(token) : NaN;
81
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw fail("malformed listener record");
82
+ ports.add(port);
83
+ }
84
+ if (ports.size === 0) throw fail("incomplete listener census");
85
+ return ports;
86
+ }
87
+
88
+ function releaseCooldowns(registryDir, { readdir, read, now }) {
89
+ const cooling = new Map();
90
+ const fail = (operation, error) => Object.assign(new Error(`refusing port allocation: released-block cooldown ${operation} failed (${error?.code ?? "read error"}). Restore access to the port registry before retrying.`), { code: "stack_port_cooldown_failed" });
91
+ let names;
92
+ try { names = readdir(registryDir); } catch (error) { throw fail("enumeration", error); }
93
+ for (const name of names) {
94
+ if (!name.startsWith(RELEASE_COOLDOWN_PREFIX)) continue;
95
+ const token = name.slice(RELEASE_COOLDOWN_PREFIX.length);
96
+ const base = /^\d+$/.test(token) ? Number(token) : NaN;
97
+ // Atomic-write temporary files and non-candidate names are not cooldown
98
+ // evidence. Validate before I/O so irrelevant unreadable files cannot block.
99
+ if (!Number.isInteger(base) || base < LEASED_PORT_BASE || base >= LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE || (base - LEASED_PORT_BASE) % LEASED_PORT_STRIDE !== 0) continue;
100
+ let raw;
101
+ try { raw = read(join(registryDir, name), "utf8").trim(); } catch (error) { throw fail("record read", error); }
102
+ const released = raw === "" ? NaN : Number(raw);
103
+ if (Number.isInteger(base) && Number.isFinite(released) && now() - released < RELEASE_COOLDOWN_MS) cooling.set(base, released);
104
+ }
105
+ return cooling;
106
+ }
107
+
108
+ function recordReleaseCooldown(registryDir, base, { write, rename, now, warn }) {
109
+ try {
110
+ atomicWrite(join(registryDir, `${RELEASE_COOLDOWN_PREFIX}${base}`), String(now()), { write, rename });
111
+ return true;
112
+ } catch (error) {
113
+ warn(`stack: could not record released port-block cooldown ${base}: ${error?.code ?? "write failed"}`);
114
+ return false;
115
+ }
116
+ }
117
+
48
118
  /** How long a leased stack directory must have been untouched before another run
49
119
  * may collect it. Comfortably above the reaper's default 1800 s idle TTL, so a
50
120
  * batch whose owner process died but whose child/containers are still finishing is
@@ -930,6 +1000,12 @@ export function blocksForPorts(ports) {
930
1000
  export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortRegistryDir(), io = {} } = {}) {
931
1001
  const read = io.readFile ?? readFileSync;
932
1002
  const remove = io.remove ?? rmSync;
1003
+ const write = io.writeFile ?? writeFileSync;
1004
+ const rename = io.rename ?? renameSync;
1005
+ const now = io.now ?? Date.now;
1006
+ const alive = io.alive ?? pidIsAlive;
1007
+ const host = io.hostname ?? hostname;
1008
+ const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
933
1009
  const marker = readStackMarker(stackDir, read);
934
1010
  if (!marker || !Number.isInteger(marker.port_base)) return false;
935
1011
  const path = portClaimPath(registryDir, marker.port_base);
@@ -938,8 +1014,13 @@ export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortReg
938
1014
  const owner = marker.invocation_token ?? marker.project_id;
939
1015
  if (!claim || (claim.invocation_token ?? claim.project_id) !== owner) return false;
940
1016
  try {
941
- remove(path, { force: true });
942
- return true;
1017
+ return withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep: io.sleep }, () => {
1018
+ const current = JSON.parse(read(path, "utf8"));
1019
+ if ((current?.invocation_token ?? current?.project_id) !== owner) return false;
1020
+ if (!recordReleaseCooldown(registryDir, marker.port_base, { write, rename, now, warn: io.warn ?? console.warn })) return false;
1021
+ remove(path, { force: true });
1022
+ return true;
1023
+ });
943
1024
  } catch {
944
1025
  return false;
945
1026
  }
@@ -1077,14 +1158,28 @@ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(
1077
1158
  /** Returns whether none of this invocation's claims remain (BOT-2237: a verified result). */
1078
1159
  release: () => {
1079
1160
  let allReleased = true;
1161
+ // Do not create an allocation lock when the registry is unreadable: its
1162
+ // ownership could not be re-read for safe unlock either. Re-check under
1163
+ // the lock below before any unlink, so this is not an ownership shortcut.
1080
1164
  for (const block of claimed) {
1081
- let current = null;
1082
- // Codex #1168 R2 P2: a claim that is gone (ENOENT) is released; one that cannot be read
1083
- // or parsed is unverifiable — never unlinked blindly, and never reported as released.
1084
- try { current = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch (error) { if (error?.code !== "ENOENT") allReleased = false; continue; }
1085
- if (current?.invocation_token !== invocationToken) continue; // superseded: never unlink blindly
1086
- try { remove(portClaimPath(registryDir, block), { force: true }); } catch { allReleased = false; }
1165
+ try { JSON.parse(read(portClaimPath(registryDir, block), "utf8")); }
1166
+ catch (error) { if (error?.code !== "ENOENT") return false; }
1087
1167
  }
1168
+ try { withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
1169
+ for (const block of claimed) {
1170
+ let current = null;
1171
+ // A missing claim is released; unreadable/corrupt is unverifiable.
1172
+ try { current = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch (error) { if (error?.code !== "ENOENT") allReleased = false; continue; }
1173
+ if (current?.invocation_token !== invocationToken) continue; // never unlink a superseding claim
1174
+ try {
1175
+ if (!recordReleaseCooldown(registryDir, block, { write, rename: io.rename ?? renameSync, now, warn: io.warn ?? console.warn })) {
1176
+ allReleased = false;
1177
+ continue;
1178
+ }
1179
+ remove(portClaimPath(registryDir, block), { force: true });
1180
+ } catch { allReleased = false; }
1181
+ }
1182
+ }); } catch { allReleased = false; }
1088
1183
  return allReleased;
1089
1184
  },
1090
1185
  };
@@ -1225,16 +1320,20 @@ function portClaimPath(registryDir, portBase) {
1225
1320
  return join(registryDir, `${portBase}.json`);
1226
1321
  }
1227
1322
 
1228
- /** The first free block at or after the identity's own hash-derived one. */
1229
- function selectPortBase(preferred, taken) {
1323
+ /** Prefer non-cooling free blocks in hash order; otherwise oldest release first. */
1324
+ function selectPortBase(preferred, taken, cooling = new Map()) {
1230
1325
  const start = Math.floor((preferred - LEASED_PORT_BASE) / LEASED_PORT_STRIDE);
1326
+ let oldest = null;
1231
1327
  for (let i = 0; i < LEASED_PORT_SLOTS; i++) {
1232
1328
  const candidate = LEASED_PORT_BASE + ((start + i) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
1233
- if (!taken.has(candidate)) return candidate;
1329
+ if (taken.has(candidate)) continue;
1330
+ if (!cooling.has(candidate)) return candidate;
1331
+ if (oldest === null || cooling.get(candidate) < cooling.get(oldest)) oldest = candidate;
1234
1332
  }
1333
+ if (oldest !== null) return oldest;
1235
1334
  throw new Error(
1236
1335
  `refusing to materialize an isolated leased stack: all ${LEASED_PORT_SLOTS} leased port blocks ` +
1237
- `(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks. ` +
1336
+ `(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks or host listeners. ` +
1238
1337
  "Finish or reap a batch before starting another.",
1239
1338
  );
1240
1339
  }
@@ -1659,6 +1758,9 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1659
1758
  const previous = dirExisted ? readStackMarker(stackDir, read) : null;
1660
1759
  const previousBlock = previous?.project_id === identity.projectId && Number.isInteger(previous?.port_base) ? previous.port_base : null;
1661
1760
  const portBase = withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
1761
+ const listening = new Set(blocksForPorts((io.listeningPorts ?? listeningTcpPorts)()));
1762
+ const cooling = releaseCooldowns(registryDir, { readdir, read, now });
1763
+ const raced = new Set();
1662
1764
  let preferred = previousBlock ?? identity.portBase;
1663
1765
  for (let attempt = 0; attempt < LEASED_PORT_SLOTS; attempt++) {
1664
1766
  const stale = new Set();
@@ -1669,7 +1771,17 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1669
1771
  for (const block of stale) {
1670
1772
  try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
1671
1773
  }
1672
- const candidate = selectPortBase(preferred, taken);
1774
+ // A running stack necessarily owns listeners. Only this exact retained
1775
+ // invocation may re-adopt its block; a merely matching project is not proof.
1776
+ let own = null;
1777
+ try { own = JSON.parse(read(portClaimPath(registryDir, preferred), "utf8")); } catch { /* new allocation */ }
1778
+ if (own?.invocation_token === invocationToken && own.project_id === identity.projectId) {
1779
+ write(portClaimPath(registryDir, preferred), marker(preferred));
1780
+ return preferred;
1781
+ }
1782
+ for (const block of listening) if (!taken.has(block)) taken.set(block, { projectId: "host TCP listener" });
1783
+ for (const block of raced) if (!taken.has(block)) taken.set(block, { projectId: "exclusive claim collision" });
1784
+ const candidate = selectPortBase(preferred, taken, cooling);
1673
1785
  try {
1674
1786
  writeExclusive(portClaimPath(registryDir, candidate), marker(candidate));
1675
1787
  return candidate;
@@ -1686,6 +1798,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1686
1798
  write(portClaimPath(registryDir, candidate), marker(candidate));
1687
1799
  return candidate;
1688
1800
  }
1801
+ raced.add(candidate); // cooldown ordering must not select this collision again
1689
1802
  preferred = candidate + LEASED_PORT_STRIDE; // a peer won this one
1690
1803
  }
1691
1804
  }
@@ -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
@@ -3777,7 +3800,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3777
3800
  // A slot whose stack another live batch owns is a coordination refusal (the
3778
3801
  // same class as the server's duplicate-lease rejection), not bad arguments.
3779
3802
  const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
3780
- return { exitCode, outcome: "error", error: String(error?.message ?? error) };
3803
+ // BOT-2490: retain listener/cooldown refusals in the existing public code field;
3804
+ // keep the exit mapping and unrelated local-error semantics unchanged.
3805
+ const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) ? error.code : null;
3806
+ return { exitCode, outcome: "error", ...(code ? { code } : {}), error: String(error?.message ?? error) };
3781
3807
  }
3782
3808
  // BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
3783
3809
  // directory's initial retention, and a kill during them leaves a lease the
@@ -4349,13 +4375,17 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4349
4375
  placement.transport = "ssh-forward";
4350
4376
  placement.forwards = tunnel.forwards.map(({ name, remote, local }) => ({ name, remote, local }));
4351
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}`);
4352
- childConnection = tunnel.connection;
4378
+ childConnection = { ...tunnel.connection, ssh_target: sshTarget };
4353
4379
  const forwardTarget = sshTarget;
4354
4380
  tunnel.onExit((exit) => {
4355
4381
  if (childResult) return;
4356
4382
  tunnelLost = { ...exit, target: forwardTarget };
4357
4383
  stopChild("SIGTERM", EXIT.LEASE_FAILED);
4358
4384
  });
4385
+ forwardAttestation = await startForwardAttestation({
4386
+ leaseId, sshTarget, forwards: placement.forwards,
4387
+ isLive: () => Boolean(tunnel && !tunnelLost && !receivedSignal && tunnel.isLive()),
4388
+ });
4359
4389
  }
4360
4390
  connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), childConnection);
4361
4391
  if (receivedSignal) {
@@ -4373,6 +4403,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4373
4403
  ...process.env,
4374
4404
  BOTBUDDY_STACK_LEASE_ID: leaseId,
4375
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 ?? "",
4376
4408
  // BOT-2142: lets a lane's own checks (e.g. the port-owner preflight) know the
4377
4409
  // listeners are SSH forwards to another host, not this machine's Docker.
4378
4410
  BOTBUDDY_STACK_EXECUTION_MODE: servedRemote ? "remote" : (placement.mode ?? "local"),