@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.
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-forward-attestation.mjs +131 -0
- package/src/stack-isolation.mjs +127 -14
- package/src/stack-tunnel.mjs +1 -0
- package/src/stack.mjs +40 -8
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.0",
|
|
3
|
+
"source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
|
|
4
|
+
"source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "5d57767abb24be43ba64e17b57d9aa396c4149f4b89101e6a0346da57ec5ea41"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.0",
|
|
3
|
+
"source_commit": "cfcfef9b5adb90a7a7dfb2fd8cfad0206464149a",
|
|
4
|
+
"source_sha256": "9f30dd9147ad173965dfc2a5f1c8b209c32d760a82c3edd6cbf560108e95a5f0",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "33e0e6ba075a7486a02e4a3638bce10e1df41ddfe8315685e285cf144a149ea7"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
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": "
|
|
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": "
|
|
18
|
+
"sha256": "33e0e6ba075a7486a02e4a3638bce10e1df41ddfe8315685e285cf144a149ea7",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/stack-isolation.mjs
CHANGED
|
@@ -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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/src/stack-tunnel.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
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
|
-
|
|
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"),
|