From d9b4dbd38a9784e49319059897b5f62a16314a47 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Fri, 31 Jul 2026 16:15:34 +0800 Subject: [PATCH] fix(uninstall): stop the selected gateway counting itself a sibling The gateways/ scan in inspectOtherGatewayEnvironments excluded the gateway being uninstalled by path identity, comparing each entry against the selected state root. That works only for a non-default port, whose state root is /gateways/. For the default port the state root is the shared root itself, so a /gateways/8080 directory never matched and the selected gateway was reported as its own sibling. Every other sibling filter in this file, and listGatewayStateRoots in src/lib/state/gateway-registry.ts, already compare port identity instead. Align this scan with that contract: a per-port directory named for the gateway being uninstalled is that gateway's own state, never a sibling. On a single-gateway host the misdetection scoped cleanup to the selected gateway, printed "Sibling gateways remain" for shared helper services, the HTTPS Pin Runtime adapter, provider registrations, Docker images and host state, and left all of them behind. When the scoped teardown then could not delete an already-absent OpenShell sandbox, uninstall reported incomplete cleanup and exited nonzero. The conservative treatments are unchanged: a symlink or non-directory entry, a name that is not a valid port, and an unavailable OpenShell gateway list all still count as siblings. Fixes #7987 Signed-off-by: Yanyun Liao --- .../run-plan-gateway-segregation.test.ts | 95 +++++++++++++++++++ src/lib/actions/uninstall/run-plan.ts | 12 ++- 2 files changed, 105 insertions(+), 2 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 245af303be7..328e886391d 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -1215,6 +1215,101 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + it("does not treat the selected gateway's own port directory as a sibling (#7987)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-self-sibling-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + // The selected gateway runs on the default port, so its state root is the + // shared root rather than gateways/8080. A leftover directory named for + // its own port must not make it count itself as a sibling. + fs.mkdirSync(path.join(stateDir, "gateways", "8080"), { 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); + // Only the selected gateway is live; there is no sibling at all. + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(logs.join("\n")).not.toContain("Sibling gateways remain"); + expect(logs.join("\n")).not.toContain("resources owned by gateway 'nemoclaw'"); + // A single-gateway host must get the full teardown, not the scoped one. + expect(openshellCalls).toContainEqual(["sandbox", "delete", "--all"]); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("still detects a live sibling alongside the selected gateway's own port directory (#7987)", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-self-and-sibling-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(path.join(stateDir, "gateways", "8080"), { recursive: true }); + 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); + return args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }])) + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + // Excluding our own port must not suppress a genuine sibling. + expect(logs.join("\n")).toContain("Sibling gateways remain"); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + 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; diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index c87a4d560e3..f564e3c25aa 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1415,10 +1415,18 @@ function inspectOtherGatewayEnvironments( // 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; + // A directory named for the gateway being uninstalled is that gateway's + // own state, never a sibling. Path identity alone does not catch it: for + // the default port `selectedRoot` is the shared root, so + // `/gateways/` never equals it and the + // selected gateway counts itself as a sibling, scoping cleanup to + // preserve resources nothing else owns (#7987). Match on port identity + // like every other sibling filter here and like `listGatewayStateRoots`. + if (port === GATEWAY_PORT) return false; + // A per-port directory whose gateway OpenShell no longer knows is an + // orphan; dismiss it only when the live set positively lacks it. const live = liveGatewayNames(); if (live === null) return true; return live.has(resolveGatewayName(port));