@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.45.3",
3
+ "version": "1.45.6",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const argv = [...SSH_FORWARD_OPTIONS];
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
- child.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-2048); });
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: stderr.trim() });
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 = stderr.trim();
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: stderr.trim() }); },
250
- stderrTail: () => stderr.trim(),
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 fail = (why) => tunnelError("CALLBACK_NOT_LOOPBACK",
264
- `the reverse forward for --callback-port ${port} on ${sshTarget} could not be proven loopback-only (${why}). ` +
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
- let avoid = new Set();
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
- const handle = await openSshForwards({ sshBin: bin, argv, forwards, sshTarget: target, readyTimeoutMs, spawnImpl, env });
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 interface,
51
- * and the post-forward lsof check (`verifyCallbackLoopback`) runs only AFTER the listener
52
- * exists — so the caller's callback service could be reachable from the host's network for
53
- * a few seconds. Until a pre-forward probe lands (BOT-2184), a REMOTE lease refuses
54
- * --callback-port outright (fail closed). Co-located leases are unaffected.
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. Co-located
178
- leases only for now: a REMOTE lease refuses it (exit 4
179
- CALLBACK_PORT_UNSUPPORTED_REMOTE) until the pre-forward GatewayPorts
180
- probe lands (BOT-2184).
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 (reserved for BOT-2184) the host's -R listener is not loopback-only
222
- (sshd GatewayPorts yes) or could not be checked with lsof
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; both are valid ONLY with
238
- // scope=next (a run_id/sha/latest wait already names its run).
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 hasFilter = p.branch !== undefined || p.workflow !== undefined;
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 (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
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 (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
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
- return { repo, scope: "sha", sha: String(p.sha).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
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") return signal.payload?.head_sha === params.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