@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.45.4",
3
+ "version": "1.45.7",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
@@ -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
@@ -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 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.
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. 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).
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 (reserved for BOT-2184) the host's -R listener is not loopback-only
222
- (sshd GatewayPorts yes) or could not be checked with lsof
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 clear+self-heal+retry once on a
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. Mirror the other consumers: when the FIRST auth came from the per-worktree
865
- * cache and the RPC returns an auth rejection, clear the cache and self-heal once,
866
- * then let the caller retry with the fresh headers. Returns the fresh headers, or
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(), authProvider = stackAuthHeader, clear = clearAgentState,
867
+ cwd = process.cwd(), renew = selfHealAgentSession,
871
868
  } = {}) {
872
869
  if (auth?.__source !== "cache") return null;
873
- await clear(cwd);
874
- const fresh = await authProvider();
875
- // BOT-1750 (Codex R8 P2): retry ONLY with a genuinely self-healed session. If the
876
- // client key or binding vanished after clearing the cache, authProvider falls
877
- // through to the OAuth/MCP operator credential — a DIFFERENT principal whose token
878
- // also differs, so a bare "different token" check would retry as the wrong agent.
879
- // Require source "minted" AND a well-formed, changed session token.
880
- if (fresh?.__source !== "minted") return null;
881
- const token = fresh["x-agent-api-key"];
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 fresh;
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, clears +
892
- * self-heals + retries with the fresh credential (every later `call` reuses it).
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
- * clear the per-worktree cache and self-heal once. Only a well-formed agent SESSION token
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(), authProvider = stackAuthHeader, clear = clearAgentState,
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
- const isFreshSession = (h) => (h?.__source === "cache" || h?.__source === "minted")
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 clears+self-heals+retries the FIRST auth rejection.
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 clear + self-heal + retry once. Route them all
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 clears+self-heals+retries the
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) {