diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index 51498c92dc1..da013a01b0b 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -32,15 +32,18 @@ Do not use `--gateway` to select another instance; when supplied for compatibili For the default gateway, the uninstall command preserves `~/.nemoclaw/rebuild-backups/`, `~/.nemoclaw/backups/`, and `~/.nemoclaw/sandboxes.json` by default. A non-default gateway uses the corresponding entries under `~/.nemoclaw/gateways//`. -When no sibling gateways remain, uninstall also removes the shared CLI, services, images, providers, configuration, models, and swap. +When uninstall confirms that no sibling gateways remain, it also removes the shared CLI, services, images, providers, configuration, models, and swap. When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state and preserves those shared host resources. +If the OpenShell command is unavailable or its gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped path and preserves the shared resources. +When the command itself is unavailable, uninstall exits nonzero before OpenShell cleanup so you can restore the command and retry. Either way, the preserved entries above stay unless you pass `--destroy-user-data`. Interactive runs prompt before they remove the preserved entries, and the default answer keeps them. For non-interactive runs using `--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell, pass `--destroy-user-data` or set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` to acknowledge data loss and remove the preserved entries. `--yes` stays non-destructive by design and never purges preserved user data on its own. -Preserving `sandboxes.json` does not preserve the gateway registration, provider registrations, or Docker image its recorded sandboxes depend on. +Preserving `sandboxes.json` does not preserve the selected sandbox or gateway registration. +After uninstall confirms that no sibling gateways remain, it also removes the provider registrations and Docker image that the recorded sandboxes depend on. Uninstall warns that those records cannot be recovered automatically on reinstall, and the remediation is `$$nemoclaw destroy` followed by `$$nemoclaw onboard`. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 06d063c6956..a9bb6d3bfbe 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3164,7 +3164,7 @@ It prints the versioned URL of the matching `uninstall.sh` so you can download, When the gateway is externally supervised, uninstall preserves its process, Docker resources, and OpenShell binaries. It still deletes the selected sandboxes and attempts to remove the modern local gateway registration. -When no sibling gateways remain, it also deletes NemoClaw provider registrations. +When uninstall confirms that no sibling gateways remain, it also deletes NemoClaw provider registrations. It does not use the legacy `gateway destroy` command for that gateway. @@ -3212,8 +3212,10 @@ The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw | `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes. Refer to [Transfer State Manually](../manage-sandboxes/state-and-backups/transfer-state-manually). | | `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | -When no sibling gateways remain, uninstall also removes shared host resources such as the gateway source clone, runtime state, and the Ollama auth proxy PID file. +When uninstall confirms that no sibling gateways remain, it also removes shared host resources such as the gateway source clone, runtime state, and the Ollama auth proxy PID file. When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving those shared host resources. +If the OpenShell command is unavailable or its gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped path and preserves the shared resources. +When the command itself is unavailable, uninstall exits nonzero before OpenShell cleanup so you can restore the command and retry. `--yes` deliberately remains non-destructive for user data. It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. @@ -3235,7 +3237,7 @@ Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw snapshot The preserved `sandboxes.json` file does not make the recorded sandboxes recoverable on its own. Uninstall deletes the selected sandboxes and attempts to remove the local gateway registration. -When no sibling gateways remain, it also deletes provider registrations. +After uninstall confirms that no sibling gateways remain, it also deletes provider registrations. For a NemoClaw-managed gateway, it also removes the Docker image. For an externally supervised gateway, it preserves Docker resources, but the registry still cannot recover deleted sandbox and provider resources. Uninstall warns about this at preserve time. diff --git a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts index 52472e68667..f93457fa749 100644 --- a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts +++ b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.test.ts @@ -43,6 +43,10 @@ function psStub(pidStr: string, opts: { exited: Set; cmdline?: string; o function defaultRun(command: string, args: readonly string[]): RunResult { switch (command) { + case "openshell": + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(""); case "lsof": return ok(""); default: diff --git a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts index 2af9ba3dc6f..31b590a2801 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -108,6 +108,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { it("does not use legacy gateway destroy when external registration removal is unsupported (#6576)", () => { const calls: Array<{ args: string[]; command: string }> = []; const responses = new Map([ + ["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))], [ "openshell gateway remove nemoclaw", { status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" }, @@ -180,6 +181,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { it("falls back to legacy gateway destroy only when gateway remove is unsupported", () => { const calls: Array<{ args: string[]; command: string }> = []; const responses = new Map([ + ["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))], [ "openshell gateway remove nemoclaw", { status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" }, @@ -213,6 +215,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { const calls: Array<{ args: string[]; command: string }> = []; const warnings: string[] = []; const responses = new Map([ + ["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))], ["openshell gateway remove nemoclaw", { status: 1, stdout: "", stderr: "permission denied" }], ]); const result = runUninstallPlan( @@ -351,9 +354,11 @@ describe("uninstall gateway-port segregation (#3053)", () => { isTty: true, log: vi.fn(), rmSync: fs.rmSync, - run: (_command: string, args: string[]) => { + run: (command: string, args: string[]) => { runCalls.push(args); - return ok(); + return command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: `nemoclaw-${String(port)}` }])) + : ok(); }, runDocker: () => ok(""), }; @@ -747,6 +752,346 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + it("removes host-shared resources when the only gateways/ entries are OpenShell orphans (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-orphan-sibling-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + // A leftover per-port env directory with no matching live OpenShell + // gateway (e.g. a shared CI runner reusing ~/.nemoclaw across jobs). + fs.mkdirSync(path.join(stateDir, "gateways", "18790"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + }, + }), + ); + const logs: string[] = []; + const openshellCalls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + // OpenShell knows only the default gateway; nemoclaw-18790 is gone. + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + // The orphan directory must not scope the uninstall: it runs the full + // (non-scoped) teardown that removes host-shared OpenShell binaries. + expect(openshellCalls).toContainEqual(["sandbox", "delete", "--all"]); + expect(openshellCalls).not.toContainEqual(["gateway", "select", "nemoclaw"]); + expect(logs.join("\n")).not.toContain("Sibling gateways remain"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("removes host-shared resources when the only sibling registry row is an OpenShell orphan (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-orphan-registry-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + // Stale row from an interrupted migration; nemoclaw-9124 is gone. + "ghost-box": { name: "ghost-box", gatewayName: "nemoclaw-9124", gatewayPort: 9124 }, + }, + }), + ); + const logs: string[] = []; + const openshellCalls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + // OpenShell knows only the default gateway; nemoclaw-9124 is gone. + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + // The stale registry row must not scope the uninstall: it runs the full + // (non-scoped) teardown that removes host-shared OpenShell binaries. + expect(openshellCalls).toContainEqual(["sandbox", "delete", "--all"]); + expect(openshellCalls).not.toContainEqual(["gateway", "select", "nemoclaw"]); + expect(logs.join("\n")).not.toContain("Sibling gateways remain"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps host-shared resources when a sibling registry row is a live OpenShell gateway (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-live-registry-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + }, + }), + ); + const logs: string[] = []; + const openshellCalls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + // nemoclaw-9124 is a genuinely live sibling gateway. + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-9124" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + // A live sibling keeps the scoped teardown that preserves host-shared state. + expect(openshellCalls).toContainEqual(["gateway", "select", "nemoclaw"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(logs.join("\n")).toContain("Sibling gateways remain"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("refuses host-wide cleanup when OpenShell is unavailable and no sibling files exist (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-no-openshell-")); + try { + const calls: Array<{ args: string[]; command: string }> = []; + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "npm", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + error: (line) => warnings.push(line), + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (command, args) => { + calls.push({ args, command }); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + expect(logs.join("\n")).toContain("resources owned by gateway 'nemoclaw'"); + expect(warnings.join("\n")).toContain( + "openshell not found; skipping gateway/provider/sandbox cleanup", + ); + expect(calls.some(({ command }) => command === "npm")).toBe(false); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it.each([ + { + case: "the gateway list is partially malformed", + gatewayListResponse: ok(JSON.stringify([{ name: "nemoclaw" }, {}])), + }, + { + case: "the gateway-list command fails", + gatewayListResponse: { status: 1, stdout: "", stderr: "gateway query failed" }, + }, + { + case: "the gateway list is invalid JSON", + gatewayListResponse: ok("{not-json"), + }, + { + case: "the gateway list is not an array", + gatewayListResponse: ok(JSON.stringify({ name: "nemoclaw" })), + }, + ])("keeps host-shared resources when $case (#7315)", ({ gatewayListResponse }) => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-malformed-list-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + "sibling-box": { name: "sibling-box", gatewayName: "nemoclaw-9124", gatewayPort: 9124 }, + }, + }), + ); + const logs: string[] = []; + const openshellCalls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + return args[0] === "gateway" && args[1] === "list" ? gatewayListResponse : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(openshellCalls).toContainEqual(["gateway", "select", "nemoclaw"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(logs.join("\n")).toContain("Sibling gateways remain"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("switches to scoped cleanup when a sibling gateway appears before destruction (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-new-sibling-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + }, + }), + ); + const openshellCalls: string[][] = []; + const warnings: string[] = []; + let gatewayListCalls = 0; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + const isGatewayList = args[0] === "gateway" && args[1] === "list"; + gatewayListCalls += isGatewayList ? 1 : 0; + return isGatewayList + ? ok( + JSON.stringify( + gatewayListCalls === 1 + ? [{ name: "nemoclaw" }] + : [{ name: "nemoclaw" }, { name: "nemoclaw-9124" }], + ), + ) + : ok(); + }, + runDocker: () => ok(""), + error: (line) => warnings.push(line), + }, + ); + + expect(result.exitCode).toBe(0); + expect(gatewayListCalls).toBe(2); + expect(openshellCalls).toContainEqual(["gateway", "select", "nemoclaw"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(warnings.join("\n")).toContain("switching to gateway-scoped cleanup"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps host-shared resources when a gateways/ entry is a live OpenShell gateway (#7315)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-live-sibling-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(path.join(stateDir, "gateways", "8091"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, + }, + }), + ); + const logs: string[] = []; + const openshellCalls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + rmSync: fs.rmSync, + run: (_command, args) => { + openshellCalls.push(args); + // nemoclaw-8091 is a genuinely live sibling gateway. + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + // A live sibling keeps the scoped teardown that preserves host-shared state. + expect(openshellCalls).toContainEqual(["gateway", "select", "nemoclaw"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(logs.join("\n")).toContain("Sibling gateways remain"); + expect(fs.existsSync(path.join(stateDir, "gateways", "8091"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("preserves selected state when the owning gateway cannot be selected", async () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-select-fail-")); const port = 9123; @@ -805,7 +1150,10 @@ describe("uninstall gateway-port segregation (#3053)", () => { ); expect(result.exitCode).toBe(1); - expect(calls).toEqual([["gateway", "select", `nemoclaw-${String(port)}`]]); + // The read-only `gateway list` liveness probe may run first; the only + // state-changing OpenShell call is the failed select. (#7315) + const meaningful = calls.filter((args) => !(args[0] === "gateway" && args[1] === "list")); + expect(meaningful).toEqual([["gateway", "select", `nemoclaw-${String(port)}`]]); expect(fs.existsSync(path.join(selected, "sandboxes.json"))).toBe(true); expect(fs.existsSync(path.join(shared, "sandboxes.json"))).toBe(true); } finally { diff --git a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts index 37a4bed2aa9..970794a11d3 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -17,6 +17,12 @@ function notFound(): RunResult { return { status: 1, stdout: "", stderr: "" }; } +function okWithKnownGatewayList(command: string, args: readonly string[]): RunResult { + return command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); +} + function setupStateDir(): { tmpHome: string; stateDir: string } { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-registry-")); const stateDir = path.join(tmpHome, ".nemoclaw"); @@ -39,7 +45,7 @@ function preserveCaseDeps( opts: { envOverrides?: Record } = {}, ): UninstallRunDeps { return { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "", @@ -50,7 +56,7 @@ function preserveCaseDeps( existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target), isTty: false, log: (line) => logs.push(line), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }; } @@ -74,7 +80,13 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65 log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => - command === "openshell" ? notFound() : args[0] === "-c" ? ok("/fake/bin/tool\n") : ok(), + command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : command === "openshell" + ? notFound() + : args[0] === "-c" + ? ok("/fake/bin/tool\n") + : ok(), runDocker: () => ok(""), }, ); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 9974ff2b62a..11fd5f47693 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -17,6 +17,12 @@ function notFound(): RunResult { return { status: 1, stdout: "", stderr: "" }; } +function okWithKnownGatewayList(command: string, args: readonly string[]): RunResult { + return command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); +} + const PROXY_CMDLINE = "/usr/bin/node /opt/nemoclaw/scripts/ollama-auth-proxy.js\n"; // Real-world: model-router is a Python venv script so the OS interposes the // interpreter — args[0]=python, args[1]=model-router (issue #5169). @@ -62,10 +68,10 @@ describe("uninstall run plan", () => { it("applies a non-destructive uninstall run with fake tools", () => { const logs: string[] = []; - const run = vi.fn((_command: string, args: string[]) => { + const run = vi.fn((command: string, args: string[]) => { if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }); const dockerCalls: string[][] = []; const runDocker = vi.fn((args: string[]) => { @@ -130,10 +136,7 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: false }, { commandExists: (command) => - command !== "docker" && - command !== "lsof" && - command !== "openshell" && - command !== "pgrep", + command !== "docker" && command !== "lsof" && command !== "pgrep", env: { HOME: tmpHome } as NodeJS.ProcessEnv, existsSync: (target) => existing.has(target), isTty: false, @@ -141,7 +144,7 @@ describe("uninstall run plan", () => { rmSync: vi.fn((target: fs.PathLike) => { removed.push(String(target)); }), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -178,10 +181,7 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: false }, { commandExists: (command) => - command !== "docker" && - command !== "lsof" && - command !== "openshell" && - command !== "pgrep", + command !== "docker" && command !== "lsof" && command !== "pgrep", env: { HOME: tmpHome } as NodeJS.ProcessEnv, existsSync: (target) => target === hermesShim || target === deepagentsShim, isTty: false, @@ -189,7 +189,7 @@ describe("uninstall run plan", () => { rmSync: vi.fn((target: fs.PathLike) => { removed.push(String(target)); }), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -226,10 +226,7 @@ describe("uninstall run plan", () => { { assumeYes: true, deleteModels: false, keepOpenShell: false }, { commandExists: (command) => - command !== "docker" && - command !== "lsof" && - command !== "openshell" && - command !== "pgrep", + command !== "docker" && command !== "lsof" && command !== "pgrep", env: { HOME: tmpHome } as NodeJS.ProcessEnv, existsSync: (target) => target === hermesShim || target === deepagentsShim, isTty: false, @@ -237,7 +234,7 @@ describe("uninstall run plan", () => { rmSync: vi.fn((target: fs.PathLike) => { removed.push(String(target)); }), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -256,7 +253,7 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: "/tmp/nemohermes-uninstall-test", NEMOCLAW_AGENT: "hermes", @@ -268,7 +265,7 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), readLine: () => "yes", rmSync: vi.fn(), - run: vi.fn(), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -292,12 +289,13 @@ describe("uninstall run plan", () => { const run = vi.fn((_command: string, args: string[]) => { if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(_command, args); }); const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, { + commandExists: (command) => command === "openshell", env: { HOME: "/tmp/nemoclaw-uninstall-test", NEMOCLAW_AGENT: "" } as NodeJS.ProcessEnv, existsSync: () => false, isTty: true, @@ -360,13 +358,13 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv, existsSync: () => false, kill: () => true, log: () => {}, rmSync: vi.fn(), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), // isTty/readLine intentionally not injected: the default // isStdinTty/readLineFromStdin pair must never instantiate @@ -415,7 +413,7 @@ describe("uninstall run plan", () => { if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -464,7 +462,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -505,7 +503,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -553,7 +551,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -602,7 +600,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -647,7 +645,7 @@ describe("uninstall run plan", () => { if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -696,7 +694,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -744,7 +742,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -791,7 +789,7 @@ describe("uninstall run plan", () => { } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -837,7 +835,7 @@ describe("uninstall run plan", () => { if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -866,10 +864,10 @@ describe("uninstall run plan", () => { log: (line: string) => logs.push(line), error: (line: string) => warnings.push(line), rmSync: vi.fn(), - run: (_command, args) => { + run: (command, args) => { if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -896,7 +894,7 @@ describe("uninstall run plan", () => { if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -927,9 +925,9 @@ describe("uninstall run plan", () => { isTty: true, log: (line) => logs.push(line), rmSync: vi.fn(), - run: (_command, args) => { + run: (command, args) => { if (args[0] === "swapoff") return { status: 1, stdout: "", stderr: "swapoff failed" }; - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -958,7 +956,7 @@ describe("uninstall run plan", () => { return { status: 1, stdout: "", stderr: "gateway not found" }; } if (args[0] === "-c") return ok("/fake/bin/tool\n"); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -1012,7 +1010,7 @@ describe("uninstall run plan", () => { } = {}, ): UninstallRunDeps { return { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "", @@ -1023,7 +1021,7 @@ describe("uninstall run plan", () => { isTty: opts.isTty ?? false, log: (line) => logs.push(line), ...(opts.readLine ? { readLine: opts.readLine } : {}), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }; } @@ -1350,12 +1348,12 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome } as NodeJS.ProcessEnv, existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target), isTty: false, log: (line) => logs.push(line), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -1380,12 +1378,12 @@ describe("uninstall run plan", () => { const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome } as NodeJS.ProcessEnv, existsSync: tempScopedExistsSync(tmpHome), isTty: false, log: (line) => logs.push(line), - run: vi.fn(() => ok()), + run: vi.fn(okWithKnownGatewayList), runDocker: () => ok(""), }, ); @@ -1434,7 +1432,7 @@ describe("uninstall run plan", () => { if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index b503b5d46b0..9d5f2760b37 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -143,6 +143,7 @@ type SharedRegistrySiblingStatus = "none" | "present" | "uncertain"; function sharedRegistrySiblingStatus( paths: UninstallPaths, runtime: Pick, + liveGatewayNames: () => Set | null, ): SharedRegistrySiblingStatus { const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const registryFile = path.join(sharedRoot, "sandboxes.json"); @@ -150,11 +151,15 @@ function sharedRegistrySiblingStatus( try { const registry = readGatewayRegistryFile(path.dirname(sharedRoot), registryFile); if (!registry) return "uncertain"; - return Object.values(registry.sandboxes).some( - (entry) => registryEntryGatewayPort(entry) !== GATEWAY_PORT, - ) - ? "present" - : "none"; + const siblingPorts = Object.values(registry.sandboxes) + .map((entry) => registryEntryGatewayPort(entry)) + .filter((port) => port !== GATEWAY_PORT); + if (siblingPorts.length === 0) return "none"; + // A non-current registry row is a live sibling only while OpenShell still + // knows its gateway; a stale row must not report "present". + const live = liveGatewayNames(); + if (live === null) return "present"; + return siblingPorts.some((port) => live.has(resolveGatewayName(port))) ? "present" : "none"; } catch { // Unknown ownership must never permit host-global cleanup. return "uncertain"; @@ -1120,6 +1125,34 @@ interface OtherGatewayInspection { sharedRegistryMustBePreserved: boolean; } +/** + * Names of the gateways OpenShell currently knows about, or `null` when that + * cannot be determined (OpenShell missing, the query failed, or its output was + * unparseable). `null` always means "stay conservative": callers must not treat + * an absence they cannot prove as evidence that a gateway is gone. (#7315) + */ +function collectLiveOpenShellGatewayNames(runtime: UninstallRuntime): Set | null { + if (!runtime.commandExists("openshell")) return null; + const result = runtime.run("openshell", ["gateway", "list", "-o", "json"], { + env: runtime.env, + }); + if (result.status !== 0) return null; + try { + const parsed: unknown = JSON.parse(result.stdout); + if (!Array.isArray(parsed)) return null; + const names = new Set(); + for (const item of parsed) { + if (item === null || typeof item !== "object") return null; + const name = (item as { name?: unknown }).name; + if (typeof name !== "string" || name.length === 0) return null; + names.add(name); + } + return names; + } catch { + return null; + } +} + function inspectOtherGatewayEnvironments( paths: UninstallPaths, runtime: UninstallRuntime, @@ -1127,13 +1160,37 @@ function inspectOtherGatewayEnvironments( const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const selectedRoot = path.resolve(paths.nemoclawStateDir); const selectedIsDefault = selectedRoot === path.resolve(sharedRoot); - const sharedRegistryStatus = sharedRegistrySiblingStatus(paths, runtime); + // The gateways OpenShell actually knows about, queried at most once and shared + // by both sibling-detection surfaces below, so stale filesystem or registry + // state (e.g. a shared CI runner reusing ~/.nemoclaw across jobs) can't pin + // host-shared cleanup like the openshell-gateway binary on a gone gateway. (#7315) + let liveGatewayNamesCache: Set | null | undefined; + const liveGatewayNames = (): Set | null => { + if (liveGatewayNamesCache === undefined) { + liveGatewayNamesCache = collectLiveOpenShellGatewayNames(runtime); + } + return liveGatewayNamesCache; + }; + const sharedRegistryStatus = sharedRegistrySiblingStatus(paths, runtime, liveGatewayNames); if (sharedRegistryStatus !== "none") { return { otherGatewayEnvironmentsRemain: true, sharedRegistryMustBePreserved: true, }; } + const liveNames = liveGatewayNames(); + if (liveNames === null) { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } + if ([...liveNames].some((name) => name !== resolveGatewayName(GATEWAY_PORT))) { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } if (!selectedIsDefault && pathEntryExists(sharedRoot, runtime)) { try { @@ -1171,9 +1228,16 @@ function inspectOtherGatewayEnvironments( const siblingExists = fs.readdirSync(gatewaysDir, { withFileTypes: true }).some((entry) => { const candidate = path.resolve(gatewaysDir, entry.name); if (candidate === selectedRoot) return false; - // Any other filesystem object is conservatively treated as gateway - // state. In particular, never follow or dismiss a symlink here. - return true; + // Never follow or dismiss a symlink or non-directory: a surprising shape + // may hide live gateway state, so keep the conservative treatment. + if (entry.isSymbolicLink() || !entry.isDirectory()) return true; + // A per-port directory whose gateway OpenShell no longer knows is an + // orphan; dismiss it only when the live set positively lacks it. + const port = Number(entry.name); + if (!Number.isInteger(port) || port < 1 || port > 65535) return true; + const live = liveGatewayNames(); + if (live === null) return true; + return live.has(resolveGatewayName(port)); }); return { otherGatewayEnvironmentsRemain: siblingExists, @@ -1524,8 +1588,8 @@ export function runUninstallPlan( ); return { exitCode: 1, plan }; } - const gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); - const { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; + let gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); + let { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; let sandboxNames: string[] = []; if (scopedToSelectedGateway) { try { @@ -1539,6 +1603,22 @@ export function runUninstallPlan( if (!confirm(resolvedOptions, runtime, paths, scopedToSelectedGateway)) { return { exitCode: 0, plan }; } + if (!scopedToSelectedGateway) { + const boundaryInspection = inspectOtherGatewayEnvironments(paths, runtime); + if (boundaryInspection.otherGatewayEnvironmentsRemain) { + gatewayInspection = boundaryInspection; + scopedToSelectedGateway = true; + try { + sandboxNames = selectedRegistrySandboxNames(paths, runtime); + } catch (error) { + runtime.error(error instanceof Error ? error.message : String(error)); + return { exitCode: 1, plan }; + } + runtime.warn( + "A sibling gateway appeared during uninstall preparation; switching to gateway-scoped cleanup.", + ); + } + } const preserveUnderStateDir = resolvePreserveSet(paths, resolvedOptions, runtime); const { ok } = executePlan( plan, diff --git a/test/fixtures/uninstall-prompt-pty-driver.ts b/test/fixtures/uninstall-prompt-pty-driver.ts index 25bf93c16eb..ee97a755bfc 100644 --- a/test/fixtures/uninstall-prompt-pty-driver.ts +++ b/test/fixtures/uninstall-prompt-pty-driver.ts @@ -48,11 +48,16 @@ if (preservable) { fs.mkdirSync(path.join(home, ".nemoclaw", "backups"), { recursive: true }); } const okResult: RunResult = { status: 0, stdout: "", stderr: "" }; +const knownGatewayListResult: RunResult = { + status: 0, + stdout: JSON.stringify([{ name: "nemoclaw" }]), + stderr: "", +}; const { exitCode } = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", // Hermetic env: the runtime merges the real process.env, so a developer // shell exporting NEMOCLAW_* knobs (non-interactive mode, destroy-user- // data acknowledgement, agent branding) would change which prompts run @@ -68,7 +73,10 @@ const { exitCode } = runUninstallPlan( existsSync: fs.existsSync, kill: () => true, rmSync: (() => {}) as never, - run: () => okResult, + run: (command, args) => + command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? knownGatewayListResult + : okResult, runDocker: () => okResult, // readLine and isTty are deliberately NOT injected: the default // readLineFromStdin/isStdinTty pair reading the pty is what is under test. diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index b59fa2a6783..817a65852e7 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1043,11 +1043,9 @@ describe("sandbox provisioning: base runtime tools", () => { const aptInstall = dockerfile.match( /^RUN apt-get update && apt-get install -y --no-install-recommends \\\n(?:.*\\\n)*.*$/m, )?.[0]; - expect(aptInstall).toBeDefined(); expect(aptInstall).toContain("nftables=1.1.3-1"); }); - it("rejects a sandbox security package when its expected checksum changes", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-checksum-")); @@ -1062,7 +1060,6 @@ describe("sandbox provisioning: base runtime tools", () => { .replaceAll("/tmp/nemoclaw-debian-security", path.join(tmp, "security-debs")) .replaceAll("/usr/local/bin/python", untouchedTail) .replaceAll("/usr/bin/python3", path.join(tmp, "python3")); - try { const { result } = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', @@ -1074,7 +1071,6 @@ describe("sandbox provisioning: base runtime tools", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("base apt layer requests procps, e2fsprogs, and the SFTP server", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-apt-")); @@ -1095,7 +1091,6 @@ describe("sandbox provisioning: base runtime tools", () => { .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) .replaceAll("/usr/local/bin/python", fakePyLink) .replaceAll("/usr/bin/python3", fakePy3); - try { const { result, calls } = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', @@ -1122,7 +1117,6 @@ describe("sandbox provisioning: base runtime tools", () => { fs.mkdirSync(path.dirname(fakePy3), { recursive: true }); fs.mkdirSync(path.dirname(fakePyLink), { recursive: true }); fs.writeFileSync(fakePy3, "#!/bin/sh\necho 3.13\n", { mode: 0o755 }); - const command = dockerRunCommandBetween( dockerfile, "RUN apt-get update", @@ -1132,7 +1126,6 @@ describe("sandbox provisioning: base runtime tools", () => { .replaceAll("/tmp/nemoclaw-debian-security", securityDebs) .replaceAll("/usr/local/bin/python", fakePyLink) .replaceAll("/usr/bin/python3", fakePy3); - try { const { result } = runLoggedDockerShell(command, tmp, [ 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index c85860eb337..839fbdaa59f 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -12,11 +12,21 @@ const UNINSTALL_SCRIPT = path.join(import.meta.dirname, "..", "uninstall.sh"); describe("uninstall CLI flags", () => { function writeFakeTools(fakeBin: string) { fs.mkdirSync(fakeBin); - for (const cmd of ["npm", "openshell", "docker", "ollama", "pgrep"]) { + for (const cmd of ["npm", "docker", "ollama", "pgrep"]) { fs.writeFileSync(path.join(fakeBin, cmd), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755, }); } + fs.writeFileSync( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +case "$*" in + "gateway list -o json") printf '[{"name":"nemoclaw"}]\\n' ;; +esac +exit 0 +`, + { mode: 0o755 }, + ); } function seedPreservedState(tmp: string): string {