@botbuddy/cli 1.62.3 → 1.62.5
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-isolation.mjs +127 -14
- package/src/stack-tunnel.mjs +35 -6
- package/src/stack.mjs +4 -1
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.62.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.62.5",
|
|
3
|
+
"source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
|
|
4
|
+
"source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.62.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.62.5",
|
|
3
|
+
"source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
|
|
4
|
+
"source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.62.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.62.5",
|
|
4
|
+
"source_commit": "abebdf325df18fd2d61b76971c59c2d57fd512a1",
|
|
5
|
+
"source_sha256": "c936de9003230430258fd3ee54cc3fc6aa74618312bbf875a16c47b2320975ce",
|
|
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": "3695d85ceebb29aeea8f764924286918409a1fe60c0da85d0e37dff20e893754",
|
|
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": "f391c6f146dda4ea93ce60a856e4d3e63d64e467c0b928af0299a97b8df626f2",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
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
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Dependency-free (Node stdlib only).
|
|
23
23
|
|
|
24
24
|
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
-
import { connect, createServer } from "node:net";
|
|
25
|
+
import { connect, createServer, isIP } from "node:net";
|
|
26
26
|
|
|
27
27
|
export const SSH_FORWARD_OPTIONS = Object.freeze([
|
|
28
28
|
"-N", "-T",
|
|
@@ -299,7 +299,7 @@ export async function openSshForwards({
|
|
|
299
299
|
* `GatewayPorts yes` silently binds it on every interface, exposing this machine's
|
|
300
300
|
* callback service to the host's network. The CLI cannot read sshd_config, so after the
|
|
301
301
|
* forward is up it asks the host which address the listener really holds and refuses
|
|
302
|
-
* anything but loopback (fail closed: no
|
|
302
|
+
* anything but loopback (fail closed: no inventory, no listener, unreadable → refused).
|
|
303
303
|
*/
|
|
304
304
|
export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.env, run = spawnSync, label = null }) {
|
|
305
305
|
const what = label ?? `the reverse forward for --callback-port ${port}`;
|
|
@@ -307,18 +307,47 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
|
|
|
307
307
|
`${what} on ${sshTarget} could not be proven loopback-only (${why}). ` +
|
|
308
308
|
"An sshd with GatewayPorts yes binds -R forwards on every interface; set GatewayPorts no (or clientspecified) on the host, " +
|
|
309
309
|
"or run without --callback-port.", extra);
|
|
310
|
+
const checkedPort = validPort(port);
|
|
311
|
+
if (checkedPort == null) throw fail("invalid listener port");
|
|
312
|
+
// Linux lsof cannot see root-owned sshd sockets from an unprivileged login (BOT-2743).
|
|
313
|
+
// ss reads the socket table without process visibility; -p is deliberately omitted.
|
|
314
|
+
// Dispatch on the remote OS in the SAME SSH invocation, with no guessed fallback.
|
|
315
|
+
const command = `case "$(uname -s)" in Linux) printf 'bb-ss-v1\\n'; ss -H -ltn '( sport = :${checkedPort} )';; ` +
|
|
316
|
+
`Darwin) lsof -nP -iTCP:${checkedPort} -sTCP:LISTEN -Fn;; ` +
|
|
317
|
+
"*) printf 'unsupported listener inventory platform\\n' >&2; exit 69;; esac";
|
|
310
318
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed argv; target validated; integer port.
|
|
311
319
|
const r = run(sshBin, [
|
|
312
320
|
"-T", "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", "--", sshTarget,
|
|
313
|
-
|
|
321
|
+
command,
|
|
314
322
|
], { encoding: "utf8", env, timeout: 15_000 });
|
|
315
323
|
if (r.error || r.status !== 0) {
|
|
316
324
|
const tail = String(r.stderr || r.error?.message || "").trim().split("\n").slice(-1)[0];
|
|
317
325
|
// lsof exits 1 with no output when nothing listens.
|
|
318
|
-
if (r.status === 1 && !String(r.stdout || "").trim()
|
|
319
|
-
|
|
326
|
+
if (!r.error && r.status === 1 && !String(r.stdout || "").trim() && !String(r.stderr || "").trim()) {
|
|
327
|
+
throw fail(`no listener on the host's port ${port}`, { noListener: true });
|
|
328
|
+
}
|
|
329
|
+
throw fail(`could not inspect host sockets with ss/lsof (exit ${r.status ?? "?"}${tail ? `: ${tail}` : ""})`);
|
|
330
|
+
}
|
|
331
|
+
const output = String(r.stdout || "");
|
|
332
|
+
let addresses;
|
|
333
|
+
if (output.startsWith("bb-ss-v1\n")) {
|
|
334
|
+
addresses = output.slice("bb-ss-v1\n".length).split("\n").filter((line) => line.trim()).map((line) => {
|
|
335
|
+
// ss -H -ltn: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port.
|
|
336
|
+
// Reject the whole answer on any bad row: silently skipping a wildcard is unsafe.
|
|
337
|
+
const fields = line.trim().split(/\s+/);
|
|
338
|
+
const local = /^(.+):(\d+)$/.exec(fields[3] ?? "");
|
|
339
|
+
let address = local?.[1];
|
|
340
|
+
if (address?.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
|
|
341
|
+
const family = isIP(address ?? "");
|
|
342
|
+
if (fields.length !== 5 || fields[0] !== "LISTEN" || !/^\d+$/.test(fields[1]) || !/^\d+$/.test(fields[2]) ||
|
|
343
|
+
!local || Number(local[2]) !== checkedPort || (!family && address !== "*")) {
|
|
344
|
+
throw fail("malformed ss listener inventory");
|
|
345
|
+
}
|
|
346
|
+
return `${family === 6 ? `[${address}]` : address}:${checkedPort}`;
|
|
347
|
+
});
|
|
348
|
+
} else {
|
|
349
|
+
addresses = output.split("\n").filter((l) => l.startsWith("n")).map((l) => l.slice(1).trim());
|
|
320
350
|
}
|
|
321
|
-
const addresses = String(r.stdout || "").split("\n").filter((l) => l.startsWith("n")).map((l) => l.slice(1).trim());
|
|
322
351
|
if (!addresses.length) throw fail(`no listener on the host's port ${port}`, { noListener: true });
|
|
323
352
|
const exposed = addresses.filter((a) => !(a.startsWith("127.") || a.startsWith("[::1]:") || a.startsWith("localhost:")));
|
|
324
353
|
if (exposed.length) throw fail(`the host listens on ${exposed.join(", ")}`, { exposed, listeners: addresses });
|
package/src/stack.mjs
CHANGED
|
@@ -3777,7 +3777,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3777
3777
|
// A slot whose stack another live batch owns is a coordination refusal (the
|
|
3778
3778
|
// same class as the server's duplicate-lease rejection), not bad arguments.
|
|
3779
3779
|
const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
|
|
3780
|
-
|
|
3780
|
+
// BOT-2490: retain listener/cooldown refusals in the existing public code field;
|
|
3781
|
+
// keep the exit mapping and unrelated local-error semantics unchanged.
|
|
3782
|
+
const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) ? error.code : null;
|
|
3783
|
+
return { exitCode, outcome: "error", ...(code ? { code } : {}), error: String(error?.message ?? error) };
|
|
3781
3784
|
}
|
|
3782
3785
|
// BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
|
|
3783
3786
|
// directory's initial retention, and a kill during them leaves a lease the
|