From 44caf416e32bfd2571a8200212e05e82bb4661ef Mon Sep 17 00:00:00 2001 From: Udaya Tejas Date: Thu, 20 Aug 2026 14:04:02 -0700 Subject: [PATCH 01/19] fix(uninstall): exit nonzero when the gateway registration cannot be removed The full uninstall path discarded removeGatewayRegistration's result and returned true unconditionally, so a failed 'openshell gateway remove' still exited 0, printed the success banner, and wiped ~/.nemoclaw. Return the result instead, matching finishScopedOpenShellCleanup in the same file. removeGatewayRegistration already returns true for an absent gateway, so a clean host is unaffected. Signed-off-by: Udaya Tejas --- src/lib/actions/uninstall/run-plan.test.ts | 29 ++++++++++++++++++++++ src/lib/actions/uninstall/run-plan.ts | 3 +-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 19b8bdedd5f..5f9dfafe03a 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -1455,4 +1455,33 @@ describe("uninstall run plan", () => { expect(killed).toContain(9999887); expect(logs).toContain("Stopped host openshell-gateway process 9999887"); }); + + it("exits nonzero when full uninstall cannot remove the gateway registration", () => { + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: false }, + { + commandExists: () => true, + env: { HOME: "/tmp/nemoclaw-uninstall-test-gateway-remove" } as NodeJS.ProcessEnv, + error: (line: string) => warnings.push(line), + existsSync: () => false, + hasPortableRuntimeCleanup: () => false, + isTty: false, + kill: () => true, + log: () => {}, + rmSync: vi.fn(), + run: (command: string, args: string[]) => + command === "openshell" && args[0] === "gateway" && args[1] === "remove" + ? { status: 1, stdout: "", stderr: "connection refused" } + : okWithKnownGatewayList(command, args), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + expect(warnings).toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(warnings).toContain( + "Uninstall completed with errors. Some state may remain on disk; see warnings above.", + ); + }); }); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 105435afb98..7971cd6f914 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1540,8 +1540,7 @@ function removeOpenShellResources( { onSkip: providerDeleteSkipMessage(provider) }, ); } - removeGatewayRegistration(runtime, gatewayLabel, !externallySupervised); - return true; + return removeGatewayRegistration(runtime, gatewayLabel, !externallySupervised); } function canRemoveScopedOpenShellResources( From 578192e6a3ff2a09365679c023a5851356fab26e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 21 Aug 2026 07:21:38 -0700 Subject: [PATCH 02/19] fix(uninstall): preserve state on registration failure Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.test.ts | 109 ++++++++++++--------- src/lib/actions/uninstall/run-plan.ts | 24 ++++- 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 5f9dfafe03a..41db61cdac6 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -561,52 +561,52 @@ describe("uninstall run plan", () => { expect(logs).toContain("Stopped Ollama auth proxy 33333"); }); - it.each([ - "ollama-auth-proxy-helper.mjs", - "ollama-auth-proxy.mts.backup", - ])("never kills the near-named %s process on :11435", (scriptName) => { - const logs: string[] = []; - const killed: number[] = []; - const stub = psStub("99999", { - exited: new Set(), - cmdline: `/usr/bin/node /opt/nemoclaw/scripts/${scriptName}\n`, - }); - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: () => true, - env: { - HOME: "/tmp/nemoclaw-uninstall-test-2759-foreign", - LOGNAME: "testuser", - } as NodeJS.ProcessEnv, - existsSync: () => false, - isTty: false, - kill: (pid) => { - killed.push(pid); - return true; - }, - log: (line) => logs.push(line), - rmSync: vi.fn(), - run: (command, args) => { - if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { - return ok("99999\n"); - } - if (command === "ps") { - const result = stub(args); - if (result) return result; - } - if (args[0] === "-c") return ok("/fake/bin/tool\n"); - if (args[0] === "-f") return ok(""); - return okWithKnownGatewayList(command, args); + it.each(["ollama-auth-proxy-helper.mjs", "ollama-auth-proxy.mts.backup"])( + "never kills the near-named %s process on :11435", + (scriptName) => { + const logs: string[] = []; + const killed: number[] = []; + const stub = psStub("99999", { + exited: new Set(), + cmdline: `/usr/bin/node /opt/nemoclaw/scripts/${scriptName}\n`, + }); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => true, + env: { + HOME: "/tmp/nemoclaw-uninstall-test-2759-foreign", + LOGNAME: "testuser", + } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + kill: (pid) => { + killed.push(pid); + return true; + }, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: (command, args) => { + if (command === "lsof" && args[0] === "-ti" && args[1] === ":11435") { + return ok("99999\n"); + } + if (command === "ps") { + const result = stub(args); + if (result) return result; + } + if (args[0] === "-c") return ok("/fake/bin/tool\n"); + if (args[0] === "-f") return ok(""); + return okWithKnownGatewayList(command, args); + }, + runDocker: () => ok(""), }, - runDocker: () => ok(""), - }, - ); + ); - expect(result.exitCode).toBe(0); - expect(killed).not.toContain(99999); - expect(logs).toContain("No Ollama auth proxy processes found"); - }); + expect(result.exitCode).toBe(0); + expect(killed).not.toContain(99999); + expect(logs).toContain("No Ollama auth proxy processes found"); + }, + ); it("kills the model router via onboard-session routerPid (#5169)", () => { const logs: string[] = []; @@ -1458,6 +1458,8 @@ describe("uninstall run plan", () => { it("exits nonzero when full uninstall cannot remove the gateway registration", () => { const warnings: string[] = []; + const logs: string[] = []; + const rmSync = vi.fn(); const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: false }, { @@ -1468,20 +1470,31 @@ describe("uninstall run plan", () => { hasPortableRuntimeCleanup: () => false, isTty: false, kill: () => true, - log: () => {}, - rmSync: vi.fn(), + log: (line) => logs.push(line), + rmSync, run: (command: string, args: string[]) => command === "openshell" && args[0] === "gateway" && args[1] === "remove" - ? { status: 1, stdout: "", stderr: "connection refused" } + ? { + status: 1, + stdout: "", + stderr: "connection refused; OPENAI_API_KEY=must-not-be-logged", + } : okWithKnownGatewayList(command, args), runDocker: () => ok(""), }, ); expect(result.exitCode).toBe(1); - expect(warnings).toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(warnings).toContain( + "Could not remove gateway registration 'nemoclaw': openshell gateway remove failed (connection refused; exit 1).", + ); + expect(warnings.join("\n")).not.toContain("must-not-be-logged"); + expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable"); expect(warnings).toContain( "Uninstall completed with errors. Some state may remain on disk; see warnings above.", ); + expect(rmSync).not.toHaveBeenCalled(); + expect(logs).not.toContain("[3/6] NemoClaw CLI"); + expect(logs).not.toContain("Claws retracted. Until next time."); }); }); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 7097ea6474f..18944a8edcb 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -861,6 +861,19 @@ const GATEWAY_ALREADY_ABSENT = const GATEWAY_REMOVE_UNSUPPORTED = /unrecognized subcommand ['"]remove['"]|unknown command ['"]remove['"]/i; +function gatewayRegistrationRemovalFailureMessage( + gatewayLabel: string, + operation: "destroy" | "remove", + result: RunResult, +): string { + const output = `${result.stdout}\n${result.stderr}`; + // Map untrusted command output only to fixed phrases so diagnostics stay + // actionable without echoing credentials or unbounded sandbox-controlled text. + const cause = /connection refused/iu.test(output) ? "connection refused; " : ""; + const status = result.status === null ? "no exit status" : `exit ${String(result.status)}`; + return `Could not remove gateway registration '${gatewayLabel}': openshell gateway ${operation} failed (${cause}${status}).`; +} + function removeGatewayRegistration( runtime: UninstallRuntime, gatewayLabel: string, @@ -880,7 +893,7 @@ function removeGatewayRegistration( return true; } if (!GATEWAY_REMOVE_UNSUPPORTED.test(removeOutput)) { - runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + runtime.warn(gatewayRegistrationRemovalFailureMessage(gatewayLabel, "remove", removeResult)); return false; } if (!allowLegacyDestroy) { @@ -905,7 +918,7 @@ function removeGatewayRegistration( runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); return true; } - runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + runtime.warn(gatewayRegistrationRemovalFailureMessage(gatewayLabel, "destroy", destroyResult)); return false; } @@ -1729,8 +1742,7 @@ function removeNvmLeftovers(paths: UninstallPaths, runtime: UninstallRuntime): v const versionDir = path.join(nodeVersionsDir, version.name); const modulesDir = path.join(versionDir, "lib", "node_modules"); const packageEntry = dirEntries(modulesDir).find( - (entry) => - (entry.isDirectory() || entry.isSymbolicLink()) && entry.name === "nemoclaw", + (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && entry.name === "nemoclaw", ); const packageDir = packageEntry ? path.join(modulesDir, packageEntry.name) : null; const packageBins = packageDir ? nvmPackageBinTargets(packageDir) : new Map(); @@ -2307,7 +2319,9 @@ function removeHostModelStores( preserveForFailedLlamaCleanup: boolean, ): boolean { if (preserveForFailedLlamaCleanup) { - runtime.log("Managed llama.cpp cleanup did not complete. NemoClaw kept model stores for retry."); + runtime.log( + "Managed llama.cpp cleanup did not complete. NemoClaw kept model stores for retry.", + ); return true; } if (scopedToSelectedGateway) { From 53a51b37f8279529ae11e37f54c15c02d60c6ac5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 21 Aug 2026 08:55:28 -0700 Subject: [PATCH 03/19] test(uninstall): cover registration failure paths Signed-off-by: Apurv Kumaria --- .../run-plan-gateway-segregation.test.ts | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 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 05c5e23847b..3df38328e51 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -205,6 +205,9 @@ 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 logs: string[] = []; + const rmSync = vi.fn(); + const warnings: string[] = []; const responses = new Map([ ["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))], [ @@ -233,7 +236,9 @@ describe("uninstall gateway-port segregation (#3053)", () => { }, requiredCapabilities: [], }), - rmSync: vi.fn(), + error: (line) => warnings.push(line), + log: (line) => logs.push(line), + rmSync, run: (command, args) => { calls.push({ args, command }); return responses.get([command, ...args].join(" ")) ?? ok(); @@ -242,12 +247,18 @@ describe("uninstall gateway-port segregation (#3053)", () => { }, ); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); const openshellCalls = calls .filter(({ command }) => command === "openshell") .map(({ args }) => args); expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); expect(openshellCalls).not.toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); + expect(warnings).toContain( + "Could not remove local registration for externally supervised gateway 'nemoclaw'. NemoClaw will not use the legacy gateway destroy command for an externally supervised gateway.", + ); + expect(rmSync).not.toHaveBeenCalled(); + expect(logs).not.toContain("[3/6] NemoClaw CLI"); + expect(logs).not.toContain("Claws retracted. Until next time."); }); it("fails before uninstall effects when gateway authority revalidation fails (#6576)", () => { @@ -311,6 +322,8 @@ describe("uninstall gateway-port segregation (#3053)", () => { it("does not hide a current gateway remove failure behind the legacy verb", () => { const calls: Array<{ args: string[]; command: string }> = []; + const logs: string[] = []; + const rmSync = vi.fn(); const warnings: string[] = []; const responses = new Map([ ["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))], @@ -323,7 +336,8 @@ describe("uninstall gateway-port segregation (#3053)", () => { env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, existsSync: () => false, isTty: false, - rmSync: vi.fn(), + log: (line) => logs.push(line), + rmSync, run: (command, args) => { calls.push({ args, command }); return responses.get([command, ...args].join(" ")) ?? ok(); @@ -333,13 +347,73 @@ describe("uninstall gateway-port segregation (#3053)", () => { }, ); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); const openshellCalls = calls .filter(({ command }) => command === "openshell") .map(({ args }) => args); expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false); - expect(warnings.join("\n")).toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(warnings).toContain( + "Could not remove gateway registration 'nemoclaw': openshell gateway remove failed (exit 1).", + ); + expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(rmSync).not.toHaveBeenCalled(); + expect(logs).not.toContain("[3/6] NemoClaw CLI"); + expect(logs).not.toContain("Claws retracted. Until next time."); + }); + + it("fails closed when the legacy gateway destroy command fails", () => { + const calls: Array<{ args: string[]; command: string }> = []; + const logs: string[] = []; + const rmSync = vi.fn(); + const warnings: 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'" }, + ], + [ + "openshell gateway destroy -g nemoclaw", + { + status: 1, + stdout: "", + stderr: "connection refused; OPENAI_API_KEY=must-not-be-logged", + }, + ], + ]); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + error: (line) => warnings.push(line), + existsSync: () => false, + isTty: false, + log: (line) => logs.push(line), + rmSync, + run: (command, args) => { + calls.push({ args, command }); + return responses.get([command, ...args].join(" ")) ?? ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(openshellCalls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); + expect(warnings).toContain( + "Could not remove gateway registration 'nemoclaw': openshell gateway destroy failed (connection refused; exit 1).", + ); + expect(warnings.join("\n")).not.toContain("must-not-be-logged"); + expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(rmSync).not.toHaveBeenCalled(); + expect(logs).not.toContain("[3/6] NemoClaw CLI"); + expect(logs).not.toContain("Claws retracted. Until next time."); }); it("preserves the gateways/ subtree so uninstalling one environment leaves the others", () => { From cee0668d97581e67671af8103ee749554fbacdaf Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 21 Aug 2026 09:50:17 -0700 Subject: [PATCH 04/19] test(uninstall): isolate provider no-op wording Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan-preserved-registry.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 8622808ab7c..08918b6b8fc 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -103,6 +103,8 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65 run: (command, args) => command === "openshell" && args[0] === "gateway" && args[1] === "list" ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : command === "openshell" && args[0] === "gateway" && args[1] === "remove" + ? ok() : command === "openshell" ? notFound() : args[0] === "-c" From ac95e65cbc77e8de0de817f348b1520a3a7cf390 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 21 Aug 2026 10:05:15 -0700 Subject: [PATCH 05/19] test(uninstall): constrain gateway removal mock Signed-off-by: Apurv Kumaria --- .../actions/uninstall/run-plan-preserved-registry.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 08918b6b8fc..2deba28f134 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -103,7 +103,10 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65 run: (command, args) => command === "openshell" && args[0] === "gateway" && args[1] === "list" ? ok(JSON.stringify([{ name: "nemoclaw" }])) - : command === "openshell" && args[0] === "gateway" && args[1] === "remove" + : command === "openshell" && + args[0] === "gateway" && + args[1] === "remove" && + args[2] === "nemoclaw" ? ok() : command === "openshell" ? notFound() From d0b576baf27e19187323f6a6edba2f407d43980f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 21 Aug 2026 21:21:50 -0700 Subject: [PATCH 06/19] test(uninstall): split user-data cases Signed-off-by: Carlos Villela --- src/lib/actions/uninstall/run-plan.test.ts | 444 ---------------- .../uninstall/run-plan.user-data.test.ts | 488 ++++++++++++++++++ 2 files changed, 488 insertions(+), 444 deletions(-) create mode 100644 src/lib/actions/uninstall/run-plan.user-data.test.ts diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index d467763abeb..c68d7a134f5 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -973,450 +973,6 @@ describe("uninstall run plan", () => { ); }); - describe("user-data preservation under ~/.nemoclaw/", () => { - function setupStateDir(): { tmpHome: string; stateDir: string } { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); - const stateDir = path.join(tmpHome, ".nemoclaw"); - fs.mkdirSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"), - "{}", - ); - fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true }); - fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello"); - fs.writeFileSync( - path.join(stateDir, "sandboxes.json"), - JSON.stringify({ - defaultSandbox: "sb1", - sandboxes: { sb1: { name: "sb1", gatewayName: "nemoclaw", gatewayPort: 8080 } }, - }), - ); - fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); - fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.pid"), "1235"); - fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.json"), "{}"); - fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.lock"), "lock"); - fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.log"), "{}\n"); - fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.pid"), "1236"); - fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter-token"), "secret"); - fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.json"), "{}"); - fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.lock"), "lock"); - fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.log"), "{}\n"); - fs.mkdirSync(path.join(stateDir, "source")); - return { tmpHome, stateDir }; - } - - function tempScopedExistsSync(tmpHome: string): (target: string) => boolean { - return (target: string) => target.startsWith(tmpHome) && fs.existsSync(target); - } - - function preserveCaseDeps( - tmpHome: string, - logs: string[], - opts: { - envOverrides?: Record; - isTty?: boolean; - readLine?: UninstallRunDeps["readLine"]; - } = {}, - ): UninstallRunDeps { - return { - commandExists: (command) => command === "openshell", - env: { - HOME: tmpHome, - NEMOCLAW_NON_INTERACTIVE: "", - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - ...(opts.envOverrides ?? {}), - } as NodeJS.ProcessEnv, - existsSync: tempScopedExistsSync(tmpHome), - isTty: opts.isTty ?? false, - log: (line) => logs.push(line), - ...(opts.readLine ? { readLine: opts.readLine } : {}), - run: vi.fn(okWithKnownGatewayList), - runDocker: () => ok(""), - }; - } - - function expectPreservedEntries(stateDir: string): void { - expect( - fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), - ).toBe(true); - expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe( - true, - ); - expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true); - } - - function expectNoPreserveSignals(logs: string[]): void { - expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true); - expect(logs.every((line) => !line.includes("preserved:"))).toBe(true); - } - - it("preserves rebuild-backups/, backups/, and sandboxes.json by default in non-interactive runs", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs), - ); - - expect(result.exitCode).toBe(0); - expectPreservedEntries(stateDir); - expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.pid"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.json"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.lock"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.log"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.pid"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter-token"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.json"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.lock"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.log"))).toBe(false); - expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false); - expect(logs).toContain( - `Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`, - ); - expect( - logs.some((line) => line.includes("preserved: rebuild-backups, backups, sandboxes.json")), - ).toBe(true); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("purges the whole state dir when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain(`Removed ${stateDir}`); - expect(logs).toContain( - "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", - ); - expectNoPreserveSignals(logs); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("purges the whole state dir when destroyUserData is set, even with --yes on a non-TTY", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain(`Removed ${stateDir}`); - expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); - expectNoPreserveSignals(logs); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("destroyUserData purges on a TTY without prompting", () => { - const { tmpHome, stateDir } = setupStateDir(); - const readLine = vi.fn(() => "y"); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { isTty: true, readLine }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); - expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); - expect(readLine).not.toHaveBeenCalled(); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("destroyUserData without --yes renders a purge-aware global confirmation and skips the user-data prompt", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, destroyUserData: true, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { isTty: true, readLine: () => "y" }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain( - " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)", - ); - expect( - logs.every( - (line) => - line !== - " · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)", - ), - ).toBe(true); - expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("env var without --yes renders a purge-aware global confirmation", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, - isTty: true, - readLine: () => "y", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain( - " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)", - ); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("destroyUserData takes precedence over NEMOCLAW_UNINSTALL_DESTROY_USER_DATA env var", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); - expect( - logs.every( - (line) => - line !== - "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", - ), - ).toBe(true); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("non-interactive hint mentions --destroy-user-data alongside the env var on non-TTY without --yes", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { readLine: () => "y" }), - ); - - expect(result.exitCode).toBe(0); - expectPreservedEntries(stateDir); - expect( - logs.some( - (line) => - line.includes("--destroy-user-data") && - line.includes("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1"), - ), - ).toBe(true); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("purges via interactive y/N prompt when user answers yes", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const replies = ["yes", "y"]; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - isTty: true, - readLine: () => replies.shift() ?? null, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain("Also remove them? [y/N]"); - expect(logs).toContain("Acknowledged; purging user data."); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("keeps user data when interactive prompt is declined", () => { - const { tmpHome, stateDir } = setupStateDir(); - try { - const logs: string[] = []; - const replies = ["yes", ""]; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - isTty: true, - readLine: () => replies.shift() ?? null, - }), - ); - - expect(result.exitCode).toBe(0); - expectPreservedEntries(stateDir); - expect(logs).toContain("Keeping user data."); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("preserves entries on a TTY when NEMOCLAW_NON_INTERACTIVE=1 is set instead of --yes", () => { - const { tmpHome, stateDir } = setupStateDir(); - const readLine = vi.fn(() => "yes"); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { - envOverrides: { NEMOCLAW_NON_INTERACTIVE: "1" }, - isTty: true, - readLine, - }), - ); - - expect(result.exitCode).toBe(0); - expectPreservedEntries(stateDir); - expect(logs).toContain( - `Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`, - ); - expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); - expect(readLine).toHaveBeenCalledTimes(1); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("fails closed before cleanup when ~/.nemoclaw cannot be inspected", () => { - const { tmpHome, stateDir } = setupStateDir(); - const realLstat = fs.lstatSync; - const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((p: fs.PathLike) => { - if (String(p) === stateDir) { - const err = new Error("permission denied") as NodeJS.ErrnoException; - err.code = "EACCES"; - throw err; - } - return realLstat(p); - }); - try { - const logs: string[] = []; - const warnings: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - ...preserveCaseDeps(tmpHome, logs), - error: (line) => warnings.push(line), - }, - ); - - expect(result.exitCode).toBe(1); - expect(warnings.some((line) => line.includes("permission denied"))).toBe(true); - expect(logs).not.toContain("Claws retracted. Until next time."); - expect( - fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), - ).toBe(true); - } finally { - lstatSpy.mockRestore(); - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("refuses to follow or remove ~/.nemoclaw when it is a symlink", () => { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); - const realTarget = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-target-"), - ); - const stateDir = path.join(tmpHome, ".nemoclaw"); - fs.symlinkSync(realTarget, stateDir); - // Symlink target intentionally non-empty so that following it would - // tempt the selective-wipe path; lstat must short-circuit that. - fs.writeFileSync(path.join(realTarget, "rebuild-backups"), "should not be followed"); - try { - const logs: string[] = []; - const errors: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: (command) => command === "openshell", - env: { HOME: tmpHome } as NodeJS.ProcessEnv, - existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target), - error: (line) => errors.push(line), - isTty: false, - log: (line) => logs.push(line), - run: vi.fn(okWithKnownGatewayList), - runDocker: () => ok(""), - }, - ); - - expect(result.exitCode).toBe(1); - expect(fs.lstatSync(stateDir).isSymbolicLink()).toBe(true); - expect(fs.existsSync(realTarget)).toBe(true); - expect(errors.join("\n")).toContain( - "Managed distributed vLLM state root is not a real directory", - ); - expect(logs).not.toContain(`Removed ${stateDir}`); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(realTarget, { recursive: true, force: true }); - } - }); - - it("skips the preservation notice when no protected entries exist on disk", () => { - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); - const stateDir = path.join(tmpHome, ".nemoclaw"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); - try { - const logs: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: (command) => command === "openshell", - env: { HOME: tmpHome } as NodeJS.ProcessEnv, - existsSync: tempScopedExistsSync(tmpHome), - isTty: false, - log: (line) => logs.push(line), - run: vi.fn(okWithKnownGatewayList), - runDocker: () => ok(""), - }, - ); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(stateDir)).toBe(false); - expect(logs).toContain(`Removed ${stateDir}`); - expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - }); - it("kills host openshell-gateway process during full uninstall (#3516)", () => { const logs: string[] = []; const killed: number[] = []; diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts new file mode 100644 index 00000000000..b0609d04c81 --- /dev/null +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + type RunResult, + runUninstallPlan as runUninstallPlanBase, + type UninstallRunDeps, + type UninstallRunOptions, +} from "./run-plan"; + +function ok(stdout = ""): RunResult { + return { status: 0, stdout, stderr: "" }; +} + +function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) { + return runUninstallPlanBase(options, { + resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({ + gatewayName, + gatewayPort, + mode: "nemoclaw-managed", + source: gatewayPort === 8080 ? "packaged-service" : "standalone", + endpoint: null, + stateDir: null, + supervisor: null, + requiredCapabilities: [], + }), + ...deps, + }); +} + +function okWithKnownGatewayList(command: string, args: readonly string[]): RunResult { + return command === "openshell" && args[0] === "gateway" && args[1] === "list" + ? ok(JSON.stringify([{ name: "nemoclaw" }])) + : ok(); +} + +describe("uninstall run plan", () => { + describe("user-data preservation under ~/.nemoclaw/", () => { + function setupStateDir(): { tmpHome: string; stateDir: string } { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"), + "{}", + ); + fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true }); + fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello"); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "sb1", + sandboxes: { sb1: { name: "sb1", gatewayName: "nemoclaw", gatewayPort: 8080 } }, + }), + ); + fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.pid"), "1235"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.json"), "{}"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.lock"), "lock"); + fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.log"), "{}\n"); + fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.pid"), "1236"); + fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter-token"), "secret"); + fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.json"), "{}"); + fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.lock"), "lock"); + fs.writeFileSync(path.join(stateDir, "https-pin-runtime-adapter.log"), "{}\n"); + fs.mkdirSync(path.join(stateDir, "source")); + return { tmpHome, stateDir }; + } + + function tempScopedExistsSync(tmpHome: string): (target: string) => boolean { + return (target: string) => target.startsWith(tmpHome) && fs.existsSync(target); + } + + function preserveCaseDeps( + tmpHome: string, + logs: string[], + opts: { + envOverrides?: Record; + isTty?: boolean; + readLine?: UninstallRunDeps["readLine"]; + } = {}, + ): UninstallRunDeps { + return { + commandExists: (command) => command === "openshell", + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", + ...(opts.envOverrides ?? {}), + } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: opts.isTty ?? false, + log: (line) => logs.push(line), + ...(opts.readLine ? { readLine: opts.readLine } : {}), + run: vi.fn(okWithKnownGatewayList), + runDocker: () => ok(""), + }; + } + + function expectPreservedEntries(stateDir: string): void { + expect( + fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), + ).toBe(true); + expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe( + true, + ); + expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true); + } + + function expectNoPreserveSignals(logs: string[]): void { + expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true); + expect(logs.every((line) => !line.includes("preserved:"))).toBe(true); + } + + it("preserves rebuild-backups/, backups/, and sandboxes.json by default in non-interactive runs", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs), + ); + + expect(result.exitCode).toBe(0); + expectPreservedEntries(stateDir); + expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.pid"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.lock"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "openrouter-runtime-adapter.log"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.pid"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter-token"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.lock"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "https-pin-runtime-adapter.log"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false); + expect(logs).toContain( + `Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`, + ); + expect( + logs.some((line) => line.includes("preserved: rebuild-backups, backups, sandboxes.json")), + ).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("purges the whole state dir when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain(`Removed ${stateDir}`); + expect(logs).toContain( + "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", + ); + expectNoPreserveSignals(logs); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("purges the whole state dir when destroyUserData is set, even with --yes on a non-TTY", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain(`Removed ${stateDir}`); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expectNoPreserveSignals(logs); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData purges on a TTY without prompting", () => { + const { tmpHome, stateDir } = setupStateDir(); + const readLine = vi.fn(() => "y"); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { isTty: true, readLine }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + expect(readLine).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData without --yes renders a purge-aware global confirmation and skips the user-data prompt", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { isTty: true, readLine: () => "y" }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain( + " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)", + ); + expect( + logs.every( + (line) => + line !== + " · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)", + ), + ).toBe(true); + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("env var without --yes renders a purge-aware global confirmation", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + isTty: true, + readLine: () => "y", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain( + " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)", + ); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("destroyUserData takes precedence over NEMOCLAW_UNINSTALL_DESTROY_USER_DATA env var", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); + expect( + logs.every( + (line) => + line !== + "NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.", + ), + ).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("non-interactive hint mentions --destroy-user-data alongside the env var on non-TTY without --yes", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { readLine: () => "y" }), + ); + + expect(result.exitCode).toBe(0); + expectPreservedEntries(stateDir); + expect( + logs.some( + (line) => + line.includes("--destroy-user-data") && + line.includes("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1"), + ), + ).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("purges via interactive y/N prompt when user answers yes", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const replies = ["yes", "y"]; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + isTty: true, + readLine: () => replies.shift() ?? null, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain("Also remove them? [y/N]"); + expect(logs).toContain("Acknowledged; purging user data."); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps user data when interactive prompt is declined", () => { + const { tmpHome, stateDir } = setupStateDir(); + try { + const logs: string[] = []; + const replies = ["yes", ""]; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + isTty: true, + readLine: () => replies.shift() ?? null, + }), + ); + + expect(result.exitCode).toBe(0); + expectPreservedEntries(stateDir); + expect(logs).toContain("Keeping user data."); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("preserves entries on a TTY when NEMOCLAW_NON_INTERACTIVE=1 is set instead of --yes", () => { + const { tmpHome, stateDir } = setupStateDir(); + const readLine = vi.fn(() => "yes"); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: false, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { + envOverrides: { NEMOCLAW_NON_INTERACTIVE: "1" }, + isTty: true, + readLine, + }), + ); + + expect(result.exitCode).toBe(0); + expectPreservedEntries(stateDir); + expect(logs).toContain( + `Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`, + ); + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + expect(readLine).toHaveBeenCalledTimes(1); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("fails closed before cleanup when ~/.nemoclaw cannot be inspected", () => { + const { tmpHome, stateDir } = setupStateDir(); + const realLstat = fs.lstatSync; + const inspectError = new Error("permission denied") as NodeJS.ErrnoException; + inspectError.code = "EACCES"; + const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((p: fs.PathLike) => + String(p) === stateDir + ? (() => { + throw inspectError; + })() + : realLstat(p), + ); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + ...preserveCaseDeps(tmpHome, logs), + error: (line) => warnings.push(line), + }, + ); + + expect(result.exitCode).toBe(1); + expect(warnings.some((line) => line.includes("permission denied"))).toBe(true); + expect(logs).not.toContain("Claws retracted. Until next time."); + expect( + fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), + ).toBe(true); + } finally { + lstatSpy.mockRestore(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("refuses to follow or remove ~/.nemoclaw when it is a symlink", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); + const realTarget = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-target-"), + ); + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.symlinkSync(realTarget, stateDir); + // Symlink target intentionally non-empty so that following it would + // tempt the selective-wipe path; lstat must short-circuit that. + fs.writeFileSync(path.join(realTarget, "rebuild-backups"), "should not be followed"); + try { + const logs: string[] = []; + const errors: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target), + error: (line) => errors.push(line), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(okWithKnownGatewayList), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + expect(fs.lstatSync(stateDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(realTarget)).toBe(true); + expect(errors.join("\n")).toContain( + "Managed distributed vLLM state root is not a real directory", + ); + expect(logs).not.toContain(`Removed ${stateDir}`); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(realTarget, { recursive: true, force: true }); + } + }); + + it("skips the preservation notice when no protected entries exist on disk", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); + try { + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(okWithKnownGatewayList), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(logs).toContain(`Removed ${stateDir}`); + expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + }); +}); From 92f2c7edb095f1a7736d3f42662f885440ed228f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 23 Aug 2026 21:06:33 -0700 Subject: [PATCH 07/19] test(uninstall): isolate legacy failure fixture Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3b515a1c2b4..8827dbdf5dc 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -394,7 +394,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { { assumeYes: true, deleteModels: false, keepOpenShell: true }, { commandExists: (command) => command !== "docker" && command !== "pgrep", - env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + env: { HOME: STATIC_TEST_HOME, TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, error: (line) => warnings.push(line), existsSync: () => false, isTty: false, From 6086b14d80eacb4ae2177d58bc8a66d211380a94 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 23 Aug 2026 21:15:35 -0700 Subject: [PATCH 08/19] fix(uninstall): match explicit gateway absence Signed-off-by: Apurv Kumaria --- .../run-plan-gateway-segregation.test.ts | 81 ++++++++++--------- src/lib/actions/uninstall/run-plan.test.ts | 6 +- src/lib/actions/uninstall/run-plan.ts | 32 +++++++- 3 files changed, 75 insertions(+), 44 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 8827dbdf5dc..dc9876c4db5 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -328,47 +328,50 @@ describe("uninstall gateway-port segregation (#3053)", () => { expect(openshellCalls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); }); - it("does not hide a current gateway remove failure behind the legacy verb", () => { - const calls: Array<{ args: string[]; command: string }> = []; - const logs: string[] = []; - const rmSync = vi.fn(); - 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( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: (command) => command !== "docker" && command !== "pgrep", - env: { HOME: STATIC_TEST_HOME, TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, - existsSync: () => false, - isTty: false, - log: (line) => logs.push(line), - rmSync, - run: (command, args) => { - calls.push({ args, command }); - return responses.get([command, ...args].join(" ")) ?? ok(); + it.each(["permission denied", "gateway service endpoint not found"])( + "does not hide a current gateway remove failure reported as %s behind the legacy verb", + (diagnostic) => { + const calls: Array<{ args: string[]; command: string }> = []; + const logs: string[] = []; + const rmSync = vi.fn(); + 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: diagnostic }], + ]); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: STATIC_TEST_HOME, TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + error: (line) => warnings.push(line), + existsSync: () => false, + isTty: false, + log: (line) => logs.push(line), + rmSync, + run: (command, args) => { + calls.push({ args, command }); + return responses.get([command, ...args].join(" ")) ?? ok(); + }, + runDocker: () => ok(""), }, - runDocker: () => ok(""), - error: (line) => warnings.push(line), - }, - ); + ); - expect(result.exitCode).toBe(1); - const openshellCalls = calls - .filter(({ command }) => command === "openshell") - .map(({ args }) => args); - expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); - expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false); - expect(warnings).toContain( - "Could not remove gateway registration 'nemoclaw': openshell gateway remove failed (exit 1).", - ); - expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable"); - expect(rmSync).not.toHaveBeenCalled(); - expect(logs).not.toContain("[3/6] NemoClaw CLI"); - expect(logs).not.toContain("Claws retracted. Until next time."); - }); + expect(result.exitCode).toBe(1); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false); + expect(warnings).toContain( + "Could not remove gateway registration 'nemoclaw': openshell gateway remove failed (exit 1).", + ); + expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable"); + expect(rmSync).not.toHaveBeenCalled(); + expect(logs).not.toContain("[3/6] NemoClaw CLI"); + expect(logs).not.toContain("Claws retracted. Until next time."); + }, + ); it("fails closed when the legacy gateway destroy command fails", () => { const calls: Array<{ args: string[]; command: string }> = []; diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index c68d7a134f5..cd9963da160 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -957,7 +957,11 @@ describe("uninstall run plan", () => { rmSync: vi.fn(), run: (command, args) => { if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { - return { status: 1, stdout: "", stderr: "gateway not found" }; + return { + status: 1, + stdout: "", + stderr: "No gateway metadata found for 'nemoclaw'.", + }; } if (args[0] === "-c") return ok("/fake/bin/tool\n"); return okWithKnownGatewayList(command, args); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 424d3ef3e31..9b4d969f6f5 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -869,11 +869,30 @@ function deletePortableOpenShellSandbox( return false; } -const GATEWAY_ALREADY_ABSENT = - /gateway[^\n]*(?:does not exist|not found)|No (?:active )?gateway|No gateway metadata found/i; const GATEWAY_REMOVE_UNSUPPORTED = /unrecognized subcommand ['"]remove['"]|unknown command ['"]remove['"]/i; +function isExplicitGatewayRegistrationAbsence(output: string, gatewayLabel: string): boolean { + const clean = output.replace(/\x1B\[[0-?]*[ -/]*[@-~]/gu, ""); + const escapedLabel = gatewayLabel.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const namedGateway = `(?:['"]${escapedLabel}['"]|${escapedLabel})`; + const linePrefix = "(?:^|\\n)\\s*(?:Error:\\s*)?(?:×\\s*)?"; + const lineEnd = "\\.?\\s*(?:\\n|$)"; + return ( + new RegExp(`${linePrefix}No gateway metadata found for ${namedGateway}${lineEnd}`, "iu").test( + clean, + ) || + new RegExp( + `${linePrefix}gateway\\s+${namedGateway}\\s+(?:does not exist|not found)${lineEnd}`, + "iu", + ).test(clean) || + new RegExp( + `${linePrefix}status:\\s*NotFound,\\s*message:\\s*["']gateway\\s+${escapedLabel}\\s+(?:does not exist|not found)["']${lineEnd}`, + "iu", + ).test(clean) + ); +} + function gatewayRegistrationRemovalFailureMessage( gatewayLabel: string, operation: "destroy" | "remove", @@ -901,7 +920,7 @@ function removeGatewayRegistration( } const removeOutput = `${removeResult.stdout}\n${removeResult.stderr}`; - if (GATEWAY_ALREADY_ABSENT.test(removeOutput)) { + if (isExplicitGatewayRegistrationAbsence(removeOutput, gatewayLabel)) { runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); return true; } @@ -927,7 +946,12 @@ function removeGatewayRegistration( runtime.log(`Destroyed legacy gateway '${gatewayLabel}'`); return true; } - if (GATEWAY_ALREADY_ABSENT.test(`${destroyResult.stdout}\n${destroyResult.stderr}`)) { + if ( + isExplicitGatewayRegistrationAbsence( + `${destroyResult.stdout}\n${destroyResult.stderr}`, + gatewayLabel, + ) + ) { runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); return true; } From 4290af23c3c70bc57c536eadbc0186dee3cc46c0 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 04:17:37 -0700 Subject: [PATCH 09/19] fix(uninstall): stage selective cleanup safely --- .../run-plan-preserved-registry.test.ts | 112 --------------- src/lib/actions/uninstall/run-plan.ts | 130 +++++++++++++----- .../uninstall/run-plan.user-data.test.ts | 60 +++++++- 3 files changed, 155 insertions(+), 147 deletions(-) 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 d65fab018a3..807c97ab1be 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -46,50 +46,6 @@ function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) }); } -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"); - fs.mkdirSync(path.join(stateDir, "rebuild-backups"), { recursive: true }); - fs.mkdirSync(path.join(stateDir, "backups"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "sandboxes.json"), - JSON.stringify({ - defaultSandbox: "preserved-box", - sandboxes: { "preserved-box": { name: "preserved-box" } }, - }), - ); - return { tmpHome, stateDir }; -} - -function preserveCaseDeps( - tmpHome: string, - logs: string[], - warnings: string[], - opts: { envOverrides?: Record } = {}, -): UninstallRunDeps { - return { - commandExists: (command) => command === "openshell", - env: { - HOME: tmpHome, - NEMOCLAW_NON_INTERACTIVE: "", - NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", - ...(opts.envOverrides ?? {}), - } as NodeJS.ProcessEnv, - error: (line) => warnings.push(line), - existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target), - isTty: false, - log: (line) => logs.push(line), - run: vi.fn(okWithKnownGatewayList), - runDocker: () => ok(""), - }; -} - describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#6520)", () => { it("uses the 'already removed' wording for provider and sandbox delete no-ops", () => { // Same defect family as the gateway wording fix (#3456 sub-bug 4): when @@ -132,72 +88,4 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65 expect(combined).not.toContain("Deleted provider 'nvidia-nim' skipped"); expect(combined).not.toContain("Deleted all OpenShell sandboxes skipped"); }); - - it("warns that preserved sandboxes.json cannot be auto-recovered after uninstall removes its dependencies", () => { - // Uninstall keeps sandboxes.json but removes the gateway, provider - // registrations, and Docker image its recorded sandboxes depend on. Say - // so at the moment the preserve choice is made, with a remediation path, - // instead of letting a later reinstall report false success. - const { tmpHome } = setupStateDir(); - try { - const logs: string[] = []; - const warnings: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, warnings), - ); - - expect(result.exitCode).toBe(0); - const joined = warnings.join("\n"); - expect(joined).toContain("sandboxes.json"); - expect(joined).toContain("cannot be recovered automatically"); - expect(joined).toContain("--destroy-user-data"); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("warns on the interactive keep path when the purge prompt is declined", () => { - const { tmpHome } = setupStateDir(); - try { - const logs: string[] = []; - const warnings: string[] = []; - // First reply confirms the uninstall itself; the empty second reply - // declines the purge prompt, keeping user data. - const replies = ["yes", ""]; - const result = runUninstallPlan( - { assumeYes: false, deleteModels: false, keepOpenShell: true }, - { - ...preserveCaseDeps(tmpHome, logs, warnings), - isTty: true, - readLine: () => replies.shift() ?? null, - }, - ); - - expect(result.exitCode).toBe(0); - expect(logs).toContain("Keeping user data."); - expect(warnings.join("\n")).toContain("cannot be recovered automatically"); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); - - it("does not warn about unrecoverable sandboxes when user data is purged", () => { - const { tmpHome } = setupStateDir(); - try { - const logs: string[] = []; - const warnings: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, warnings, { - envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(warnings.join("\n")).not.toContain("cannot be recovered automatically"); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); }); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 9b4d969f6f5..0e389ee31a4 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -418,43 +418,107 @@ function removePathExcept( deps.log(`Removed ${target}`); return true; } - // Only enumerate when `target` is a real directory. A symlink or non-dir - // would make readdirSync follow into / fail noisily; treat those as - // wholesale removal, matching prior behaviour for unusual shapes. - let stat: fs.Stats; - try { - stat = fs.lstatSync(target); - } catch (err) { - // ENOENT — gone already, nothing to do. Any other error means we cannot - // safely decide whether to enumerate or remove; surface it and report - // failure so uninstall returns a non-zero exit instead of silently - // claiming success while leaving state on disk. - if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return true; - deps.warn(`Failed to inspect ${target}: ${err instanceof Error ? err.message : String(err)}`); + const noFollow = fs.constants.O_NOFOLLOW; + const directoryOnly = fs.constants.O_DIRECTORY; + if (typeof noFollow !== "number" || typeof directoryOnly !== "number") { + deps.warn(`Failed to clean ${target}: no-follow directory access is unavailable`); return false; } - if (!stat.isDirectory()) { - deps.rmSync(target, { force: true, recursive: true }); - deps.log(`Removed ${target}`); - return true; - } - const preserveSet = new Set(preserve); - const children = fs.readdirSync(target); - for (const entry of children) { - if (preserveSet.has(entry)) continue; - deps.rmSync(path.join(target, entry), { force: true, recursive: true }); - } - // Track preserved order against the declared allowlist so the log line is - // stable across filesystems with non-deterministic readdir ordering. - const childSet = new Set(children); - const preserved = preserve.filter((name) => childSet.has(name)); - if (preserved.length === 0) { - deps.rmSync(target, { force: true, recursive: true }); - deps.log(`Removed ${target}`); + let descriptor: number | null = null; + let stagingRoot: string | null = null; + let stagedTarget: string | null = null; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollow | directoryOnly); + const identity = fs.fstatSync(descriptor, { bigint: true }); + const named = fs.lstatSync(target, { bigint: true }); + if (!sameDirectoryIdentity(identity, named)) { + throw new Error("directory changed during inspection"); + } + + stagingRoot = fs.mkdtempSync( + path.join(path.dirname(target), `.${path.basename(target)}-cleanup-`), + ); + stagedTarget = path.join(stagingRoot, "content"); + fs.renameSync(target, stagedTarget); + if (!sameDirectoryIdentity(identity, fs.lstatSync(stagedTarget, { bigint: true }))) { + throw new Error("directory changed before cleanup"); + } + + const preserveSet = new Set(preserve); + const children = fs.readdirSync(stagedTarget); + for (const entry of children) { + if (preserveSet.has(entry)) continue; + if (!sameDirectoryIdentity(identity, fs.lstatSync(stagedTarget, { bigint: true }))) { + throw new Error("directory changed during cleanup"); + } + deps.rmSync(path.join(stagedTarget, entry), { force: true, recursive: true }); + } + if (!sameDirectoryIdentity(identity, fs.lstatSync(stagedTarget, { bigint: true }))) { + throw new Error("directory changed during cleanup"); + } + + // Track preserved order against the declared allowlist so the log line is + // stable across filesystems with non-deterministic readdir ordering. + const childSet = new Set(children); + const preserved = preserve.filter((name) => childSet.has(name)); + if (preserved.length === 0) { + if (fs.existsSync(target)) throw new Error("directory path was replaced during cleanup"); + fs.renameSync(stagedTarget, target); + stagedTarget = null; + if (!sameDirectoryIdentity(identity, fs.lstatSync(target, { bigint: true }))) { + throw new Error("directory changed before removal"); + } + deps.rmSync(target, { force: true, recursive: true }); + deps.log(`Removed ${target}`); + return true; + } + if (fs.existsSync(target)) throw new Error("directory path was replaced during cleanup"); + fs.renameSync(stagedTarget, target); + stagedTarget = null; + if (!sameDirectoryIdentity(identity, fs.lstatSync(target, { bigint: true }))) { + throw new Error("directory changed while restoring preserved data"); + } + deps.log(`Removed contents of ${target} (preserved: ${preserved.join(", ")})`); return true; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT" && descriptor === null) return true; + deps.warn(`Failed to clean ${target}: ${err instanceof Error ? err.message : String(err)}`); + return false; + } finally { + if (stagedTarget && descriptor !== null) { + try { + const identity = fs.fstatSync(descriptor, { bigint: true }); + if ( + !fs.existsSync(target) && + sameDirectoryIdentity(identity, fs.lstatSync(stagedTarget, { bigint: true })) + ) { + fs.renameSync(stagedTarget, target); + stagedTarget = null; + } + } catch { + // Leave the staged directory in place for manual recovery. + } + } + if (descriptor !== null) fs.closeSync(descriptor); + if (stagingRoot) { + try { + fs.rmdirSync(stagingRoot); + } catch { + if (stagedTarget) deps.warn(`Cleanup staging remains at ${stagedTarget}.`); + } + } } - deps.log(`Removed contents of ${target} (preserved: ${preserved.join(", ")})`); - return true; +} + +function sameDirectoryIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.isDirectory() && + right.isDirectory() && + !right.isSymbolicLink() && + left.dev === right.dev && + left.ino === right.ino && + left.uid === right.uid + ); } function removeFileWithOptionalSudo(target: string, deps: UninstallRuntime): void { diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index b0609d04c81..4a0eca62e77 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -84,6 +84,7 @@ describe("uninstall run plan", () => { envOverrides?: Record; isTty?: boolean; readLine?: UninstallRunDeps["readLine"]; + warnings?: string[]; } = {}, ): UninstallRunDeps { return { @@ -94,6 +95,7 @@ describe("uninstall run plan", () => { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", ...(opts.envOverrides ?? {}), } as NodeJS.ProcessEnv, + error: (line) => opts.warnings?.push(line), existsSync: tempScopedExistsSync(tmpHome), isTty: opts.isTty ?? false, log: (line) => logs.push(line), @@ -122,9 +124,10 @@ describe("uninstall run plan", () => { const { tmpHome, stateDir } = setupStateDir(); try { const logs: string[] = []; + const warnings: string[] = []; const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs), + preserveCaseDeps(tmpHome, logs, { warnings }), ); expect(result.exitCode).toBe(0); @@ -146,6 +149,10 @@ describe("uninstall run plan", () => { expect( logs.some((line) => line.includes("preserved: rebuild-backups, backups, sandboxes.json")), ).toBe(true); + const warningText = warnings.join("\n"); + expect(warningText).toContain("sandboxes.json"); + expect(warningText).toContain("cannot be recovered automatically"); + expect(warningText).toContain("--destroy-user-data"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } @@ -178,9 +185,10 @@ describe("uninstall run plan", () => { const { tmpHome, stateDir } = setupStateDir(); try { const logs: string[] = []; + const warnings: string[] = []; const result = runUninstallPlan( { assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs), + preserveCaseDeps(tmpHome, logs, { warnings }), ); expect(result.exitCode).toBe(0); @@ -188,6 +196,7 @@ describe("uninstall run plan", () => { expect(logs).toContain(`Removed ${stateDir}`); expect(logs).toContain("--destroy-user-data set; purging user data under ~/.nemoclaw/."); expectNoPreserveSignals(logs); + expect(warnings.join("\n")).not.toContain("cannot be recovered automatically"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } @@ -338,18 +347,21 @@ describe("uninstall run plan", () => { const { tmpHome, stateDir } = setupStateDir(); try { const logs: string[] = []; + const warnings: string[] = []; const replies = ["yes", ""]; const result = runUninstallPlan( { assumeYes: false, deleteModels: false, keepOpenShell: true }, preserveCaseDeps(tmpHome, logs, { isTty: true, readLine: () => replies.shift() ?? null, + warnings, }), ); expect(result.exitCode).toBe(0); expectPreservedEntries(stateDir); expect(logs).toContain("Keeping user data."); + expect(warnings.join("\n")).toContain("cannot be recovered automatically"); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } @@ -416,6 +428,50 @@ describe("uninstall run plan", () => { } }); + it("fails without following a state-directory replacement during cleanup", () => { + const { tmpHome, stateDir } = setupStateDir(); + const replacementTarget = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-uninstall-replacement-target-"), + ); + const originalStateDir = path.join(tmpHome, "original-state"); + const protectedFile = path.join(replacementTarget, "keep.txt"); + fs.writeFileSync(protectedFile, "keep"); + const renameSync = fs.renameSync; + const replacements = new Map void>(); + replacements.set(stateDir, (destination) => { + replacements.delete(stateDir); + expect(String(destination)).toContain(".nemoclaw-cleanup-"); + renameSync(stateDir, originalStateDir); + fs.symlinkSync(replacementTarget, stateDir); + }); + const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => { + replacements.get(String(source))?.(destination); + return renameSync(source, destination); + }); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { warnings }), + ); + + expect(result.exitCode).toBe(1); + expect(fs.readFileSync(protectedFile, "utf8")).toBe("keep"); + expect( + fs.existsSync( + path.join(originalStateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"), + ), + ).toBe(true); + expect(warnings.join("\n")).toContain("directory changed before cleanup"); + expect(logs).not.toContain("Claws retracted. Until next time."); + } finally { + renameSpy.mockRestore(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(replacementTarget, { recursive: true, force: true }); + } + }); + it("refuses to follow or remove ~/.nemoclaw when it is a symlink", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); const realTarget = fs.mkdtempSync( From 9869d837047f8f1c2504d4fd815970db815ffaf7 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 04:43:46 -0700 Subject: [PATCH 10/19] fix(uninstall): explain staged state recovery Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.ts | 12 +++++- .../uninstall/run-plan.user-data.test.ts | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 0e389ee31a4..d1a332eeaa9 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -425,6 +425,7 @@ function removePathExcept( return false; } let descriptor: number | null = null; + let preservedEntries: readonly string[] = []; let stagingRoot: string | null = null; let stagedTarget: string | null = null; try { @@ -461,6 +462,7 @@ function removePathExcept( // stable across filesystems with non-deterministic readdir ordering. const childSet = new Set(children); const preserved = preserve.filter((name) => childSet.has(name)); + preservedEntries = preserved; if (preserved.length === 0) { if (fs.existsSync(target)) throw new Error("directory path was replaced during cleanup"); fs.renameSync(stagedTarget, target); @@ -504,7 +506,15 @@ function removePathExcept( try { fs.rmdirSync(stagingRoot); } catch { - if (stagedTarget) deps.warn(`Cleanup staging remains at ${stagedTarget}.`); + if (stagedTarget) { + const preservedSummary = + preservedEntries.length === 0 + ? "" + : ` It contains preserved entries: ${preservedEntries.join(", ")}.`; + deps.warn( + `Cleanup did not restore ${target}. Unreconciled staging remains at ${stagedTarget}.${preservedSummary} Do not retry uninstall until you inspect both paths without following links. If ${target} is absent and the staging entry is the intended directory, move it back to ${target}; if ${target} exists, stop and reconcile both paths before continuing.`, + ); + } } } } diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 4a0eca62e77..9454b25a262 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -472,6 +472,49 @@ describe("uninstall run plan", () => { } }); + it("reports how to restore preserved state when restoration fails", () => { + const { tmpHome, stateDir } = setupStateDir(); + const renameSync = fs.renameSync; + const restoreError = new Error("restore denied"); + const renameHandlers = new Map void>(); + let stagedTarget = ""; + renameHandlers.set(stateDir, (destination) => { + stagedTarget = String(destination); + renameHandlers.set(stagedTarget, () => { + throw restoreError; + }); + }); + const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => { + renameHandlers.get(String(source))?.(destination); + return renameSync(source, destination); + }); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { warnings }), + ); + + expect(result.exitCode).toBe(1); + expect(stagedTarget).not.toBe(""); + expect(fs.existsSync(stateDir)).toBe(false); + expectPreservedEntries(stagedTarget); + const warningText = warnings.join("\n"); + expect(warningText).toContain(`Cleanup did not restore ${stateDir}`); + expect(warningText).toContain(`Unreconciled staging remains at ${stagedTarget}`); + expect(warningText).toContain( + "It contains preserved entries: rebuild-backups, backups, sandboxes.json.", + ); + expect(warningText).toContain("Do not retry uninstall"); + expect(warningText).toContain(`move it back to ${stateDir}`); + expect(logs).not.toContain("Claws retracted. Until next time."); + } finally { + renameSpy.mockRestore(); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("refuses to follow or remove ~/.nemoclaw when it is a symlink", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); const realTarget = fs.mkdtempSync( From 9239c1dc19cc287204b0ab9453b104538e659583 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 24 Aug 2026 05:24:17 -0700 Subject: [PATCH 11/19] fix(uninstall): delete verified staged state Signed-off-by: Carlos Villela --- src/lib/actions/uninstall/run-plan.ts | 7 +--- .../uninstall/run-plan.user-data.test.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index d1a332eeaa9..d7f0a4e130b 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -464,13 +464,8 @@ function removePathExcept( const preserved = preserve.filter((name) => childSet.has(name)); preservedEntries = preserved; if (preserved.length === 0) { - if (fs.existsSync(target)) throw new Error("directory path was replaced during cleanup"); - fs.renameSync(stagedTarget, target); + deps.rmSync(stagedTarget, { force: true, recursive: true }); stagedTarget = null; - if (!sameDirectoryIdentity(identity, fs.lstatSync(target, { bigint: true }))) { - throw new Error("directory changed before removal"); - } - deps.rmSync(target, { force: true, recursive: true }); deps.log(`Removed ${target}`); return true; } diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 9454b25a262..b8e4fc89dc8 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -555,6 +555,43 @@ describe("uninstall run plan", () => { } }); + it("does not delete a replacement added at the canonical path during final cleanup", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); + const stateDir = path.join(tmpHome, ".nemoclaw"); + const replacementFile = path.join(stateDir, "unrelated.txt"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); + const removedPaths: string[] = []; + try { + const logs: string[] = []; + const rmSync: typeof fs.rmSync = (target, options) => { + const removedPath = String(target); + removedPaths.push(removedPath); + expect(removedPath).toMatch(/\.nemoclaw-cleanup-.*\/content$/u); + expect(fs.existsSync(stateDir)).toBe(false); + fs.mkdirSync(stateDir); + fs.writeFileSync(replacementFile, "keep"); + fs.rmSync(target, options); + }; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + ...preserveCaseDeps(tmpHome, logs), + rmSync, + }, + ); + + expect(result.exitCode).toBe(0); + expect(removedPaths).toContainEqual( + expect.stringMatching(/\.nemoclaw-cleanup-.*\/content$/u), + ); + expect(removedPaths).not.toContain(stateDir); + expect(fs.readFileSync(replacementFile, "utf8")).toBe("keep"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("skips the preservation notice when no protected entries exist on disk", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); const stateDir = path.join(tmpHome, ".nemoclaw"); From 2b614a57a19d5aa61a4afc51fc0dba0c64103f45 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 05:48:27 -0700 Subject: [PATCH 12/19] test(uninstall): isolate staged cleanup race Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.user-data.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index b8e4fc89dc8..b84e10eea23 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -560,7 +560,6 @@ describe("uninstall run plan", () => { const stateDir = path.join(tmpHome, ".nemoclaw"); const replacementFile = path.join(stateDir, "unrelated.txt"); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); const removedPaths: string[] = []; try { const logs: string[] = []; From f346e5c183b7a645a6875ae288b0552df0a066fa Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 06:03:23 -0700 Subject: [PATCH 13/19] fix(uninstall): detect abandoned staged state Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.ts | 56 ++++++++++++++++++- .../uninstall/run-plan.user-data.test.ts | 29 ++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index d7f0a4e130b..89e5fcd3408 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -406,13 +406,67 @@ function dormantHostGlobalLifecycleState(sharedRoot: string): boolean { } } +function findUnreconciledCleanupTargets(target: string): readonly string[] { + const parent = path.dirname(target); + const prefix = `.${path.basename(target)}-cleanup-`; + let parentIdentity: fs.BigIntStats; + try { + parentIdentity = fs.lstatSync(parent, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + if (parentIdentity.isSymbolicLink() || !parentIdentity.isDirectory()) { + throw new Error("parent is not a real directory"); + } + + const stagedTargets: string[] = []; + for (const entry of fs.readdirSync(parent)) { + if (!entry.startsWith(prefix)) continue; + const stagingRoot = path.join(parent, entry); + const stagedTarget = path.join(stagingRoot, "content"); + try { + const rootBefore = fs.lstatSync(stagingRoot, { bigint: true }); + if (rootBefore.isSymbolicLink() || !rootBefore.isDirectory()) continue; + const staged = fs.lstatSync(stagedTarget, { bigint: true }); + const rootAfter = fs.lstatSync(stagingRoot, { bigint: true }); + if ( + staged.isDirectory() && + !staged.isSymbolicLink() && + sameDirectoryIdentity(rootBefore, rootAfter) + ) { + stagedTargets.push(stagedTarget); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (!sameDirectoryIdentity(parentIdentity, fs.lstatSync(parent, { bigint: true }))) { + throw new Error("parent changed during inspection"); + } + return stagedTargets; +} + function removePathExcept( target: string, preserve: readonly string[], deps: Required> & Pick, ): boolean { - if (!deps.existsSync(target)) return true; + if (!deps.existsSync(target)) { + try { + const stagedTargets = findUnreconciledCleanupTargets(target); + if (stagedTargets.length === 0) return true; + deps.warn( + `Cleanup cannot continue because ${target} is absent and unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`, + ); + } catch (error) { + deps.warn( + `Failed to inspect interrupted cleanup state for ${target}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return false; + } if (preserve.length === 0) { deps.rmSync(target, { force: true, recursive: true }); deps.log(`Removed ${target}`); diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index b84e10eea23..3f0686e50a5 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -515,6 +515,35 @@ describe("uninstall run plan", () => { } }); + it("reports abandoned staged state before an uninstall retry", () => { + const { tmpHome, stateDir } = setupStateDir(); + const stagingRoot = fs.mkdtempSync( + path.join(tmpHome, `.${path.basename(stateDir)}-cleanup-`), + ); + const stagedTarget = path.join(stagingRoot, "content"); + fs.renameSync(stateDir, stagedTarget); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { warnings }), + ); + + expect(result.exitCode).toBe(1); + expect(fs.existsSync(stateDir)).toBe(false); + expectPreservedEntries(stagedTarget); + const warningText = warnings.join("\n"); + expect(warningText).toContain(`Cleanup cannot continue because ${stateDir} is absent`); + expect(warningText).toContain(`unreconciled staging remains at ${stagedTarget}`); + expect(warningText).toContain("Do not retry uninstall"); + expect(warningText).toContain(`move it back to ${stateDir}`); + expect(logs).not.toContain("Claws retracted. Until next time."); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("refuses to follow or remove ~/.nemoclaw when it is a symlink", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); const realTarget = fs.mkdtempSync( From 554fb6587be957dcac39da6a7241dda6ac624273 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 09:10:42 -0700 Subject: [PATCH 14/19] fix(uninstall): reject stale cleanup staging Signed-off-by: Apurv Kumaria --- .../run-plan-portable-leftover-state.test.ts | 13 ++++++- src/lib/actions/uninstall/run-plan.ts | 26 +++++++------- .../uninstall/run-plan.user-data.test.ts | 34 ++++++++++++++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan-portable-leftover-state.test.ts b/src/lib/actions/uninstall/run-plan-portable-leftover-state.test.ts index f4e6b023af8..e0de96d889d 100644 --- a/src/lib/actions/uninstall/run-plan-portable-leftover-state.test.ts +++ b/src/lib/actions/uninstall/run-plan-portable-leftover-state.test.ts @@ -287,7 +287,18 @@ describe("uninstall on a host that owns no portable lifecycle resource", () => { const result = uninstall(host); expect(result.exitCode).toBe(0); - expect(host.rmSync.mock.calls.map(([target]) => String(target))).toContain(host.stateDir); + const removedPaths = host.rmSync.mock.calls.map(([target]) => String(target)); + const stagedCleanupPrefix = path.join( + path.dirname(host.stateDir), + `.${path.basename(host.stateDir)}-cleanup-`, + ); + expect( + removedPaths.some( + (target) => target.startsWith(stagedCleanupPrefix) && path.basename(target) === "content", + ), + ).toBe(true); + expect(removedPaths).not.toContain(host.stateDir); + expect(fs.existsSync(host.stateDir)).toBe(false); }); it("refuses an unknown portable uninstall artifact in the configuration directory (#9581)", () => { diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 89e5fcd3408..f8d53ca3f0b 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -453,20 +453,22 @@ function removePathExcept( deps: Required> & Pick, ): boolean { - if (!deps.existsSync(target)) { - try { - const stagedTargets = findUnreconciledCleanupTargets(target); - if (stagedTargets.length === 0) return true; - deps.warn( - `Cleanup cannot continue because ${target} is absent and unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`, - ); - } catch (error) { - deps.warn( - `Failed to inspect interrupted cleanup state for ${target}: ${error instanceof Error ? error.message : String(error)}`, - ); - } + let stagedTargets: readonly string[]; + try { + stagedTargets = findUnreconciledCleanupTargets(target); + } catch (error) { + deps.warn( + `Failed to inspect interrupted cleanup state for ${target}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + if (stagedTargets.length > 0) { + deps.warn( + `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`, + ); return false; } + if (!deps.existsSync(target)) return true; if (preserve.length === 0) { deps.rmSync(target, { force: true, recursive: true }); deps.log(`Removed ${target}`); diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 3f0686e50a5..9b143925161 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -534,7 +534,39 @@ describe("uninstall run plan", () => { expect(fs.existsSync(stateDir)).toBe(false); expectPreservedEntries(stagedTarget); const warningText = warnings.join("\n"); - expect(warningText).toContain(`Cleanup cannot continue because ${stateDir} is absent`); + expect(warningText).toContain(`Cleanup cannot continue for ${stateDir}`); + expect(warningText).toContain(`unreconciled staging remains at ${stagedTarget}`); + expect(warningText).toContain("Do not retry uninstall"); + expect(warningText).toContain(`move it back to ${stateDir}`); + expect(logs).not.toContain("Claws retracted. Until next time."); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("reports abandoned staged state when the canonical directory also exists", () => { + const { tmpHome, stateDir } = setupStateDir(); + const stagingRoot = fs.mkdtempSync( + path.join(tmpHome, `.${path.basename(stateDir)}-cleanup-`), + ); + const stagedTarget = path.join(stagingRoot, "content"); + fs.renameSync(stateDir, stagedTarget); + fs.mkdirSync(stateDir); + const canonicalFile = path.join(stateDir, "canonical.txt"); + fs.writeFileSync(canonicalFile, "canonical"); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { warnings }), + ); + + expect(result.exitCode).toBe(1); + expect(fs.readFileSync(canonicalFile, "utf8")).toBe("canonical"); + expectPreservedEntries(stagedTarget); + const warningText = warnings.join("\n"); + expect(warningText).toContain(`Cleanup cannot continue for ${stateDir}`); expect(warningText).toContain(`unreconciled staging remains at ${stagedTarget}`); expect(warningText).toContain("Do not retry uninstall"); expect(warningText).toContain(`move it back to ${stateDir}`); From 981deeb13f2bf3349eea1cce2c616f96e7581fd0 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 10:04:05 -0700 Subject: [PATCH 15/19] fix(uninstall): preflight staged cleanup Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.ts | 47 +++++++++++++++++-- .../uninstall/run-plan.user-data.test.ts | 19 +++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index f8d53ca3f0b..c1a19524d16 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -447,6 +447,42 @@ function findUnreconciledCleanupTargets(target: string): readonly string[] { return stagedTargets; } +function unreconciledCleanupWarning(target: string, stagedTargets: readonly string[]): string { + return `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`; +} + +function preflightSelectiveCleanupTargets( + targets: readonly string[], + runtime: Pick, +): void { + for (const target of new Set(targets)) { + let stagedTargets: readonly string[]; + try { + stagedTargets = findUnreconciledCleanupTargets(target); + } catch (error) { + runtime.warn( + `Failed to inspect interrupted cleanup state for ${target}: ${error instanceof Error ? error.message : String(error)}`, + ); + throw new IncompleteSelectiveCleanupError(); + } + if (stagedTargets.length > 0) { + runtime.warn(unreconciledCleanupWarning(target, stagedTargets)); + throw new IncompleteSelectiveCleanupError(); + } + } +} + +function selectiveCleanupTargets( + paths: UninstallPaths, + portableRuntimeCleanup: boolean, +): readonly string[] { + const targets = [paths.nemoclawStateDir, paths.openshellConfigDir]; + if (portableRuntimeCleanup) { + targets.push(path.join(paths.nemoclawConfigDir, "portable"), paths.nemoclawConfigDir); + } + return targets; +} + function removePathExcept( target: string, preserve: readonly string[], @@ -463,9 +499,7 @@ function removePathExcept( return false; } if (stagedTargets.length > 0) { - deps.warn( - `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`, - ); + deps.warn(unreconciledCleanupWarning(target, stagedTargets)); return false; } if (!deps.existsSync(target)) return true; @@ -3044,6 +3078,8 @@ function executePlan( portableRuntimeCleanup: boolean, portableRetirementEntries: ReturnType, ): { ok: boolean } { + const portableConfigDir = path.join(paths.nemoclawConfigDir, "portable"); + preflightSelectiveCleanupTargets(selectiveCleanupTargets(paths, portableRuntimeCleanup), runtime); const externallySupervised = isExternallySupervised(teardownAuthority); if ( !canRemoveScopedOpenShellResources( @@ -3327,7 +3363,6 @@ function executePlan( } } else runtime.log("Keeping OpenShell configuration used by the default gateway service."); if (portableRuntimeCleanup) { - const portableConfigDir = path.join(paths.nemoclawConfigDir, "portable"); const portableConfigEntries = ["containers.conf", ...portableRetirementEntries.config]; if ( portableConfigEntries.some((entry) => @@ -3370,6 +3405,7 @@ function completePortablePlan( class IncompleteHostGatewayCleanupError extends Error {} class IncompleteBedrockRuntimeAdapterCleanupError extends Error {} +class IncompleteSelectiveCleanupError extends Error {} function stopHostGatewayProcessesForUninstall( runtime: UninstallRuntime, @@ -3532,7 +3568,8 @@ export function runUninstallPlan( } catch (error) { if ( !(error instanceof IncompleteHostGatewayCleanupError) && - !(error instanceof IncompleteBedrockRuntimeAdapterCleanupError) + !(error instanceof IncompleteBedrockRuntimeAdapterCleanupError) && + !(error instanceof IncompleteSelectiveCleanupError) ) { throw error; } diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 9b143925161..44b6a57796f 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -525,9 +525,17 @@ describe("uninstall run plan", () => { try { const logs: string[] = []; const warnings: string[] = []; + const run = vi.fn(okWithKnownGatewayList); + const runDocker = vi.fn(() => ok("")); + const rmSync = vi.fn(fs.rmSync); const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, - preserveCaseDeps(tmpHome, logs, { warnings }), + { + ...preserveCaseDeps(tmpHome, logs, { warnings }), + rmSync, + run, + runDocker, + }, ); expect(result.exitCode).toBe(1); @@ -538,6 +546,15 @@ describe("uninstall run plan", () => { expect(warningText).toContain(`unreconciled staging remains at ${stagedTarget}`); expect(warningText).toContain("Do not retry uninstall"); expect(warningText).toContain(`move it back to ${stateDir}`); + expect(logs.some((line) => /^\[\d+\/\d+\]/u.test(line))).toBe(false); + expect(rmSync).not.toHaveBeenCalled(); + expect(runDocker).not.toHaveBeenCalled(); + expect( + run.mock.calls.every( + ([command, args]) => + command === "openshell" && args[0] === "gateway" && args[1] === "list", + ), + ).toBe(true); expect(logs).not.toContain("Claws retracted. Until next time."); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); From af617f426174913cd5f45f67b93c7c2ce47e32af Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 12:29:38 -0700 Subject: [PATCH 16/19] fix(uninstall): distinguish staged recovery paths Allow staged restoration only when the canonical path is absent. Require manual reconciliation when both paths exist. Signed-off-by: Apurv Kumaria --- src/lib/actions/uninstall/run-plan.ts | 11 ++++++++++- src/lib/actions/uninstall/run-plan.user-data.test.ts | 7 ++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index c1a19524d16..d3e36ea6cbb 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -448,7 +448,16 @@ function findUnreconciledCleanupTargets(target: string): readonly string[] { } function unreconciledCleanupWarning(target: string, stagedTargets: readonly string[]): string { - return `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.`; + let targetIsAbsent = false; + try { + fs.lstatSync(target); + } catch (error) { + targetIsAbsent = (error as NodeJS.ErrnoException).code === "ENOENT"; + } + const recovery = targetIsAbsent + ? `The canonical path is absent. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.` + : `The canonical path ${target} also exists. Do not move the canonical path or any staging entry. Reconcile the canonical path and each staging entry before continuing.`; + return `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. ${recovery}`; } function preflightSelectiveCleanupTargets( diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 44b6a57796f..e73d2897585 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -586,7 +586,12 @@ describe("uninstall run plan", () => { expect(warningText).toContain(`Cleanup cannot continue for ${stateDir}`); expect(warningText).toContain(`unreconciled staging remains at ${stagedTarget}`); expect(warningText).toContain("Do not retry uninstall"); - expect(warningText).toContain(`move it back to ${stateDir}`); + expect(warningText).toContain(`The canonical path ${stateDir} also exists`); + expect(warningText).toContain("Do not move the canonical path or any staging entry"); + expect(warningText).toContain( + "Reconcile the canonical path and each staging entry before continuing", + ); + expect(warningText).not.toContain(`move it back to ${stateDir}`); expect(logs).not.toContain("Claws retracted. Until next time."); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); From d055965bf428a4546a3d873ad946ec1e5a4eaa5f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 27 Aug 2026 21:46:34 -0700 Subject: [PATCH 17/19] fix(uninstall): detect incomplete cleanup staging Signed-off-by: Carlos Villela --- src/lib/actions/uninstall/run-plan.ts | 18 ++++---- .../uninstall/run-plan.user-data.test.ts | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index ac9547b01f6..3cb45e8cc1e 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -434,15 +434,17 @@ function findUnreconciledCleanupTargets(target: string): readonly string[] { try { const rootBefore = fs.lstatSync(stagingRoot, { bigint: true }); if (rootBefore.isSymbolicLink() || !rootBefore.isDirectory()) continue; - const staged = fs.lstatSync(stagedTarget, { bigint: true }); - const rootAfter = fs.lstatSync(stagingRoot, { bigint: true }); - if ( - staged.isDirectory() && - !staged.isSymbolicLink() && - sameDirectoryIdentity(rootBefore, rootAfter) - ) { - stagedTargets.push(stagedTarget); + let staged: fs.BigIntStats | null = null; + try { + staged = fs.lstatSync(stagedTarget, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const rootAfter = fs.lstatSync(stagingRoot, { bigint: true }); + if (!sameDirectoryIdentity(rootBefore, rootAfter)) continue; + stagedTargets.push( + staged?.isDirectory() && !staged.isSymbolicLink() ? stagedTarget : stagingRoot, + ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index e73d2897585..0758da8ce42 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -561,6 +561,52 @@ describe("uninstall run plan", () => { } }); + it("reports empty interrupted staging when the canonical directory is absent", () => { + const { tmpHome, stateDir } = setupStateDir(); + fs.rmSync(stateDir, { recursive: true }); + const stagingRoot = fs.mkdtempSync( + path.join(tmpHome, `.${path.basename(stateDir)}-cleanup-`), + ); + try { + const logs: string[] = []; + const warnings: string[] = []; + const run = vi.fn(okWithKnownGatewayList); + const runDocker = vi.fn(() => ok("")); + const rmSync = vi.fn(fs.rmSync); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + ...preserveCaseDeps(tmpHome, logs, { warnings }), + rmSync, + run, + runDocker, + }, + ); + + expect(result.exitCode).toBe(1); + expect(fs.existsSync(stateDir)).toBe(false); + expect(fs.readdirSync(stagingRoot)).toEqual([]); + const warningText = warnings.join("\n"); + expect(warningText).toContain(`Cleanup cannot continue for ${stateDir}`); + expect(warningText).toContain(`unreconciled staging remains at ${stagingRoot}`); + expect(warningText).toContain("The canonical path is absent"); + expect(warningText).toContain("Do not retry uninstall"); + expect(warningText).toContain(`move it back to ${stateDir}`); + expect(logs.some((line) => /^\[\d+\/\d+\]/u.test(line))).toBe(false); + expect(rmSync).not.toHaveBeenCalled(); + expect(runDocker).not.toHaveBeenCalled(); + expect( + run.mock.calls.every( + ([command, args]) => + command === "openshell" && args[0] === "gateway" && args[1] === "list", + ), + ).toBe(true); + expect(logs).not.toContain("Claws retracted. Until next time."); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("reports abandoned staged state when the canonical directory also exists", () => { const { tmpHome, stateDir } = setupStateDir(); const stagingRoot = fs.mkdtempSync( From a69e8a74095332ff128ac7c48c0e5baf35da2304 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 28 Aug 2026 00:08:25 -0700 Subject: [PATCH 18/19] fix(uninstall): clarify staged cleanup recovery Signed-off-by: Carlos Villela --- src/lib/actions/uninstall/run-plan.ts | 6 +-- .../uninstall/run-plan.user-data.test.ts | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 3cb45e8cc1e..fd1920c9a9e 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -463,9 +463,9 @@ function unreconciledCleanupWarning(target: string, stagedTargets: readonly stri targetIsAbsent = (error as NodeJS.ErrnoException).code === "ENOENT"; } const recovery = targetIsAbsent - ? `The canonical path is absent. If a staging entry is the intended directory, move it back to ${target}; otherwise, stop and reconcile the paths before continuing.` + ? `The canonical path is absent. Inspect every listed staging path before deciding whether one entry is the intended directory; if so, move it back to ${target}. Otherwise, stop and reconcile the paths before continuing.` : `The canonical path ${target} also exists. Do not move the canonical path or any staging entry. Reconcile the canonical path and each staging entry before continuing.`; - return `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect both paths without following links. ${recovery}`; + return `Cleanup cannot continue for ${target} because unreconciled staging remains at ${stagedTargets.join(", ")}. Do not retry uninstall until you inspect the canonical target ${target} and every listed staging path without following links. ${recovery}`; } function preflightSelectiveCleanupTargets( @@ -614,7 +614,7 @@ function removePathExcept( ? "" : ` It contains preserved entries: ${preservedEntries.join(", ")}.`; deps.warn( - `Cleanup did not restore ${target}. Unreconciled staging remains at ${stagedTarget}.${preservedSummary} Do not retry uninstall until you inspect both paths without following links. If ${target} is absent and the staging entry is the intended directory, move it back to ${target}; if ${target} exists, stop and reconcile both paths before continuing.`, + `Cleanup did not restore ${target}. Unreconciled staging remains at ${stagedTarget}.${preservedSummary} Do not retry uninstall until you inspect the canonical target ${target} and every listed staging path without following links. If ${target} is absent, inspect every listed staging path before deciding whether one entry is the intended directory; if so, move it back to ${target}. If ${target} exists, stop and reconcile the canonical target and every listed staging path before continuing.`, ); } } diff --git a/src/lib/actions/uninstall/run-plan.user-data.test.ts b/src/lib/actions/uninstall/run-plan.user-data.test.ts index 0758da8ce42..0236742a4f0 100644 --- a/src/lib/actions/uninstall/run-plan.user-data.test.ts +++ b/src/lib/actions/uninstall/run-plan.user-data.test.ts @@ -561,6 +561,43 @@ describe("uninstall run plan", () => { } }); + it("reports every abandoned staging path before choosing a restore source", () => { + const { tmpHome, stateDir } = setupStateDir(); + const firstStagingRoot = fs.mkdtempSync( + path.join(tmpHome, `.${path.basename(stateDir)}-cleanup-`), + ); + const firstStagedTarget = path.join(firstStagingRoot, "content"); + fs.renameSync(stateDir, firstStagedTarget); + const secondStagingRoot = fs.mkdtempSync( + path.join(tmpHome, `.${path.basename(stateDir)}-cleanup-`), + ); + const secondStagedTarget = path.join(secondStagingRoot, "content"); + fs.mkdirSync(secondStagedTarget); + fs.writeFileSync(path.join(secondStagedTarget, "rebuild-backups"), "second candidate"); + try { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + preserveCaseDeps(tmpHome, logs, { warnings }), + ); + + expect(result.exitCode).toBe(1); + expect(fs.existsSync(stateDir)).toBe(false); + const warningText = warnings.join("\n"); + expect(warningText).toContain(`the canonical target ${stateDir}`); + expect(warningText).toContain(firstStagedTarget); + expect(warningText).toContain(secondStagedTarget); + expect(warningText).toContain("every listed staging path without following links"); + expect(warningText).toContain( + "Inspect every listed staging path before deciding whether one entry is the intended directory", + ); + expect(logs.some((line) => /^\[\d+\/\d+\]/u.test(line))).toBe(false); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("reports empty interrupted staging when the canonical directory is absent", () => { const { tmpHome, stateDir } = setupStateDir(); fs.rmSync(stateDir, { recursive: true }); From b22ac1129253e68c1b487b69cb3258eef39403f2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 28 Aug 2026 00:17:31 -0700 Subject: [PATCH 19/19] ci: retrigger PR review advisor Signed-off-by: Carlos Villela