@botbuddy/cli 1.45.3 → 1.45.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/stack-tunnel.mjs +234 -22
- package/src/stack.mjs +17 -29
- package/src/wait-core.mjs +18 -7
package/package.json
CHANGED
package/src/stack-tunnel.mjs
CHANGED
|
@@ -37,6 +37,19 @@ export const SSH_FORWARD_OPTIONS = Object.freeze([
|
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
39
|
export const DEFAULT_TUNNEL_READY_MS = 20_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The host-side bind every reverse forward asks for: IPv4 loopback ONLY (Codex #1134 P1). With no
|
|
43
|
+
* bind address sshd binds `localhost` on both address families and reports the forward granted
|
|
44
|
+
* when EITHER binds, so two sessions could each hold one family of the same port (captured
|
|
45
|
+
* 2026-10-13, OpenSSH_10.3p1). One address is one socket: the forward is granted whole or refused
|
|
46
|
+
* whole, and any other listener on the port is another process's. An sshd with `GatewayPorts yes`
|
|
47
|
+
* still binds every interface, which the probe and the post-forward check refuse.
|
|
48
|
+
*/
|
|
49
|
+
export const REVERSE_BIND = "127.0.0.1";
|
|
50
|
+
const REVERSE_BIND_RE = REVERSE_BIND.split(".").join("\\.");
|
|
51
|
+
/** Listeners on the host's `port` that are not the one address a reverse forward of ours binds. */
|
|
52
|
+
const foreignListeners = (addresses, port) => addresses.filter((a) => a !== `${REVERSE_BIND}:${Number(port)}`);
|
|
40
53
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
41
54
|
|
|
42
55
|
function tunnelError(code, message, extra = {}) {
|
|
@@ -146,9 +159,11 @@ export async function planForwards(remotePorts, { isFree = isLocalPortFree, pick
|
|
|
146
159
|
|
|
147
160
|
/** `ssh` argv (without the binary) for the planned forwards. */
|
|
148
161
|
export function buildSshForwardArgv({ sshTarget, forwards, callbackPort = null }) {
|
|
149
|
-
|
|
162
|
+
// `-v` only with a callback forward: OpenSSH then logs whether the host GRANTED this session's
|
|
163
|
+
// `-R` (see openSshForwards `confirmRemoteForward`).
|
|
164
|
+
const argv = [...(callbackPort != null ? ["-v"] : []), ...SSH_FORWARD_OPTIONS];
|
|
150
165
|
for (const f of forwards) argv.push("-L", `127.0.0.1:${f.local}:127.0.0.1:${f.remote}`);
|
|
151
|
-
if (callbackPort != null) argv.push("-R", `${callbackPort}:127.0.0.1:${callbackPort}`);
|
|
166
|
+
if (callbackPort != null) argv.push("-R", `${REVERSE_BIND}:${callbackPort}:127.0.0.1:${callbackPort}`);
|
|
152
167
|
argv.push("--", sshTarget);
|
|
153
168
|
return argv;
|
|
154
169
|
}
|
|
@@ -186,19 +201,41 @@ function probeConnect(port, timeoutMs = 500) {
|
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
const BIND_RACE = /address already in use|cannot listen|could not request local forwarding/i;
|
|
204
|
+
// What `ssh -v` adds to stderr that is not an error (captured 2026-10-13: OpenSSH_10.3p1 on
|
|
205
|
+
// macOS, and OpenSSH_9.6p1 on Linux, which prints its version banner with no debug prefix).
|
|
206
|
+
const SSH_VERBOSE_CHATTER = /^(debug\d: |Authenticated to |Warning: Permanently added |OpenSSH_[\w.]+[ ,])/;
|
|
189
207
|
|
|
190
208
|
/**
|
|
191
209
|
* Spawn ONE ssh carrying every forward and resolve once each local listener accepts.
|
|
192
210
|
* Rejects (killing ssh) if ssh exits first or readiness times out.
|
|
211
|
+
*
|
|
212
|
+
* `confirmRemoteForward` (a host port; argv must carry `-v`): also wait for OpenSSH's
|
|
213
|
+
* `remote forward success for: listen 127.0.0.1:<port>` from THIS session (Codex #1134 P1). The `-L`
|
|
214
|
+
* listeners are up before the host answers the `-R` request, so without it a session whose
|
|
215
|
+
* `-R` the host refused looks ready until its exit is delivered.
|
|
193
216
|
*/
|
|
194
217
|
export async function openSshForwards({
|
|
195
218
|
sshBin = "ssh", argv, forwards, sshTarget, readyTimeoutMs = DEFAULT_TUNNEL_READY_MS,
|
|
196
219
|
spawnImpl = spawn, env = process.env, connectProbe = probeConnect, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
220
|
+
confirmRemoteForward = null,
|
|
197
221
|
}) {
|
|
198
222
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed ssh argv; target validated by assertSshTarget; no shell.
|
|
199
223
|
const child = spawnImpl(sshBin, argv, { stdio: ["ignore", "ignore", "pipe"], env });
|
|
224
|
+
const verbose = argv.includes("-v");
|
|
225
|
+
const granted = confirmRemoteForward == null ? null : new RegExp(`^debug1: remote forward success for: listen ${REVERSE_BIND_RE}:${Number(confirmRemoteForward)}\\b`);
|
|
226
|
+
let remoteConfirmed = granted == null;
|
|
200
227
|
let stderr = "";
|
|
201
|
-
|
|
228
|
+
let partial = "";
|
|
229
|
+
child.stderr?.on("data", (chunk) => {
|
|
230
|
+
const lines = (partial + chunk.toString("utf8")).split("\n");
|
|
231
|
+
partial = lines.pop();
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
if (granted?.test(line)) remoteConfirmed = true;
|
|
234
|
+
if (verbose && SSH_VERBOSE_CHATTER.test(line)) continue;
|
|
235
|
+
stderr = `${stderr}${line}\n`.slice(-2048);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
const stderrText = () => `${stderr}${verbose && SSH_VERBOSE_CHATTER.test(partial) ? "" : partial}`.trim();
|
|
202
239
|
let exited = null;
|
|
203
240
|
let closing = false;
|
|
204
241
|
const listeners = [];
|
|
@@ -207,10 +244,10 @@ export async function openSshForwards({
|
|
|
207
244
|
if (exited) return;
|
|
208
245
|
exited = { code: code ?? null, signal: signal ?? null };
|
|
209
246
|
resolve(exited);
|
|
210
|
-
if (!closing) for (const fn of listeners) fn({ ...exited, stderr:
|
|
247
|
+
if (!closing) for (const fn of listeners) fn({ ...exited, stderr: stderrText() });
|
|
211
248
|
};
|
|
212
249
|
child.once("exit", finish);
|
|
213
|
-
child.once("error", (error) => { stderr += `\n${error.message}`; finish(null, null); });
|
|
250
|
+
child.once("error", (error) => { stderr += `\n${error.message}\n`; finish(null, null); });
|
|
214
251
|
});
|
|
215
252
|
|
|
216
253
|
const close = async () => {
|
|
@@ -226,17 +263,22 @@ export async function openSshForwards({
|
|
|
226
263
|
|
|
227
264
|
const deadline = Date.now() + readyTimeoutMs;
|
|
228
265
|
const pending = new Set(forwards.map((f) => f.local));
|
|
229
|
-
while (pending.size) {
|
|
266
|
+
while (pending.size || !remoteConfirmed) {
|
|
230
267
|
if (exited) break;
|
|
231
268
|
for (const port of [...pending]) if (await connectProbe(port)) pending.delete(port);
|
|
232
|
-
if (!pending.size || exited) break;
|
|
269
|
+
if ((!pending.size && remoteConfirmed) || exited) break;
|
|
233
270
|
if (Date.now() > deadline) break;
|
|
234
271
|
await sleep(100);
|
|
235
272
|
}
|
|
236
|
-
if (exited || pending.size) {
|
|
273
|
+
if (exited || pending.size || !remoteConfirmed) {
|
|
237
274
|
const timedOut = !exited;
|
|
238
275
|
await close();
|
|
239
|
-
const tail =
|
|
276
|
+
const tail = stderrText();
|
|
277
|
+
if (timedOut && !pending.size) {
|
|
278
|
+
throw tunnelError("REMOTE_UNREACHABLE",
|
|
279
|
+
`${sshTarget} never confirmed the reverse forward for --callback-port ${confirmRemoteForward} within ${Math.round(readyTimeoutMs / 1000)}s` +
|
|
280
|
+
`${tail ? `: ${tail}` : ""}, so it was not trusted and the tunnel was closed.`, { sshStderr: tail });
|
|
281
|
+
}
|
|
240
282
|
throw tunnelError("REMOTE_UNREACHABLE",
|
|
241
283
|
`ssh ${sshTarget} could not open the stack forwards${timedOut ? ` within ${Math.round(readyTimeoutMs / 1000)}s` : ` (exit ${exited?.code ?? "?"})`}` +
|
|
242
284
|
`${tail ? `: ${tail}` : ""}. Check that \`ssh ${sshTarget}\` works non-interactively from this machine (BatchMode: a key, no password prompt).`,
|
|
@@ -246,8 +288,8 @@ export async function openSshForwards({
|
|
|
246
288
|
pid: child.pid,
|
|
247
289
|
argv,
|
|
248
290
|
exited: exitPromise,
|
|
249
|
-
onExit: (fn) => { listeners.push(fn); if (exited && !closing) fn({ ...exited, stderr:
|
|
250
|
-
stderrTail:
|
|
291
|
+
onExit: (fn) => { listeners.push(fn); if (exited && !closing) fn({ ...exited, stderr: stderrText() }); },
|
|
292
|
+
stderrTail: stderrText,
|
|
251
293
|
close,
|
|
252
294
|
};
|
|
253
295
|
}
|
|
@@ -259,11 +301,12 @@ export async function openSshForwards({
|
|
|
259
301
|
* forward is up it asks the host which address the listener really holds and refuses
|
|
260
302
|
* anything but loopback (fail closed: no lsof, no listener, unreadable → refused).
|
|
261
303
|
*/
|
|
262
|
-
export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.env, run = spawnSync }) {
|
|
263
|
-
const
|
|
264
|
-
|
|
304
|
+
export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.env, run = spawnSync, label = null }) {
|
|
305
|
+
const what = label ?? `the reverse forward for --callback-port ${port}`;
|
|
306
|
+
const fail = (why, extra = {}) => tunnelError("CALLBACK_NOT_LOOPBACK",
|
|
307
|
+
`${what} on ${sshTarget} could not be proven loopback-only (${why}). ` +
|
|
265
308
|
"An sshd with GatewayPorts yes binds -R forwards on every interface; set GatewayPorts no (or clientspecified) on the host, " +
|
|
266
|
-
"or run without --callback-port.");
|
|
309
|
+
"or run without --callback-port.", extra);
|
|
267
310
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed argv; target validated; integer port.
|
|
268
311
|
const r = run(sshBin, [
|
|
269
312
|
"-T", "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", "--", sshTarget,
|
|
@@ -272,16 +315,145 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
|
|
|
272
315
|
if (r.error || r.status !== 0) {
|
|
273
316
|
const tail = String(r.stderr || r.error?.message || "").trim().split("\n").slice(-1)[0];
|
|
274
317
|
// lsof exits 1 with no output when nothing listens.
|
|
275
|
-
if (r.status === 1 && !String(r.stdout || "").trim()) throw fail(`no listener on the host's port ${port}
|
|
318
|
+
if (r.status === 1 && !String(r.stdout || "").trim()) throw fail(`no listener on the host's port ${port}`, { noListener: true });
|
|
276
319
|
throw fail(`could not run lsof on the host (exit ${r.status ?? "?"}${tail ? `: ${tail}` : ""})`);
|
|
277
320
|
}
|
|
278
321
|
const addresses = String(r.stdout || "").split("\n").filter((l) => l.startsWith("n")).map((l) => l.slice(1).trim());
|
|
279
|
-
if (!addresses.length) throw fail(`no listener on the host's port ${port}
|
|
322
|
+
if (!addresses.length) throw fail(`no listener on the host's port ${port}`, { noListener: true });
|
|
280
323
|
const exposed = addresses.filter((a) => !(a.startsWith("127.") || a.startsWith("[::1]:") || a.startsWith("localhost:")));
|
|
281
|
-
if (exposed.length) throw fail(`the host listens on ${exposed.join(", ")}
|
|
324
|
+
if (exposed.length) throw fail(`the host listens on ${exposed.join(", ")}`, { exposed, listeners: addresses });
|
|
282
325
|
return addresses;
|
|
283
326
|
}
|
|
284
327
|
|
|
328
|
+
|
|
329
|
+
/** A random throwaway port in the dynamic range (49152–65535) for the GatewayPorts probe. */
|
|
330
|
+
export function randomProbePort(random = Math.random) {
|
|
331
|
+
return 49152 + Math.floor(random() * (65535 - 49152 + 1));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const REMOTE_FORWARD_BUSY = /remote port forwarding failed|address already in use|cannot listen/i;
|
|
335
|
+
export const DEFAULT_PROBE_READY_MS = 10_000;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Codex #1134 P1: the probe's LOCAL destination is a port this process holds for the whole probe —
|
|
339
|
+
* a listener that closes every connection without a byte — so no other local service can bind it
|
|
340
|
+
* while a (possibly wildcard) probe listener on the host forwards there. Resolves { port, close }.
|
|
341
|
+
*/
|
|
342
|
+
export function holdSilentLocalPort() {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const server = createServer((socket) => socket.destroy());
|
|
345
|
+
server.once("error", reject);
|
|
346
|
+
server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
|
|
347
|
+
resolve({ port: server.address().port, close: () => new Promise((done) => server.close(() => done())) });
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* BOT-2184: prove the host keeps `-R` listeners on loopback BEFORE the real callback forward
|
|
354
|
+
* exists. Opens a short-lived `ssh -N -R P:127.0.0.1:<held local port>` for a throwaway host
|
|
355
|
+
* port P (the local end is a listener this process holds that answers nothing, so nothing is
|
|
356
|
+
* exposed even if sshd binds it wildcard), asks
|
|
357
|
+
* the host which address P really holds (`verifyCallbackLoopback` with `port: P`), and kills the
|
|
358
|
+
* probe. Fail closed: an exposed bind, lsof missing, or a listener that never appears is
|
|
359
|
+
* CALLBACK_NOT_LOOPBACK; a busy P is retried once on another port, then refused.
|
|
360
|
+
*/
|
|
361
|
+
export async function probeGatewayPorts({
|
|
362
|
+
sshBin = "ssh", sshTarget, env = process.env, spawnImpl = spawn, run = spawnSync, pickProbePort = randomProbePort,
|
|
363
|
+
holdLocalPort = holdSilentLocalPort, readyTimeoutMs = DEFAULT_PROBE_READY_MS, attempts = 2,
|
|
364
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
365
|
+
}) {
|
|
366
|
+
const fail = (why) => tunnelError("CALLBACK_NOT_LOOPBACK",
|
|
367
|
+
`the GatewayPorts probe on ${sshTarget} could not prove reverse forwards stay loopback-only (${why}), so no -R for ` +
|
|
368
|
+
"--callback-port was opened. An sshd with GatewayPorts yes binds -R forwards on every interface; set GatewayPorts no " +
|
|
369
|
+
"(or clientspecified) on the host, or run without --callback-port.");
|
|
370
|
+
const tried = new Set();
|
|
371
|
+
let busyTail = "";
|
|
372
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
373
|
+
let port = pickProbePort();
|
|
374
|
+
for (let i = 0; tried.has(port) && i < 20; i++) port = pickProbePort();
|
|
375
|
+
tried.add(port);
|
|
376
|
+
// P must be free on the host BEFORE the probe, or a listener seen afterwards could be someone
|
|
377
|
+
// else's (a loopback one would pass a host that would expose ours). Taken → another port.
|
|
378
|
+
try {
|
|
379
|
+
verifyCallbackLoopback({ sshBin, sshTarget, port, env, run, label: `the GatewayPorts probe (host port ${port})` });
|
|
380
|
+
busyTail = `host port ${port} already has a listener`;
|
|
381
|
+
continue;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
// A listener that was there before the probe, on ANY address, says nothing about
|
|
384
|
+
// GatewayPorts (Codex #1134 P2): the port is busy, probe another one.
|
|
385
|
+
if (error?.exposed) { busyTail = `host port ${port} already has a listener (${error.listeners.join(", ")})`; continue; }
|
|
386
|
+
if (!error?.noListener) throw error;
|
|
387
|
+
}
|
|
388
|
+
const held = await holdLocalPort();
|
|
389
|
+
const closedLocal = held.port;
|
|
390
|
+
// `-v`: OpenSSH logs `remote forward success for: listen 127.0.0.1:<P>` once the host GRANTED this
|
|
391
|
+
// forward (Codex #1134 P1): only then can a listener on P be this probe's, not a process that
|
|
392
|
+
// took P after the free-port check.
|
|
393
|
+
const argv = [
|
|
394
|
+
"-v", "-N", "-T", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none",
|
|
395
|
+
"-R", `${REVERSE_BIND}:${port}:127.0.0.1:${closedLocal}`, "--", sshTarget,
|
|
396
|
+
];
|
|
397
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- fixed ssh argv; target validated by assertSshTarget; integer ports; no shell.
|
|
398
|
+
const child = spawnImpl(sshBin, argv, { stdio: ["ignore", "ignore", "pipe"], env });
|
|
399
|
+
let stderr = "";
|
|
400
|
+
let exited = null;
|
|
401
|
+
let confirmed = false;
|
|
402
|
+
const granted = new RegExp(`remote forward success for: listen ${REVERSE_BIND_RE}:${port}\\b`);
|
|
403
|
+
child.stderr?.on("data", (chunk) => {
|
|
404
|
+
const text = stderr.slice(-256) + chunk.toString("utf8");
|
|
405
|
+
if (granted.test(text)) confirmed = true;
|
|
406
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-2048);
|
|
407
|
+
});
|
|
408
|
+
const exitPromise = new Promise((resolve) => {
|
|
409
|
+
child.once("exit", (code, signal) => { exited = { code, signal }; resolve(exited); });
|
|
410
|
+
child.once("error", (error) => { stderr += `\n${error.message}`; exited = { code: null, signal: null }; resolve(exited); });
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
414
|
+
for (;;) {
|
|
415
|
+
if (exited) {
|
|
416
|
+
const tail = stderr.trim().split("\n").filter((l) => !l.startsWith("debug1:")).slice(-3).join(" | ");
|
|
417
|
+
if (REMOTE_FORWARD_BUSY.test(stderr)) { busyTail = tail; break; }
|
|
418
|
+
throw fail(`the probe forward exited (${exited.code ?? exited.signal ?? "?"})${tail ? `: ${tail}` : ""}`);
|
|
419
|
+
}
|
|
420
|
+
if (!confirmed) {
|
|
421
|
+
if (Date.now() > deadline) throw fail(`the host never confirmed the probe forward on port ${port} within ${Math.round(readyTimeoutMs / 1000)}s`);
|
|
422
|
+
await sleep(100);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const addresses = verifyCallbackLoopback({ sshBin, sshTarget, port, env, run, label: `the GatewayPorts probe (host port ${port})` });
|
|
427
|
+
// The probe asked for one address; anything else on P is another process's, so this
|
|
428
|
+
// port proves nothing about the host. Probe another one.
|
|
429
|
+
const foreign = foreignListeners(addresses, port);
|
|
430
|
+
if (foreign.length) { busyTail = `host port ${port} also has a listener that is not this probe's (${foreign.join(", ")})`; break; }
|
|
431
|
+
return { port, attempts: attempt };
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (!error?.noListener) {
|
|
434
|
+
// An exposed bind or no lsof: refuse (never retried). The message names the probe port.
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (exited) continue;
|
|
439
|
+
if (Date.now() > deadline) throw fail(`the probe listener on host port ${port} never appeared within ${Math.round(readyTimeoutMs / 1000)}s`);
|
|
440
|
+
await sleep(150);
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
if (!exited) {
|
|
444
|
+
try { child.kill("SIGTERM"); } catch { /* gone */ }
|
|
445
|
+
const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } }, 3_000);
|
|
446
|
+
timer.unref?.();
|
|
447
|
+
await exitPromise;
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
}
|
|
450
|
+
// Released only after the probe forward is gone.
|
|
451
|
+
await held.close();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
throw fail(`the host refused ${attempts} probe ports: ${busyTail}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
285
457
|
/**
|
|
286
458
|
* Plan, spawn and verify the forwards for an ACTIVE remote lease.
|
|
287
459
|
* @returns {{ forwards, connection, argv, attempts, onExit, close, pid }}
|
|
@@ -289,21 +461,54 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
|
|
|
289
461
|
export async function establishSshTunnel({
|
|
290
462
|
connection, sshTarget, hostKey = null, callbackPort = null, sshBin = null, env = process.env,
|
|
291
463
|
attempts = 3, readyTimeoutMs = DEFAULT_TUNNEL_READY_MS, isFree = isLocalPortFree, pickFree = pickFreeLocalPort, spawnImpl = spawn,
|
|
292
|
-
runRemote = spawnSync,
|
|
464
|
+
runRemote = spawnSync, probe = probeGatewayPorts, probeReadyTimeoutMs = DEFAULT_PROBE_READY_MS,
|
|
293
465
|
}) {
|
|
294
466
|
const target = assertSshTarget(sshTarget, hostKey);
|
|
295
467
|
const remotePorts = remotePortsFromConnection(connection);
|
|
296
468
|
const bin = sshBin || env.BOTBUDDY_STACK_SSH_BIN || "ssh";
|
|
297
|
-
|
|
469
|
+
// BOT-2184: prove the host keeps -R listeners on loopback BEFORE the real callback forward
|
|
470
|
+
// exists, so the caller's callback service is never reachable from the host's network.
|
|
471
|
+
// The post-forward check below stays as a second line.
|
|
472
|
+
if (callbackPort != null) {
|
|
473
|
+
await probe({ sshBin: bin, sshTarget: target, env, run: runRemote, spawnImpl, readyTimeoutMs: probeReadyTimeoutMs });
|
|
474
|
+
// Codex #1134 P1: the REAL callback port must be free on the host, or the post-forward check
|
|
475
|
+
// could accept another process's loopback listener and the stack's callbacks would reach it.
|
|
476
|
+
let holders = null;
|
|
477
|
+
try {
|
|
478
|
+
holders = verifyCallbackLoopback({ sshBin: bin, sshTarget: target, port: callbackPort, env, run: runRemote });
|
|
479
|
+
} catch (error) {
|
|
480
|
+
// No -R of ours exists yet, so a listener on any address is someone else's: busy, not a
|
|
481
|
+
// GatewayPorts verdict.
|
|
482
|
+
if (error?.exposed) holders = error.listeners;
|
|
483
|
+
else if (!error?.noListener) throw error;
|
|
484
|
+
}
|
|
485
|
+
if (holders) {
|
|
486
|
+
throw tunnelError("CALLBACK_PORT_BUSY",
|
|
487
|
+
`--callback-port ${callbackPort} is already listening on ${target} (${holders.join(", ")}): another run or service holds it, ` +
|
|
488
|
+
"so the stack's callbacks could reach that process instead of this machine. Pick another --callback-port, or wait for it to free.");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Codex #1134 P2: a remapped stack forward must never take the callback port locally; ssh
|
|
492
|
+
// would bind it before the caller's callback service and the -R traffic would loop into it.
|
|
493
|
+
let avoid = new Set(callbackPort != null ? [Number(callbackPort)] : []);
|
|
298
494
|
let lastError = null;
|
|
299
495
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
300
496
|
const forwards = await planForwards(remotePorts, { isFree, pickFree, avoid });
|
|
301
497
|
const argv = buildSshForwardArgv({ sshTarget: target, forwards, callbackPort });
|
|
302
498
|
try {
|
|
303
|
-
|
|
499
|
+
// Codex #1134 P1: the tunnel is ready only once the host GRANTED this session's -R; the
|
|
500
|
+
// post-forward check below then reads a listener that can only be this session's.
|
|
501
|
+
const handle = await openSshForwards({ sshBin: bin, argv, forwards, sshTarget: target, readyTimeoutMs, spawnImpl, env, confirmRemoteForward: callbackPort });
|
|
304
502
|
if (callbackPort != null) {
|
|
305
503
|
try {
|
|
306
|
-
verifyCallbackLoopback({ sshBin: bin, sshTarget: target, port: callbackPort, env, run: runRemote });
|
|
504
|
+
const addresses = verifyCallbackLoopback({ sshBin: bin, sshTarget: target, port: callbackPort, env, run: runRemote });
|
|
505
|
+
// This session holds exactly 127.0.0.1:<port>; any other listener on the port is not ours.
|
|
506
|
+
const foreign = foreignListeners(addresses, callbackPort);
|
|
507
|
+
if (foreign.length) {
|
|
508
|
+
throw tunnelError("CALLBACK_PORT_BUSY",
|
|
509
|
+
`--callback-port ${callbackPort} on ${target} also has a listener that is not this run's (${foreign.join(", ")}): another run or service shares the port, ` +
|
|
510
|
+
"so the stack's callbacks could reach that process instead of this machine. Pick another --callback-port, or wait for it to free.");
|
|
511
|
+
}
|
|
307
512
|
} catch (error) {
|
|
308
513
|
await handle.close();
|
|
309
514
|
throw error;
|
|
@@ -311,6 +516,13 @@ export async function establishSshTunnel({
|
|
|
311
516
|
}
|
|
312
517
|
return { ...handle, forwards, connection: rewriteConnectionForForwards(connection, forwards), attempts: attempt };
|
|
313
518
|
} catch (error) {
|
|
519
|
+
// Another run took the callback port between the free-port check and this session's -R:
|
|
520
|
+
// the host refused ours, so nothing of this run listens there.
|
|
521
|
+
if (callbackPort != null && new RegExp(`remote port forwarding failed for listen port ${Number(callbackPort)}\\b`).test(error?.sshStderr ?? "")) {
|
|
522
|
+
throw tunnelError("CALLBACK_PORT_BUSY",
|
|
523
|
+
`--callback-port ${callbackPort} was taken on ${target} before this run's reverse forward could bind it (ssh: ${error.sshStderr.split("\n").slice(-1)[0]}): ` +
|
|
524
|
+
"another run or service holds it, so the stack's callbacks could reach that process instead of this machine. Pick another --callback-port, or wait for it to free.");
|
|
525
|
+
}
|
|
314
526
|
lastError = error;
|
|
315
527
|
// Only a lost local bind race is worth another try, on fresh ports; an auth or
|
|
316
528
|
// host failure would fail the same way again.
|
package/src/stack.mjs
CHANGED
|
@@ -47,17 +47,13 @@ import { bold, dim, yellow } from "./utils.mjs";
|
|
|
47
47
|
export const STACK_SCHEMA_VERSION = 1;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Codex #1077 P1: an sshd with `GatewayPorts yes` binds a `-R` forward on every
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* Codex #1077 P1 / BOT-2184: an sshd with `GatewayPorts yes` binds a `-R` forward on every
|
|
51
|
+
* interface. A remote lease with --callback-port therefore runs the pre-forward GatewayPorts
|
|
52
|
+
* probe (`probeGatewayPorts` in stack-tunnel.mjs) before the real `-R` exists, and refuses
|
|
53
|
+
* with exit 5 CALLBACK_NOT_LOOPBACK when the host would expose it. The code below is kept
|
|
54
|
+
* only so older automation that matched on it still resolves; no path emits it any more.
|
|
55
55
|
*/
|
|
56
56
|
export const CALLBACK_PORT_UNSUPPORTED_REMOTE = "CALLBACK_PORT_UNSUPPORTED_REMOTE";
|
|
57
|
-
const CALLBACK_PORT_REMOTE_REFUSAL =
|
|
58
|
-
`${CALLBACK_PORT_UNSUPPORTED_REMOTE}: --callback-port is not supported on a remote lease yet — the -R listener ` +
|
|
59
|
-
"could be exposed on the serving host's network (sshd GatewayPorts yes) before it is checked. Tracked in BOT-2184 " +
|
|
60
|
-
"(pre-forward GatewayPorts probe). Use --local-only for a run that needs a callback port.";
|
|
61
57
|
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
62
58
|
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
63
59
|
/**
|
|
@@ -174,10 +170,11 @@ ${bold("run OPTIONS")}
|
|
|
174
170
|
Default (neither flag): execution "auto" — co-located if it admits now,
|
|
175
171
|
else a remote host with capacity; the bundle digest is sent up front.
|
|
176
172
|
--callback-port N The caller runs a listener the stack calls back through
|
|
177
|
-
host.docker.internal:N (e.g. 4600): sent as callback_port.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
173
|
+
host.docker.internal:N (e.g. 4600): sent as callback_port. On a REMOTE
|
|
174
|
+
lease run first probes the host with a throwaway reverse forward and opens
|
|
175
|
+
-R 127.0.0.1:N:127.0.0.1:N only when the host keeps it on loopback (sshd
|
|
176
|
+
GatewayPorts no/clientspecified); otherwise exit 5 CALLBACK_NOT_LOOPBACK
|
|
177
|
+
(BOT-2184).
|
|
181
178
|
--bundle-max-mb N Remote-lease bundle size cap in MB (default 50, compressed). The bundle
|
|
182
179
|
excludes .bb-stack.json, node_modules, supabase/.temp|.branches and
|
|
183
180
|
untracked-secret dotenv files (.env, .env.local, .env.*.local).
|
|
@@ -218,14 +215,17 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
|
|
|
218
215
|
4 STACK_EXECUTION_MODE_MISMATCH the slot already holds a lease in another mode/host — stack done it first
|
|
219
216
|
(the four codes above create NO lease)
|
|
220
217
|
5 BUNDLE_UPLOAD_FAILED the signed bundle upload failed (the lease is released first)
|
|
221
|
-
5 CALLBACK_NOT_LOOPBACK
|
|
222
|
-
|
|
218
|
+
5 CALLBACK_NOT_LOOPBACK --callback-port: the GatewayPorts probe (before any real -R) or the
|
|
219
|
+
post-forward check found the host's -R listener not loopback-only
|
|
220
|
+
(sshd GatewayPorts yes), or could not check it (no lsof, no listener)
|
|
221
|
+
5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
|
|
222
|
+
service), the host refused this run's -R because another run took the
|
|
223
|
+
port first, or another listener shares the port; this run holds no -R —
|
|
224
|
+
pick another port or wait
|
|
223
225
|
4 BUNDLE_TOO_LARGE --remote and the bundle is over --bundle-max-mb
|
|
224
226
|
4 BUNDLE_SECRET_ENV a --bundle-include file holds a live-credential-shaped value (names the key)
|
|
225
227
|
4 BUNDLE_INCLUDE_* a --bundle-include path is missing, not a file, too large, or excluded
|
|
226
228
|
(BUNDLE_UNSAFE_LINK: it resolves outside the stack); no lease is created
|
|
227
|
-
4 CALLBACK_PORT_UNSUPPORTED_REMOTE --callback-port on a remote lease (refused until BOT-2184;
|
|
228
|
-
under auto the remote lease is released first)
|
|
229
229
|
6 TUNNEL_LOST the forward exited mid-child: the child is stopped, the lease released,
|
|
230
230
|
park_reason tunnel_lost (its failures were transport, not product)
|
|
231
231
|
Every remote failure after the lease exists still runs \`stack done\` and closes the forwards.
|
|
@@ -435,8 +435,6 @@ export function parseStackArgs(argv) {
|
|
|
435
435
|
}
|
|
436
436
|
if ((opts.callbackPort != null || opts.bundleMaxBytes != null) && command !== "run") {
|
|
437
437
|
errors.push("--callback-port and --bundle-max-mb are valid only with `stack run`");
|
|
438
|
-
} else if (opts.remote && opts.callbackPort != null) {
|
|
439
|
-
errors.push(CALLBACK_PORT_REMOTE_REFUSAL);
|
|
440
438
|
}
|
|
441
439
|
if (opts.bundleInclude.length && command !== "run") {
|
|
442
440
|
errors.push("--bundle-include is valid only with `stack run` (only run builds a remote-lease bundle)");
|
|
@@ -3189,12 +3187,6 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3189
3187
|
};
|
|
3190
3188
|
}
|
|
3191
3189
|
let sshTarget = request.data.ssh_target ?? request.data.connection?.ssh_target ?? null;
|
|
3192
|
-
if (remoteLease && opts.callbackPort != null) {
|
|
3193
|
-
// Codex #1077 P1 / BOT-2184: `auto` placed this run remote; release before any upload
|
|
3194
|
-
// or forward rather than open an -R listener that may be exposed.
|
|
3195
|
-
const cleanup = await requestCleanup();
|
|
3196
|
-
return { exitCode: EXIT.INVALID, outcome: "error", code: CALLBACK_PORT_UNSUPPORTED_REMOTE, leaseId, cleanup, error: CALLBACK_PORT_REMOTE_REFUSAL };
|
|
3197
|
-
}
|
|
3198
3190
|
if (remoteLease) {
|
|
3199
3191
|
// Fail fast when the server SAYS the host has no SSH destination (the key is
|
|
3200
3192
|
// present but empty); a server that only reports it on the active read is
|
|
@@ -3335,10 +3327,6 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3335
3327
|
const servedRemote = remoteLease || current.data.execution_mode === "remote";
|
|
3336
3328
|
if (servedRemote && !remoteLease) {
|
|
3337
3329
|
stderrLine(`stack: lease ${leaseId} moved to ${placement.hostKey ?? "another host"} while queued (${placement.placementReason ?? "moved"}) — forwarding to it`);
|
|
3338
|
-
if (opts.callbackPort != null) {
|
|
3339
|
-
const cleanup = await requestCleanup();
|
|
3340
|
-
return { exitCode: EXIT.INVALID, outcome: "error", code: CALLBACK_PORT_UNSUPPORTED_REMOTE, leaseId, cleanup, error: CALLBACK_PORT_REMOTE_REFUSAL };
|
|
3341
|
-
}
|
|
3342
3330
|
}
|
|
3343
3331
|
let childConnection = current.data.connection;
|
|
3344
3332
|
if (servedRemote) {
|
package/src/wait-core.mjs
CHANGED
|
@@ -234,8 +234,9 @@ const VALIDATORS = {
|
|
|
234
234
|
// BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run or PR
|
|
235
235
|
// (they must already exist at arm time) and wakes on the first terminal CI run for
|
|
236
236
|
// the repo delivered AFTER the wait was armed — the post-merge / cron train case.
|
|
237
|
-
// Optional branch=/workflow= filter that first run;
|
|
238
|
-
// scope=next
|
|
237
|
+
// Optional branch=/workflow= filter that first run; branch is valid only with
|
|
238
|
+
// scope=next. A workflow also pins a level-triggered sha= recovery to one
|
|
239
|
+
// canonical workflow identity.
|
|
239
240
|
// BOT-1685 — scope=next also accepts an optional sha= FILTER (distinct from the
|
|
240
241
|
// level-triggered sha= selector): a wait armed BEFORE re-dispatching a run for a
|
|
241
242
|
// known commit then wakes on the NEXT terminal run for THAT commit, instead of
|
|
@@ -253,10 +254,11 @@ const VALIDATORS = {
|
|
|
253
254
|
if ((shaIsNextFilter ? selectors.length - 1 : selectors.length) !== 1) {
|
|
254
255
|
throw new Error("ci needs exactly one of scope=latest|next | run_id=<id> | sha=<sha>");
|
|
255
256
|
}
|
|
256
|
-
const
|
|
257
|
+
const hasBranchFilter = p.branch !== undefined;
|
|
258
|
+
const hasWorkflowFilter = p.workflow !== undefined;
|
|
257
259
|
if (p.scope !== undefined) {
|
|
258
260
|
if (p.scope === "latest") {
|
|
259
|
-
if (
|
|
261
|
+
if (hasBranchFilter || hasWorkflowFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
|
|
260
262
|
if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
|
|
261
263
|
throw new Error("ci scope=latest needs pr=<number>");
|
|
262
264
|
}
|
|
@@ -278,13 +280,17 @@ const VALIDATORS = {
|
|
|
278
280
|
}
|
|
279
281
|
throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
|
|
280
282
|
}
|
|
281
|
-
if (
|
|
283
|
+
if (hasBranchFilter || (hasWorkflowFilter && p.sha === undefined)) {
|
|
284
|
+
throw new Error("ci branch= is only valid with scope=next; workflow= also accepts an exact sha selector");
|
|
285
|
+
}
|
|
282
286
|
if (p.run_id !== undefined) {
|
|
283
287
|
if (!String(p.run_id).trim()) throw new Error("ci run_id must be non-empty");
|
|
284
288
|
return { repo, scope: "run_id", runId: String(p.run_id).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
|
|
285
289
|
}
|
|
286
290
|
if (!String(p.sha).trim()) throw new Error("ci sha must be non-empty");
|
|
287
|
-
|
|
291
|
+
const workflow = p.workflow === undefined ? null : String(p.workflow).trim();
|
|
292
|
+
if (p.workflow !== undefined && workflow === "") throw new Error("ci workflow= must be non-empty");
|
|
293
|
+
return { repo, scope: "sha", sha: String(p.sha).trim(), workflow, pr: p.pr != null ? String(p.pr).trim() : null };
|
|
288
294
|
},
|
|
289
295
|
"staging-green"(p) {
|
|
290
296
|
const repo = p.repo != null ? String(p.repo).trim() : null;
|
|
@@ -986,7 +992,12 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
986
992
|
// Repo is pinned via the payload (subject may be run:<id> for uncorrelated runs).
|
|
987
993
|
if (String(signal.payload?.repo ?? "").toLowerCase() !== params.repo.toLowerCase()) return false;
|
|
988
994
|
if (params.scope === "run_id") return String(signal.payload?.run_id) === params.runId;
|
|
989
|
-
if (params.scope === "sha")
|
|
995
|
+
if (params.scope === "sha") {
|
|
996
|
+
if (signal.payload?.head_sha !== params.sha) return false;
|
|
997
|
+
if (params.workflow == null) return true;
|
|
998
|
+
const workflow = signal.payload?.workflow_name;
|
|
999
|
+
return typeof workflow === "string" && workflow.trim().toLowerCase() === params.workflow.trim().toLowerCase();
|
|
1000
|
+
}
|
|
990
1001
|
if (params.scope === "next") {
|
|
991
1002
|
// BOT-1507 — live-only: the first TERMINAL run for the repo. Never keys on
|
|
992
1003
|
// subject_key (it is run:<id> for post-merge runs and owner/repo#n for PR
|