From 3d2d57eba1342f57bc77e98eb7beb5d06cbf4b70 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 17:18:56 -0700 Subject: [PATCH 1/4] test(cli): isolate macOS status subprocess cleanup Signed-off-by: Senthil Ravichandran --- test/repro-2666-silent-list-status.test.ts | 66 +++++++++++++++++----- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index e092f9a5f3c..b8f9136d0c3 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -208,39 +208,69 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress { mode: 0o755 }, ); + // Keep gateway recovery inside the test-owned process boundary. The macOS + // runner has a package-managed gateway, which must not outlive this child. + fs.writeFileSync(path.join(binDir, "openshell-gateway"), "#!/usr/bin/env bash\nexit 1\n", { + mode: 0o755, + }); + seedRegistry(path.join(home, ".nemoclaw")); }); afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); + expect(fs.existsSync(home)).toBe(false); }); function runCli( args: string[], envOverrides: NodeJS.ProcessEnv = {}, - ): { code: number; stdout: string; stderr: string } { + ): { + code: number | null; + error: Error | undefined; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + } { const result = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf-8", + killSignal: "SIGKILL", timeout: 30_000, env: { ...process.env, HOME: home, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: [binDir, "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(path.delimiter), NEMOCLAW_HEALTH_POLL_COUNT: "1", NEMOCLAW_HEALTH_POLL_INTERVAL: "0", NEMOCLAW_STATUS_PROBE_TIMEOUT_MS: "2000", NEMOCLAW_TEST_NO_SLEEP: "1", NEMOCLAW_GATEWAY_PORT: "", + NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), + NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(binDir, "openshell-gateway"), ...envOverrides, }, }); return { - code: result.status ?? -1, + code: result.status, + error: result.error, + signal: result.signal, stdout: result.stdout ?? "", stderr: result.stderr ?? "", }; } + function expectCliCompleted( + result: ReturnType, + ): asserts result is ReturnType & { + code: number; + error: undefined; + signal: null; + } { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.code).not.toBeNull(); + } + function writeFakeDocker(lines: string[]): void { fs.writeFileSync(path.join(binDir, "docker"), lines.join("\n"), { mode: 0o755 }); } @@ -281,7 +311,9 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress } it("nemoclaw list never produces silent empty output when openshell is broken", () => { - const { code, stdout, stderr } = runCli(["list"]); + const result = runCli(["list"]); + expectCliCompleted(result); + const { code, stdout, stderr } = result; const combined = `${stdout}\n${stderr}`; // The exact failure mode pre-fix was exit 0 + completely empty output. // The contract here is the negation of that — the user must see @@ -298,9 +330,11 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress seedRegistry(path.join(home, ".nemoclaw"), "default-root-model"); seedRegistry(nemoclawStateRoot(home, port), "selected-port-model", port); - const { code, stdout, stderr } = runCli(["list"], { + const result = runCli(["list"], { NEMOCLAW_GATEWAY_PORT: String(port), }); + expectCliCompleted(result); + const { code, stdout, stderr } = result; const combined = `${stdout}\n${stderr}`; expect(code).toBe(0); @@ -317,9 +351,11 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress seedRegistry(path.join(home, ".nemoclaw"), "default-root-model"); seedRegistry(nemoclawStateRoot(home, port), "selected-port-model", port); - const { code, stdout, stderr } = runCli(["my-assist", "status"], { + const result = runCli(["my-assist", "status"], { NEMOCLAW_GATEWAY_PORT: String(port), }); + expectCliCompleted(result); + const { code, stdout, stderr } = result; const combined = `${stdout}\n${stderr}`; expect(code).not.toBe(0); @@ -333,7 +369,9 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress "nemoclaw status never produces silent empty output when openshell is broken", testTimeoutOptions(30_000), () => { - const { code, stdout, stderr } = runCli(["my-assist", "status"]); + const result = runCli(["my-assist", "status"]); + expectCliCompleted(result); + const { code, stdout, stderr } = result; const combined = `${stdout}\n${stderr}`; // Must include the sandbox header AND an actionable hint. expect(combined.trim().length).toBeGreaterThan(0); @@ -380,9 +418,10 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress "esac", ]); - const { code, stdout, stderr } = runCli(["my-assist", "status"]); - const combined = `${stdout}\n${stderr}`; - expectLayerBefore(combined, "gateway_unreachable", "still refusing connections after restart"); + const result = runCli(["my-assist", "status"]); + expectCliCompleted(result); + const { code, stdout } = result; + expectLayerBefore(stdout, "gateway_unreachable", "still refusing connections after restart"); expect(code).not.toBe(0); }); @@ -418,9 +457,10 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress "esac", ]); - const { code, stdout, stderr } = runCli(["my-assist", "status"]); - const combined = `${stdout}\n${stderr}`; - expectLayerBefore(combined, "container_missing", "gateway is no longer configured"); + const result = runCli(["my-assist", "status"]); + expectCliCompleted(result); + const { code, stdout } = result; + expectLayerBefore(stdout, "container_missing", "gateway is no longer configured"); expect(code).not.toBe(0); }); From c7d6ac563bac49155245e7987bd54c733bd5a7c0 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 17:29:37 -0700 Subject: [PATCH 2/4] test(cli): clarify macOS subprocess contract Signed-off-by: Senthil Ravichandran --- test/repro-2666-silent-list-status.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index b8f9136d0c3..f8daa95a31e 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -208,8 +208,8 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress { mode: 0o755 }, ); - // Keep gateway recovery inside the test-owned process boundary. The macOS - // runner has a package-managed gateway, which must not outlive this child. + // Use a failing test-owned gateway executable so the CLI cannot start a host-side + // Homebrew-installed OpenShell gateway that writes into the temporary home. fs.writeFileSync(path.join(binDir, "openshell-gateway"), "#!/usr/bin/env bash\nexit 1\n", { mode: 0o755, }); @@ -259,9 +259,9 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress }; } - function expectCliCompleted( - result: ReturnType, - ): asserts result is ReturnType & { + function expectCliCompleted(result: ReturnType): asserts result is ReturnType< + typeof runCli + > & { code: number; error: undefined; signal: null; @@ -302,9 +302,9 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress ); } - function expectLayerBefore(combined: string, layer: string, laterText: string): void { - const layerIndex = combined.indexOf(`Failure layer: ${layer}`); - const laterIndex = combined.indexOf(laterText); + function expectLayerBefore(stdout: string, layer: string, laterText: string): void { + const layerIndex = stdout.indexOf(`Failure layer: ${layer}`); + const laterIndex = stdout.indexOf(laterText); expect(layerIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0); expect(layerIndex).toBeLessThan(laterIndex); From 9675222d83a10bd586f4d8d823a7bffe0308c718 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 17:38:00 -0700 Subject: [PATCH 3/4] test(cli): isolate port-conflict subprocess Signed-off-by: Senthil Ravichandran --- test/repro-2666-silent-list-status.test.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index f8daa95a31e..b85e8c7b0d9 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -510,23 +510,14 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress "esac", ]); - const result = spawnSync(process.execPath, [CLI, "my-assist", "status"], { - encoding: "utf-8", - timeout: 30_000, - env: { - ...process.env, - HOME: home, - PATH: `${binDir}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - NEMOCLAW_STATUS_PROBE_TIMEOUT_MS: "2000", - NEMOCLAW_TEST_NO_SLEEP: "1", - NEMOCLAW_GATEWAY_PORT: String(port), - }, + const result = runCli(["my-assist", "status"], { + NEMOCLAW_GATEWAY_PORT: String(port), }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + expectCliCompleted(result); + const { code, stdout, stderr } = result; + const combined = `${stdout}\n${stderr}`; expect(combined).toContain("container_exited_port_conflict"); - expect(result.status).not.toBe(0); + expect(code).not.toBe(0); } finally { await new Promise((resolve) => listener.close(() => resolve())); } From d9395b38dfd578df5be4f3617006206331bab212 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 19 Aug 2026 18:09:40 -0700 Subject: [PATCH 4/4] test(cli): label silent list regression Signed-off-by: Senthil Ravichandran --- test/repro-2666-silent-list-status.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index b85e8c7b0d9..66b9a616cf7 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -310,7 +310,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress expect(layerIndex).toBeLessThan(laterIndex); } - it("nemoclaw list never produces silent empty output when openshell is broken", () => { + it("nemoclaw list never produces silent empty output when openshell is broken (#2666)", () => { const result = runCli(["list"]); expectCliCompleted(result); const { code, stdout, stderr } = result;