From 1091b5be67b49e0ee9ec5bf0d5f881e816ee9201 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 17 Jul 2026 16:25:00 +0800 Subject: [PATCH 1/4] fix(sandbox): treat an already-active port forward as recovery success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensureSandboxPortForwardForPort` trusted the `openshell forward start` exit code: any non-zero status returned false and surfaced as "the dashboard/API host forward could not be restored". But OpenShell exits non-zero when the port is already forwarded, so when recovery's stop -> start ran against a still-active forward (e.g. list drift left the entry stale while the port kept listening), `forward start` reported "already forwarded" and recover failed for a healthy forward — the #7085 symptom on `nemohermes recover`. The exit code is not the authoritative success signal; the live forward list is (as onboard/forward-start.ts already documents). On a non-zero start, re-probe `isSandboxPortForwardHealthy`: accept an already-active, target-owned forward as idempotent success, and still fail when no target-owned forward is active so a genuine start failure is not masked. Adds unit coverage for both the already-forwarded (success) and genuinely-absent (failure) non-zero-start cases. Closes #7085 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jason Ma --- src/lib/actions/sandbox/forward-recovery.ts | 13 +++- test/process-recovery-forward-failure.test.ts | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 10a9fd2eed3..bd366179be5 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -242,7 +242,18 @@ export function ensureSandboxPortForwardForPort( stdio: "ignore", }, ); - if (startResult.status !== 0) return false; + // A non-zero `forward start` exit is not authoritative: OpenShell exits + // non-zero when the port is already forwarded, which is a benign no-op for + // recovery. Trust the live forward list, not the exit code (mirrors + // src/lib/onboard/forward-start.ts). Re-probe on failure: an already-active, + // target-owned forward is accepted as success; a genuinely absent forward + // still fails here, so a real start failure is not masked (#7085). + if (startResult.status !== 0) { + if (isSandboxPortForwardHealthy(sandboxName, port, expectedBind) === true) { + return acceptSuccessfulForward(); + } + return false; + } // `forward start --background` can return before its authoritative list // entry becomes visible. Poll for the exact live sandbox+port owner instead diff --git a/test/process-recovery-forward-failure.test.ts b/test/process-recovery-forward-failure.test.ts index 70845966d00..82014284d0e 100644 --- a/test/process-recovery-forward-failure.test.ts +++ b/test/process-recovery-forward-failure.test.ts @@ -12,6 +12,9 @@ const requireSource = createRequire(import.meta.url); const { checkAndRecoverSandboxProcesses: checkAndRecoverSandboxProcessesImpl } = requireSource( "../src/lib/actions/sandbox/process-recovery.ts", ) as typeof import("../src/lib/actions/sandbox/process-recovery.js"); +const { ensureSandboxPortForwardForPort } = requireSource( + "../src/lib/actions/sandbox/forward-recovery.ts", +) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); function checkAndRecoverSandboxProcesses( sandboxName: string, @@ -190,3 +193,70 @@ beta 127.0.0.1 18789 12345 dead`, ); }); }); + +describe("ensureSandboxPortForwardForPort already-forwarded idempotency (#7085)", () => { + it("accepts an already-active target-owned forward when `forward start` exits non-zero", () => { + // Forward visibility is fixed by mocks, so the production settle window is unnecessary. + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.ts"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.ts"); + + // The port is listening throughout; OpenShell's forward list only shows the + // live owner after the (idempotent) start, modelling the stale-list drift + // that makes recovery attempt a stop -> start on an already-active forward. + let started = false; + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: started + ? `SANDBOX BIND PORT PID STATUS +beta 127.0.0.1 18791 12345 running` + : `SANDBOX BIND PORT PID STATUS`, + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + if (args[0] === "forward" && args[1] === "start") { + // OpenShell exits non-zero because the port is already forwarded. + started = true; + return { status: 1 } as never; + } + return { status: 0 } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "18791", "beta"], + expect.objectContaining({ ignoreError: true }), + ); + }); + + it("still fails when `forward start` exits non-zero and no target-owned forward is active", () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.ts"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.ts"); + + // No live owner row ever appears: a genuine start failure must not be + // masked by the idempotency re-probe. + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(false); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: "SANDBOX BIND PORT PID STATUS", + }); + vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + return { status: args[0] === "forward" && args[1] === "start" ? 1 : 0 } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(false); + }); +}); From 59cab8aef135366e6fbfa2c4e743c6b1a59de28a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 17 Jul 2026 11:14:32 -0400 Subject: [PATCH 2/4] fix(sandbox): settle idempotent forward recovery Signed-off-by: Julie Yaunches --- src/lib/actions/sandbox/forward-recovery.ts | 16 +--- test/process-recovery-forward-failure.test.ts | 84 ++++++++++++++++--- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index bd366179be5..796968cb710 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -242,18 +242,10 @@ export function ensureSandboxPortForwardForPort( stdio: "ignore", }, ); - // A non-zero `forward start` exit is not authoritative: OpenShell exits - // non-zero when the port is already forwarded, which is a benign no-op for - // recovery. Trust the live forward list, not the exit code (mirrors - // src/lib/onboard/forward-start.ts). Re-probe on failure: an already-active, - // target-owned forward is accepted as success; a genuinely absent forward - // still fails here, so a real start failure is not masked (#7085). - if (startResult.status !== 0) { - if (isSandboxPortForwardHealthy(sandboxName, port, expectedBind) === true) { - return acceptSuccessfulForward(); - } - return false; - } + // OpenShell exits non-zero when the port is already forwarded. If its local + // listener is still reachable, settle against the authoritative forward list + // below; otherwise preserve the fast failure for a genuinely absent forward. + if (startResult.status !== 0 && !isLocalForwardReachable(port)) return false; // `forward start --background` can return before its authoritative list // entry becomes visible. Poll for the exact live sandbox+port owner instead diff --git a/test/process-recovery-forward-failure.test.ts b/test/process-recovery-forward-failure.test.ts index 82014284d0e..f4cdea6342f 100644 --- a/test/process-recovery-forward-failure.test.ts +++ b/test/process-recovery-forward-failure.test.ts @@ -7,14 +7,14 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import * as forwardHealth from "../src/lib/actions/sandbox/forward-health.js"; +import { ensureSandboxPortForwardForPort } from "../src/lib/actions/sandbox/forward-recovery.js"; +import * as openshellRuntime from "../src/lib/adapters/openshell/runtime.js"; const requireSource = createRequire(import.meta.url); const { checkAndRecoverSandboxProcesses: checkAndRecoverSandboxProcessesImpl } = requireSource( "../src/lib/actions/sandbox/process-recovery.ts", ) as typeof import("../src/lib/actions/sandbox/process-recovery.js"); -const { ensureSandboxPortForwardForPort } = requireSource( - "../src/lib/actions/sandbox/forward-recovery.ts", -) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); function checkAndRecoverSandboxProcesses( sandboxName: string, @@ -198,8 +198,6 @@ describe("ensureSandboxPortForwardForPort already-forwarded idempotency (#7085)" it("accepts an already-active target-owned forward when `forward start` exits non-zero", () => { // Forward visibility is fixed by mocks, so the production settle window is unnecessary. vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); - const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.ts"); - const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.ts"); // The port is listening throughout; OpenShell's forward list only shows the // live owner after the (idempotent) start, modelling the stale-list drift @@ -217,12 +215,10 @@ beta 127.0.0.1 18791 12345 running` .spyOn(openshellRuntime, "runOpenshell") .mockImplementation((rawArgs: unknown) => { const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; - if (args[0] === "forward" && args[1] === "start") { - // OpenShell exits non-zero because the port is already forwarded. - started = true; - return { status: 1 } as never; - } - return { status: 0 } as never; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + started ||= isForwardStart; + // OpenShell exits non-zero because the port is already forwarded. + return { status: Number(isForwardStart) } as never; }); expect( @@ -238,8 +234,6 @@ beta 127.0.0.1 18791 12345 running` it("still fails when `forward start` exits non-zero and no target-owned forward is active", () => { vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); - const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.ts"); - const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.ts"); // No live owner row ever appears: a genuine start failure must not be // masked by the idempotency re-probe. @@ -259,4 +253,68 @@ beta 127.0.0.1 18791 12345 running` ), ).toBe(false); }); + + it("waits for delayed target ownership after a non-zero `forward start`", () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "250"); + let started = false; + let postStartProbes = 0; + + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => { + postStartProbes += Number(started); + return { + status: 0, + output: + postStartProbes >= 2 + ? `SANDBOX BIND PORT PID STATUS +beta 127.0.0.1 18791 12345 running` + : "SANDBOX BIND PORT PID STATUS", + }; + }); + vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + started ||= isForwardStart; + return { status: Number(isForwardStart) } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(true); + expect(postStartProbes).toBe(2); + }); + + it("rejects delayed ownership by another sandbox after a non-zero `forward start`", () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "250"); + let started = false; + let postStartProbes = 0; + + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => { + postStartProbes += Number(started); + return { + status: 0, + output: + postStartProbes >= 2 + ? `SANDBOX BIND PORT PID STATUS +gamma 127.0.0.1 18791 12345 running` + : "SANDBOX BIND PORT PID STATUS", + }; + }); + vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + started ||= isForwardStart; + return { status: Number(isForwardStart) } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(false); + expect(postStartProbes).toBe(2); + }); }); From a735921d3e54aa9b9a7e47959cfdcadfbf83c901 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 17 Jul 2026 11:32:00 -0400 Subject: [PATCH 3/4] test(sandbox): cover unowned forward settlement Signed-off-by: Julie Yaunches --- src/lib/actions/sandbox/forward-recovery.ts | 9 ++++--- test/process-recovery-forward-failure.test.ts | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 796968cb710..24a460f85f7 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -242,9 +242,12 @@ export function ensureSandboxPortForwardForPort( stdio: "ignore", }, ); - // OpenShell exits non-zero when the port is already forwarded. If its local - // listener is still reachable, settle against the authoritative forward list - // below; otherwise preserve the fast failure for a genuinely absent forward. + // OpenShell 0.0.85 returns an error when start preflight finds a validated + // live forward for the requested port. Recovery cannot change that upstream + // CLI contract, so a reachable listener settles against the authoritative + // forward list below; an absent listener still fails fast. Remove this + // tolerance once every supported OpenShell release makes `forward start` + // idempotent for an already-tracked live forward. if (startResult.status !== 0 && !isLocalForwardReachable(port)) return false; // `forward start --background` can return before its authoritative list diff --git a/test/process-recovery-forward-failure.test.ts b/test/process-recovery-forward-failure.test.ts index f4cdea6342f..9ed20d64c4b 100644 --- a/test/process-recovery-forward-failure.test.ts +++ b/test/process-recovery-forward-failure.test.ts @@ -254,6 +254,30 @@ beta 127.0.0.1 18791 12345 running` ).toBe(false); }); + it("rejects a reachable listener that never gains authoritative ownership", () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "25"); + let started = false; + + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + const captureOpenshell = vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 0, + output: "SANDBOX BIND PORT PID STATUS", + }); + vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + started ||= isForwardStart; + return { status: Number(isForwardStart) } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(false); + expect(captureOpenshell.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + it("waits for delayed target ownership after a non-zero `forward start`", () => { vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "250"); let started = false; From 0bc0e6077e4e74714b02474d825e3a2168874b44 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 15:56:26 -0700 Subject: [PATCH 4/4] fix(sandbox): reconcile ownerless active forwards Allow the idempotent forward start path to restore authoritative ownership after a nonzero settle wait while retaining fail-closed behavior for unavailable or wrong-owner metadata. Co-authored-by: Jason Ma Signed-off-by: Apurv Kumaria --- src/lib/actions/sandbox/forward-recovery.ts | 15 +++-- test/process-recovery-forward-failure.test.ts | 56 ++++++++++++++++--- test/process-recovery.test.ts | 18 +++--- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 24a460f85f7..e0d1686c9f6 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -196,8 +196,12 @@ export function ensureSandboxPortForwardForPort( // before the old SSH listener is guaranteed to release its host port. A // blind stop -> start can therefore collide with the just-stopped process. // Preserve authoritative owner metadata while waiting: accept a target- - // owned forward that recovered on its own, reject another sandbox, and only - // start after an otherwise-unowned local listener has actually quiesced. + // owned forward that recovered on its own, reject another sandbox, and + // prefer starting only after an otherwise-unowned local listener has + // quiesced. If an authoritative list remains ownerless while the listener + // stays reachable, `forward start` is still the reconciliation operation: + // its result is accepted below only after the list reports the exact target + // owner. An unavailable list and forced bind replacement remain fail-closed. // NemoClaw must compensate while the supported OpenShell 0.0.85 // contract remains supported; test/process-recovery.test.ts locks both the // delayed-release and fail-closed cases. Remove this wait only after every @@ -209,7 +213,7 @@ export function ensureSandboxPortForwardForPort( health: forwardHealth, portReleased: false, }; - const stopSettled = waitUntil( + waitUntil( () => { stopState.health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); stopState.portReleased = !isLocalForwardReachable(port); @@ -227,7 +231,10 @@ export function ensureSandboxPortForwardForPort( }, ); if (stopState.health === true && !forceRestart) return acceptSuccessfulForward(); - if (stopState.health === "occupied" || !stopSettled || !stopState.portReleased) return false; + if (stopState.health === "occupied") return false; + if (!stopState.portReleased && (forceRestart || stopState.health === null)) { + return false; + } } if (!beforeStart()) return false; diff --git a/test/process-recovery-forward-failure.test.ts b/test/process-recovery-forward-failure.test.ts index 9ed20d64c4b..779f7dc5357 100644 --- a/test/process-recovery-forward-failure.test.ts +++ b/test/process-recovery-forward-failure.test.ts @@ -195,6 +195,40 @@ beta 127.0.0.1 18789 12345 dead`, }); describe("ensureSandboxPortForwardForPort already-forwarded idempotency (#7085)", () => { + it("reconciles a reachable ownerless listener with a nonzero recovery wait", () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "25"); + let started = false; + + // The pre-start list remains ownerless for the full stop-settle window, + // while OpenShell's idempotent start refreshes the authoritative owner row. + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: started + ? `SANDBOX BIND PORT PID STATUS +beta 127.0.0.1 18791 12345 running` + : "SANDBOX BIND PORT PID STATUS", + })); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + started ||= isForwardStart; + return { status: Number(isForwardStart) } as never; + }); + + expect( + withFakeOpenshellBinary(() => + ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), + ), + ).toBe(true); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "18791", "beta"], + expect.objectContaining({ ignoreError: true }), + ); + }); + it("accepts an already-active target-owned forward when `forward start` exits non-zero", () => { // Forward visibility is fixed by mocks, so the production settle window is unnecessary. vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); @@ -256,26 +290,30 @@ beta 127.0.0.1 18791 12345 running` it("rejects a reachable listener that never gains authoritative ownership", () => { vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "25"); - let started = false; - vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => started); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); const captureOpenshell = vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ status: 0, output: "SANDBOX BIND PORT PID STATUS", }); - vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((rawArgs: unknown) => { - const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; - const isForwardStart = args[0] === "forward" && args[1] === "start"; - started ||= isForwardStart; - return { status: Number(isForwardStart) } as never; - }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const isForwardStart = args[0] === "forward" && args[1] === "start"; + return { status: Number(isForwardStart) } as never; + }); expect( withFakeOpenshellBinary(() => ensureSandboxPortForwardForPort("beta", 18791, { expectedBind: "127.0.0.1" }), ), ).toBe(false); - expect(captureOpenshell.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(captureOpenshell.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "18791", "beta"], + expect.objectContaining({ ignoreError: true }), + ); }); it("waits for delayed target ownership after a non-zero `forward start`", () => { diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index 0e4b2df6a83..a240372bd14 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -282,23 +282,25 @@ beta 127.0.0.1 18789 12345 running`; ); }); - it("fails closed without starting when an unowned stopped-forward listener never releases", () => { + it("fails closed after start reconciliation when a listener remains unowned", () => { const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); - vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "150"); + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "25"); vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ status: 0, output: "" }); vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); const runOpenshell = vi .spyOn(openshellRuntime, "runOpenshell") - .mockReturnValue({ status: 0 } as never); + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + return { status: Number(args[0] === "forward" && args[1] === "start") } as never; + }); expect(ensureSandboxPortForwardForPort("beta", 8642)).toBe(false); - expect( - runOpenshell.mock.calls.some( - ([rawArgs]) => Array.isArray(rawArgs) && rawArgs[0] === "forward" && rawArgs[1] === "start", - ), - ).toBe(false); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "8642", "beta"], + { ignoreError: true, stdio: "ignore" }, + ); }); it("checkAndRecoverSandboxProcesses re-establishes an active Teams messaging host forward from a compact plan when the dashboard forward is healthy", () => {