@botbuddy/cli 1.45.4 → 1.45.7
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/agent-session.mjs +0 -0
- package/src/stack-tunnel.mjs +234 -22
- package/src/stack.mjs +46 -64
package/package.json
CHANGED
package/src/agent-session.mjs
CHANGED
|
Binary file
|
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
|
@@ -33,8 +33,7 @@ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
|
|
|
33
33
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
34
34
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
35
35
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
36
|
-
import { resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
37
|
-
import { clearAgentState } from "./agent-state.mjs";
|
|
36
|
+
import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
38
37
|
import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
|
|
39
38
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
40
39
|
import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
@@ -47,17 +46,13 @@ import { bold, dim, yellow } from "./utils.mjs";
|
|
|
47
46
|
export const STACK_SCHEMA_VERSION = 1;
|
|
48
47
|
|
|
49
48
|
/**
|
|
50
|
-
* Codex #1077 P1: an sshd with `GatewayPorts yes` binds a `-R` forward on every
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* Codex #1077 P1 / BOT-2184: an sshd with `GatewayPorts yes` binds a `-R` forward on every
|
|
50
|
+
* interface. A remote lease with --callback-port therefore runs the pre-forward GatewayPorts
|
|
51
|
+
* probe (`probeGatewayPorts` in stack-tunnel.mjs) before the real `-R` exists, and refuses
|
|
52
|
+
* with exit 5 CALLBACK_NOT_LOOPBACK when the host would expose it. The code below is kept
|
|
53
|
+
* only so older automation that matched on it still resolves; no path emits it any more.
|
|
55
54
|
*/
|
|
56
55
|
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
56
|
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
62
57
|
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
63
58
|
/**
|
|
@@ -174,10 +169,11 @@ ${bold("run OPTIONS")}
|
|
|
174
169
|
Default (neither flag): execution "auto" — co-located if it admits now,
|
|
175
170
|
else a remote host with capacity; the bundle digest is sent up front.
|
|
176
171
|
--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
|
-
|
|
172
|
+
host.docker.internal:N (e.g. 4600): sent as callback_port. On a REMOTE
|
|
173
|
+
lease run first probes the host with a throwaway reverse forward and opens
|
|
174
|
+
-R 127.0.0.1:N:127.0.0.1:N only when the host keeps it on loopback (sshd
|
|
175
|
+
GatewayPorts no/clientspecified); otherwise exit 5 CALLBACK_NOT_LOOPBACK
|
|
176
|
+
(BOT-2184).
|
|
181
177
|
--bundle-max-mb N Remote-lease bundle size cap in MB (default 50, compressed). The bundle
|
|
182
178
|
excludes .bb-stack.json, node_modules, supabase/.temp|.branches and
|
|
183
179
|
untracked-secret dotenv files (.env, .env.local, .env.*.local).
|
|
@@ -218,14 +214,17 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
|
|
|
218
214
|
4 STACK_EXECUTION_MODE_MISMATCH the slot already holds a lease in another mode/host — stack done it first
|
|
219
215
|
(the four codes above create NO lease)
|
|
220
216
|
5 BUNDLE_UPLOAD_FAILED the signed bundle upload failed (the lease is released first)
|
|
221
|
-
5 CALLBACK_NOT_LOOPBACK
|
|
222
|
-
|
|
217
|
+
5 CALLBACK_NOT_LOOPBACK --callback-port: the GatewayPorts probe (before any real -R) or the
|
|
218
|
+
post-forward check found the host's -R listener not loopback-only
|
|
219
|
+
(sshd GatewayPorts yes), or could not check it (no lsof, no listener)
|
|
220
|
+
5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
|
|
221
|
+
service), the host refused this run's -R because another run took the
|
|
222
|
+
port first, or another listener shares the port; this run holds no -R —
|
|
223
|
+
pick another port or wait
|
|
223
224
|
4 BUNDLE_TOO_LARGE --remote and the bundle is over --bundle-max-mb
|
|
224
225
|
4 BUNDLE_SECRET_ENV a --bundle-include file holds a live-credential-shaped value (names the key)
|
|
225
226
|
4 BUNDLE_INCLUDE_* a --bundle-include path is missing, not a file, too large, or excluded
|
|
226
227
|
(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
228
|
6 TUNNEL_LOST the forward exited mid-child: the child is stopped, the lease released,
|
|
230
229
|
park_reason tunnel_lost (its failures were transport, not product)
|
|
231
230
|
Every remote failure after the lease exists still runs \`stack done\` and closes the forwards.
|
|
@@ -435,8 +434,6 @@ export function parseStackArgs(argv) {
|
|
|
435
434
|
}
|
|
436
435
|
if ((opts.callbackPort != null || opts.bundleMaxBytes != null) && command !== "run") {
|
|
437
436
|
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
437
|
}
|
|
441
438
|
if (opts.bundleInclude.length && command !== "run") {
|
|
442
439
|
errors.push("--bundle-include is valid only with `stack run` (only run builds a remote-lease bundle)");
|
|
@@ -830,7 +827,7 @@ export async function stackAuthHeader({
|
|
|
830
827
|
// and silently authenticate as the OAuth/MCP-key principal, which is the exact
|
|
831
828
|
// principal switch the catch above guards against.
|
|
832
829
|
if (typeof credential.token === "string" && AGENT_KEY_RE.test(credential.token)) {
|
|
833
|
-
return withSource({ "x-agent-api-key": credential.token }, credential.source);
|
|
830
|
+
return withSource({ "x-agent-api-key": credential.token }, credential.source, credential.identity);
|
|
834
831
|
}
|
|
835
832
|
throw Object.assign(new Error(`resolved agent session token has an unexpected shape (source: ${credential.source ?? "unknown"}); re-run \`bb doctor --fix\``), { code: "session_token_malformed" });
|
|
836
833
|
}
|
|
@@ -850,37 +847,39 @@ export async function stackAuthHeader({
|
|
|
850
847
|
}
|
|
851
848
|
|
|
852
849
|
// BOT-1750 (Codex R7 P2): carry the credential SOURCE on the returned headers as a
|
|
853
|
-
// NON-enumerable property, so callers can
|
|
850
|
+
// NON-enumerable property, so callers can renew+retry once on a
|
|
854
851
|
// server-rejected cached session (parity with run/test/pw/wait) without the source
|
|
855
852
|
// ever leaking into the spread fetch headers.
|
|
856
|
-
function withSource(headers, source) {
|
|
853
|
+
function withSource(headers, source, identity = null) {
|
|
857
854
|
Object.defineProperty(headers, "__source", { value: source ?? null, enumerable: false });
|
|
855
|
+
Object.defineProperty(headers, "__identity", { value: identity, enumerable: false });
|
|
858
856
|
return headers;
|
|
859
857
|
}
|
|
860
858
|
|
|
861
859
|
/**
|
|
862
860
|
* BOT-1750 (Codex R7 P2): a locally-fresh cache whose token was revoked server-side
|
|
863
861
|
* (e.g. a concurrent re-registration rotated the session) is rejected at the lease
|
|
864
|
-
* RPC.
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
* null when no retry applies (not cache-sourced, or the refresh could not authenticate).
|
|
862
|
+
* RPC. Renew once without deleting the cache or losing lease ownership identity.
|
|
863
|
+
* The shared lock re-reads and verifies the cache; a concurrent renewal wins.
|
|
864
|
+
* Failure leaves the existing cache untouched and propagates its typed error.
|
|
868
865
|
*/
|
|
869
866
|
export async function refreshRejectedCacheAuth(auth, {
|
|
870
|
-
cwd = process.cwd(),
|
|
867
|
+
cwd = process.cwd(), renew = selfHealAgentSession,
|
|
871
868
|
} = {}) {
|
|
872
869
|
if (auth?.__source !== "cache") return null;
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
870
|
+
return renewStackSession(auth, { cwd, renew });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function renewStackSession(auth, { cwd, renew }) {
|
|
874
|
+
const credential = await renew({ cwd, force: true,
|
|
875
|
+
rejectedCredential: { token: auth["x-agent-api-key"], identity: auth.__identity } });
|
|
876
|
+
// BOT-1750 (Codex R8 P2): the renewal adapter must never yield an operator
|
|
877
|
+
// principal. "cache" is permitted
|
|
878
|
+
// only for a newer same-identity session verified inside the renewal lock.
|
|
879
|
+
if (credential?.source !== "minted" && credential?.source !== "cache") return null;
|
|
880
|
+
const token = credential.token;
|
|
882
881
|
if (typeof token !== "string" || !AGENT_KEY_RE.test(token) || token === auth["x-agent-api-key"]) return null;
|
|
883
|
-
return
|
|
882
|
+
return withSource({ "x-agent-api-key": token }, credential.source, credential.identity);
|
|
884
883
|
}
|
|
885
884
|
|
|
886
885
|
/**
|
|
@@ -888,8 +887,8 @@ export async function refreshRejectedCacheAuth(auth, {
|
|
|
888
887
|
* server-rejected cached session ONCE — the owner-scoped management commands
|
|
889
888
|
* (`status`/`touch`/`done`) need the same recovery as the lease-creating paths, or a
|
|
890
889
|
* revoked-but-locally-fresh cache leaves an active lease unreleasable. `init()`
|
|
891
|
-
* resolves auth; `call()` runs the RPC and, on the FIRST auth rejection,
|
|
892
|
-
*
|
|
890
|
+
* resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, renews +
|
|
891
|
+
* retries with the fresh credential (every later `call` reuses it).
|
|
893
892
|
*/
|
|
894
893
|
export function retryingStackCall({
|
|
895
894
|
callTool = callToolJson, authProvider = stackAuthHeader,
|
|
@@ -1364,25 +1363,18 @@ function abortableSleep(ms, signal) {
|
|
|
1364
1363
|
* BOT-2099: re-establish the credential a parked lease waits with. On a transport failure
|
|
1365
1364
|
* the credential is fine — keep it. On an auth rejection, first re-read (another process in
|
|
1366
1365
|
* this worktree may already have re-minted, and minting again would supersede it), then
|
|
1367
|
-
*
|
|
1366
|
+
* renew under the shared cache lock without removing recovery identity. Only a well-formed agent SESSION token
|
|
1368
1367
|
* replaces a session credential: the operator fallback is a different principal that
|
|
1369
1368
|
* cannot see the lease (BOT-1750 R8), so it is never adopted. Throws the mint error, which
|
|
1370
1369
|
* the park treats as one more failed attempt.
|
|
1371
1370
|
*/
|
|
1372
1371
|
export async function reauthenticateParkedLease(current, kind, {
|
|
1373
|
-
cwd = process.cwd(),
|
|
1372
|
+
cwd = process.cwd(), renew = selfHealAgentSession,
|
|
1374
1373
|
} = {}) {
|
|
1375
1374
|
if (kind !== "auth") return current;
|
|
1376
1375
|
const source = current?.__source ?? null;
|
|
1377
1376
|
if (source !== "cache" && source !== "minted") return current;
|
|
1378
|
-
|
|
1379
|
-
&& typeof h["x-agent-api-key"] === "string" && AGENT_KEY_RE.test(h["x-agent-api-key"])
|
|
1380
|
-
&& h["x-agent-api-key"] !== current["x-agent-api-key"];
|
|
1381
|
-
const reread = await authProvider();
|
|
1382
|
-
if (isFreshSession(reread)) return reread;
|
|
1383
|
-
await clear(cwd);
|
|
1384
|
-
const minted = await authProvider();
|
|
1385
|
-
return isFreshSession(minted) ? minted : current;
|
|
1377
|
+
return (await renewStackSession(current, { cwd, renew })) ?? current;
|
|
1386
1378
|
}
|
|
1387
1379
|
|
|
1388
1380
|
/**
|
|
@@ -2484,7 +2476,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2484
2476
|
} = {}) {
|
|
2485
2477
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
2486
2478
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
2487
|
-
// repaired. The retrying caller
|
|
2479
|
+
// repaired. The retrying caller renews+retries the FIRST auth rejection.
|
|
2488
2480
|
const rpc = retryingStackCall({ callTool, authProvider, refresh: refreshAuth });
|
|
2489
2481
|
const auth = await rpc.init();
|
|
2490
2482
|
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
@@ -2841,7 +2833,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
2841
2833
|
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
2842
2834
|
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
2843
2835
|
// can be rotated server-side at ANY of those points, so EVERY RPC — not just the
|
|
2844
|
-
// initial request — must be able to
|
|
2836
|
+
// initial request — must be able to renew + retry once. Route them all
|
|
2845
2837
|
// through one retryingStackCall so a single self-heal re-authenticates the rest
|
|
2846
2838
|
// (heartbeat, get, cleanup release), instead of fencing a healthy child or leaking
|
|
2847
2839
|
// an unreleasable lease. Retry only when we own the credential or a test provides an
|
|
@@ -3112,7 +3104,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3112
3104
|
...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
|
|
3113
3105
|
...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
|
|
3114
3106
|
};
|
|
3115
|
-
// The retrying caller (api → rpc.call) transparently
|
|
3107
|
+
// The retrying caller (api → rpc.call) transparently renews+retries the
|
|
3116
3108
|
// FIRST auth rejection among any of request/get/touch/release.
|
|
3117
3109
|
// BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
|
|
3118
3110
|
// --wait-capacity a TRANSIENT one parks on the server's capacity wait and re-admits once.
|
|
@@ -3189,12 +3181,6 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3189
3181
|
};
|
|
3190
3182
|
}
|
|
3191
3183
|
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
3184
|
if (remoteLease) {
|
|
3199
3185
|
// Fail fast when the server SAYS the host has no SSH destination (the key is
|
|
3200
3186
|
// present but empty); a server that only reports it on the active read is
|
|
@@ -3335,10 +3321,6 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3335
3321
|
const servedRemote = remoteLease || current.data.execution_mode === "remote";
|
|
3336
3322
|
if (servedRemote && !remoteLease) {
|
|
3337
3323
|
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
3324
|
}
|
|
3343
3325
|
let childConnection = current.data.connection;
|
|
3344
3326
|
if (servedRemote) {
|