From e50d0e18a7eb3c476d69919706af38fdd50667ea Mon Sep 17 00:00:00 2001 From: Rui Luo Date: Wed, 22 Jul 2026 10:58:58 +0800 Subject: [PATCH 01/14] fix(uninstall): ignore OpenShell-orphaned siblings in cleanup scoping Signed-off-by: Rui Luo --- .../run-plan-gateway-segregation.test.ts | 201 +++++++++++++++++- src/lib/actions/uninstall/run-plan.ts | 72 ++++++- 2 files changed, 263 insertions(+), 10 deletions(-) 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 23f07fd9a49..3b3a72ed8ad 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -591,6 +591,202 @@ 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) => { + if (command === "openshell") openshellCalls.push(args); + // OpenShell knows only the default gateway; nemoclaw-18790 is gone. + if (args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } + return 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) => { + if (command === "openshell") openshellCalls.push(args); + // OpenShell knows only the default gateway; nemoclaw-9124 is gone. + if (args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } + return 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 }, + "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) => { + if (command === "openshell") openshellCalls.push(args); + // nemoclaw-9124 is a genuinely live sibling gateway. + if (args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-9124" }])); + } + return 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("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) => { + if (command === "openshell") openshellCalls.push(args); + // nemoclaw-8091 is a genuinely live sibling gateway. + if (args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }])); + } + return 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; @@ -649,7 +845,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.ts b/src/lib/actions/uninstall/run-plan.ts index 88098a6e453..e9cac8b3dc3 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -137,6 +137,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"); @@ -144,11 +145,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"; @@ -1098,6 +1103,37 @@ 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` 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" && + typeof (item as { name?: unknown }).name === "string" + ) { + names.add((item as { name: string }).name); + } + } + return names; + } catch { + return null; + } +} + function inspectOtherGatewayEnvironments( paths: UninstallPaths, runtime: UninstallRuntime, @@ -1105,7 +1141,18 @@ 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, @@ -1149,9 +1196,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, From fc698af423a642e7ac1834ef845a6d1ddc61183e Mon Sep 17 00:00:00 2001 From: Rui Luo Date: Wed, 22 Jul 2026 12:14:44 +0800 Subject: [PATCH 02/14] test(uninstall): keep gateway-segregation test bodies if-free Signed-off-by: Rui Luo --- .../run-plan-gateway-segregation.test.ts | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) 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 3b3a72ed8ad..b179d875d5f 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -618,13 +618,12 @@ describe("uninstall gateway-port segregation (#3053)", () => { isTty: false, log: (line) => logs.push(line), rmSync: fs.rmSync, - run: (command, args) => { - if (command === "openshell") openshellCalls.push(args); + run: (_command, args) => { + openshellCalls.push(args); // OpenShell knows only the default gateway; nemoclaw-18790 is gone. - if (args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } - return ok(); + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); }, runDocker: () => ok(""), }, @@ -668,13 +667,12 @@ describe("uninstall gateway-port segregation (#3053)", () => { isTty: false, log: (line) => logs.push(line), rmSync: fs.rmSync, - run: (command, args) => { - if (command === "openshell") openshellCalls.push(args); + run: (_command, args) => { + openshellCalls.push(args); // OpenShell knows only the default gateway; nemoclaw-9124 is gone. - if (args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } - return ok(); + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); }, runDocker: () => ok(""), }, @@ -717,13 +715,12 @@ describe("uninstall gateway-port segregation (#3053)", () => { isTty: false, log: (line) => logs.push(line), rmSync: fs.rmSync, - run: (command, args) => { - if (command === "openshell") openshellCalls.push(args); + run: (_command, args) => { + openshellCalls.push(args); // nemoclaw-9124 is a genuinely live sibling gateway. - if (args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-9124" }])); - } - return ok(); + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-9124" }])) + : ok(); }, runDocker: () => ok(""), }, @@ -764,13 +761,12 @@ describe("uninstall gateway-port segregation (#3053)", () => { isTty: false, log: (line) => logs.push(line), rmSync: fs.rmSync, - run: (command, args) => { - if (command === "openshell") openshellCalls.push(args); + run: (_command, args) => { + openshellCalls.push(args); // nemoclaw-8091 is a genuinely live sibling gateway. - if (args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }])); - } - return ok(); + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }])) + : ok(); }, runDocker: () => ok(""), }, From 780f2330a881c52474c45899cedf2fae2042a9ee Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 22 Jul 2026 08:10:28 -0700 Subject: [PATCH 03/14] fix(uninstall): fail closed on malformed gateway lists Signed-off-by: Charan Jagwani --- .../run-plan-gateway-segregation.test.ts | 45 +++++++++++++++++++ src/lib/actions/uninstall/run-plan.ts | 11 ++--- 2 files changed, 49 insertions(+), 7 deletions(-) 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 b179d875d5f..890fdd6f2e6 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -736,6 +736,51 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + it("keeps host-shared resources when the OpenShell gateway list is partially malformed (#7315)", () => { + 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" + ? ok(JSON.stringify([{ name: "nemoclaw" }, {}])) + : 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("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 { diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index e9cac8b3dc3..925fa149482 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1120,13 +1120,10 @@ function collectLiveOpenShellGatewayNames(runtime: UninstallRuntime): Set(); for (const item of parsed) { - if ( - item !== null && - typeof item === "object" && - typeof (item as { name?: unknown }).name === "string" - ) { - names.add((item as { name: string }).name); - } + 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 { From ff943ad94bc1bb9f46a44672c41182babafab45e Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 22 Jul 2026 08:43:31 -0700 Subject: [PATCH 04/14] fix(uninstall): revalidate sibling gateways before cleanup Signed-off-by: Charan Jagwani --- .../run-plan-gateway-segregation.test.ts | 75 ++++++++++++++++++- src/lib/actions/uninstall/run-plan.ts | 30 +++++++- 2 files changed, 99 insertions(+), 6 deletions(-) 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 890fdd6f2e6..b62ae3e6771 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -736,7 +736,24 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); - it("keeps host-shared resources when the OpenShell gateway list is partially malformed (#7315)", () => { + 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"); @@ -764,9 +781,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: fs.rmSync, run: (_command, args) => { openshellCalls.push(args); - return args[0] === "gateway" && args[1] === "list" - ? ok(JSON.stringify([{ name: "nemoclaw" }, {}])) - : ok(); + return args[0] === "gateway" && args[1] === "list" ? gatewayListResponse : ok(); }, runDocker: () => ok(""), }, @@ -781,6 +796,58 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + 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); + if (args[0] !== "gateway" || args[1] !== "list") return ok(); + gatewayListCalls += 1; + return ok( + JSON.stringify( + gatewayListCalls === 1 + ? [{ name: "nemoclaw" }] + : [{ name: "nemoclaw" }, { name: "nemoclaw-9124" }], + ), + ); + }, + 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 { diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 925fa149482..6fb5b9691f6 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1156,6 +1156,16 @@ function inspectOtherGatewayEnvironments( sharedRegistryMustBePreserved: true, }; } + const liveNames = liveGatewayNames(); + if ( + liveNames !== null && + [...liveNames].some((name) => name !== resolveGatewayName(GATEWAY_PORT)) + ) { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } if (!selectedIsDefault && pathEntryExists(sharedRoot, runtime)) { try { @@ -1515,8 +1525,8 @@ export function runUninstallPlan( } const resolvedOptions = { ...options, gatewayName: expectedGatewayName }; const { paths, plan } = buildRunPlan(resolvedOptions, { ...deps, env: runtime.env }); - const gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); - const { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; + let gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); + let { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; let sandboxNames: string[] = []; if (scopedToSelectedGateway) { try { @@ -1530,6 +1540,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, From 921309705d33637163df2998ca10e787f22818d6 Mon Sep 17 00:00:00 2001 From: Rui Luo Date: Thu, 23 Jul 2026 09:50:53 +0800 Subject: [PATCH 05/14] test(uninstall): avoid conditional in gateway cleanup test Signed-off-by: Rui Luo --- .../run-plan-gateway-segregation.test.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 b62ae3e6771..11c7eec24ad 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -823,15 +823,17 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: fs.rmSync, run: (_command, args) => { openshellCalls.push(args); - if (args[0] !== "gateway" || args[1] !== "list") return ok(); - gatewayListCalls += 1; - return ok( - JSON.stringify( - gatewayListCalls === 1 - ? [{ name: "nemoclaw" }] - : [{ name: "nemoclaw" }, { name: "nemoclaw-9124" }], - ), - ); + 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), From c2b79f20a2ee7c3915f488bcd9eb29cd3f463881 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:01:27 -0700 Subject: [PATCH 06/14] fix(uninstall): preserve shared resources on unknown liveness Signed-off-by: Senthil Ravichandran --- ...openrouter-runtime-adapter-cleanup.test.ts | 4 ++ .../run-plan-gateway-segregation.test.ts | 15 +++++- .../run-plan-preserved-registry.test.ts | 8 ++- src/lib/actions/uninstall/run-plan.test.ts | 54 +++++++++++++++++-- src/lib/actions/uninstall/run-plan.ts | 11 +++- 5 files changed, 84 insertions(+), 8 deletions(-) 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 e3ab947f586..568b0801882 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -137,6 +137,9 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(), @@ -195,6 +198,9 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(""), @@ -225,6 +231,9 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(""), @@ -351,8 +360,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); + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: `nemoclaw-${String(port)}` }])); + } return ok(); }, runDocker: () => ok(""), @@ -856,7 +868,6 @@ describe("uninstall gateway-port segregation (#3053)", () => { defaultSandbox: "my-assistant", sandboxes: { "my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 }, - "sibling-box": { name: "sibling-box", gatewayName: "nemoclaw-9124", gatewayPort: 9124 }, }, }), ); 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..7fa05e3818b 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -74,7 +74,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..3ae3509918e 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -62,7 +62,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 (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); return ok(); @@ -407,6 +410,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "ps") { const result = stub(args); if (result) return result; @@ -455,6 +461,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("55678\n"); } @@ -496,6 +505,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("77777\n"); } @@ -541,6 +553,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti") { lsofPorts.push(args[1] ?? ""); // Only return a hit when the scan is asking about the custom port. @@ -593,6 +608,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("99999\n"); } @@ -640,6 +658,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "ps") { const result = stub(args); if (result) return result; @@ -684,6 +705,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("55679\n"); } @@ -732,6 +756,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("77888\n"); } @@ -779,6 +806,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("88888\n"); } @@ -830,6 +860,9 @@ describe("uninstall run plan", () => { error: (line: string) => warnings.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "ps") { const result = stub(args); if (result) return result; @@ -866,7 +899,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 (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); return ok(); @@ -893,6 +929,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "lsof") return ok(""); if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); @@ -927,7 +966,10 @@ describe("uninstall run plan", () => { isTty: true, log: (line) => logs.push(line), rmSync: vi.fn(), - run: (_command, args) => { + run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (args[0] === "swapoff") return { status: 1, stdout: "", stderr: "swapoff failed" }; return ok(); }, @@ -954,6 +996,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { return { status: 1, stdout: "", stderr: "gateway not found" }; } @@ -1419,6 +1464,9 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { + if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { + return ok(JSON.stringify([{ name: "nemoclaw" }])); + } const psResult = psStub("9999887", { cmdline: "/home/test/.local/bin/openshell-gateway --port 8080\n", exited, diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 7366c9b414a..168f48c6f1c 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1128,8 +1128,9 @@ interface OtherGatewayInspection { /** * 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` means "stay conservative": callers must not treat an - * absence they cannot prove as evidence that a gateway is gone. (#7315) + * unparseable). When OpenShell is available, `null` 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; @@ -1179,6 +1180,12 @@ function inspectOtherGatewayEnvironments( }; } const liveNames = liveGatewayNames(); + if (liveNames === null && runtime.commandExists("openshell")) { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } if ( liveNames !== null && [...liveNames].some((name) => name !== resolveGatewayName(GATEWAY_PORT)) From 475137aa5214903958ed43e45593278496e22287 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:03:31 -0700 Subject: [PATCH 07/14] docs(uninstall): explain uncertain gateway cleanup Signed-off-by: Senthil Ravichandran --- docs/manage-sandboxes/uninstall-nemoclaw.mdx | 6 ++++-- docs/reference/commands.mdx | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index 51498c92dc1..e1d8f77287e 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -32,15 +32,17 @@ 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 OpenShell's gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped cleanup and preserves the shared resources. 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 a45290a4854..315ffc0a3c9 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3204,8 +3204,9 @@ 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 OpenShell's gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped cleanup and preserves the shared resources. `--yes` deliberately remains non-destructive for user data. It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. @@ -3227,7 +3228,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. From 6397dd4de04757fd46e0ae345fa95339a88421f4 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:07:37 -0700 Subject: [PATCH 08/14] docs(uninstall): qualify provider cleanup Signed-off-by: Senthil Ravichandran --- docs/reference/commands.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 315ffc0a3c9..45f687fdad0 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3156,7 +3156,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. From 7af8434f22a308df177042a7225dacd50c1306cd Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:13:24 -0700 Subject: [PATCH 09/14] test(uninstall): route gateway probes without branches Signed-off-by: Senthil Ravichandran --- .../run-plan-gateway-segregation.test.ts | 19 ++-- src/lib/actions/uninstall/run-plan.test.ts | 86 +++++-------------- 2 files changed, 28 insertions(+), 77 deletions(-) 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 568b0801882..4181e6bbc02 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'" }, @@ -137,9 +138,6 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(), @@ -183,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'" }, @@ -198,9 +197,6 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(""), @@ -219,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( @@ -231,9 +228,6 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: vi.fn(), run: (command, args) => { calls.push({ args, command }); - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } return responses.get([command, ...args].join(" ")) ?? ok(); }, runDocker: () => ok(""), @@ -362,10 +356,9 @@ describe("uninstall gateway-port segregation (#3053)", () => { rmSync: fs.rmSync, run: (command: string, args: string[]) => { runCalls.push(args); - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: `nemoclaw-${String(port)}` }])); - } - return ok(); + return command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: `nemoclaw-${String(port)}` }])) + : ok(); }, runDocker: () => ok(""), }; diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 3ae3509918e..e25a0c92591 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). @@ -63,12 +69,9 @@ 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[]) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } 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[]) => { @@ -410,9 +413,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "ps") { const result = stub(args); if (result) return result; @@ -421,7 +421,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(""), }, @@ -461,9 +461,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("55678\n"); } @@ -473,7 +470,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,9 +502,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("77777\n"); } @@ -517,7 +511,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,9 +547,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti") { lsofPorts.push(args[1] ?? ""); // Only return a hit when the scan is asking about the custom port. @@ -568,7 +559,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(""), }, @@ -608,9 +599,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { return ok("99999\n"); } @@ -620,7 +608,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(""), }, @@ -658,9 +646,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "ps") { const result = stub(args); if (result) return result; @@ -668,7 +653,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(""), }, @@ -705,9 +690,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("55679\n"); } @@ -720,7 +702,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(""), }, @@ -756,9 +738,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("77888\n"); } @@ -771,7 +750,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(""), }, @@ -806,9 +785,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "lsof" && args[0] === "-ti" && args[1] === ":4000") { return ok("88888\n"); } @@ -821,7 +797,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(""), }, @@ -860,9 +836,6 @@ describe("uninstall run plan", () => { error: (line: string) => warnings.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "ps") { const result = stub(args); if (result) return result; @@ -870,7 +843,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(""), }, @@ -900,12 +873,9 @@ describe("uninstall run plan", () => { error: (line: string) => warnings.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (args[0] === "-c") return ok("/fake/bin/tool\n"); if (args[0] === "-f") return ok(""); - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -929,13 +899,10 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } 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(""), }, @@ -967,11 +934,8 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (args[0] === "swapoff") return { status: 1, stdout: "", stderr: "swapoff failed" }; - return ok(); + return okWithKnownGatewayList(command, args); }, runDocker: () => ok(""), }, @@ -996,14 +960,11 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { 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(""), }, @@ -1464,9 +1425,6 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "list") { - return ok(JSON.stringify([{ name: "nemoclaw" }])); - } const psResult = psStub("9999887", { cmdline: "/home/test/.local/bin/openshell-gateway --port 8080\n", exited, @@ -1482,7 +1440,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(""), }, From 631fb284f6582a4606d268219becb4a92b2401f8 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:35:03 -0700 Subject: [PATCH 10/14] test(uninstall): model a valid gateway listing Signed-off-by: Senthil Ravichandran --- test/uninstall.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 { From 4eed094858b69c64b28a9941cbbd6b252f8f5726 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 14:49:50 -0700 Subject: [PATCH 11/14] fix(uninstall): preserve shared state without openshell Signed-off-by: Senthil Ravichandran --- docs/manage-sandboxes/uninstall-nemoclaw.mdx | 3 +- docs/reference/commands.mdx | 3 +- .../run-plan-gateway-segregation.test.ts | 35 +++++++++++++++ .../run-plan-preserved-registry.test.ts | 10 ++++- src/lib/actions/uninstall/run-plan.test.ts | 44 ++++++++----------- src/lib/actions/uninstall/run-plan.ts | 7 ++- 6 files changed, 68 insertions(+), 34 deletions(-) diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index e1d8f77287e..da013a01b0b 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -34,7 +34,8 @@ For the default gateway, the uninstall command preserves `~/.nemoclaw/rebuild-ba A non-default gateway uses the corresponding entries under `~/.nemoclaw/gateways//`. 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 OpenShell's gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped cleanup and preserves the shared 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. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 6f432415f67..a9bb6d3bfbe 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3214,7 +3214,8 @@ The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw 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 OpenShell's gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped cleanup and preserves the shared 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. 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 4181e6bbc02..31b590a2801 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -896,6 +896,41 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + 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", 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 7fa05e3818b..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(""), }; } diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index e25a0c92591..11fd5f47693 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -136,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, @@ -147,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(""), }, ); @@ -184,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, @@ -195,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(""), }, ); @@ -232,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, @@ -243,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(""), }, ); @@ -262,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", @@ -274,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(""), }, ); @@ -298,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, @@ -366,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 @@ -1018,7 +1010,7 @@ describe("uninstall run plan", () => { } = {}, ): UninstallRunDeps { return { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "", @@ -1029,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(""), }; } @@ -1356,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(""), }, ); @@ -1386,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(""), }, ); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 168f48c6f1c..e80fcf5611d 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1128,9 +1128,8 @@ interface OtherGatewayInspection { /** * 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). When OpenShell is available, `null` means "stay conservative": - * callers must not treat an absence they cannot prove as evidence that a - * gateway is gone. (#7315) + * 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; @@ -1180,7 +1179,7 @@ function inspectOtherGatewayEnvironments( }; } const liveNames = liveGatewayNames(); - if (liveNames === null && runtime.commandExists("openshell")) { + if (liveNames === null) { return { otherGatewayEnvironmentsRemain: true, sharedRegistryMustBePreserved: false, From a906f3b1de17478ca5f27606456589d9f6d20a12 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 15:02:45 -0700 Subject: [PATCH 12/14] test(uninstall): model OpenShell in PTY driver Signed-off-by: Senthil Ravichandran --- test/fixtures/uninstall-prompt-pty-driver.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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. From 955fd5ff5e2cde8a09abb1271c651d6aac07b24e Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 15:12:56 -0700 Subject: [PATCH 13/14] fix(uninstall): remove redundant gateway guard Signed-off-by: Senthil Ravichandran --- src/lib/actions/uninstall/run-plan.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index e80fcf5611d..9d5f2760b37 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1185,10 +1185,7 @@ function inspectOtherGatewayEnvironments( sharedRegistryMustBePreserved: false, }; } - if ( - liveNames !== null && - [...liveNames].some((name) => name !== resolveGatewayName(GATEWAY_PORT)) - ) { + if ([...liveNames].some((name) => name !== resolveGatewayName(GATEWAY_PORT))) { return { otherGatewayEnvironmentsRemain: true, sharedRegistryMustBePreserved: false, From c4edc0e13992b73ec81bfcd1d93931d50a22babd Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 15:35:59 -0700 Subject: [PATCH 14/14] test(sandbox): restore file size budget Signed-off-by: Senthil Ravichandran --- test/sandbox-provisioning.test.ts | 7 ------- 1 file changed, 7 deletions(-) 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"; }',