diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 9245a2ab2e5..b76b7579a5a 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -257,6 +257,14 @@ nemoclaw uninstall | `--keep-openshell` | Leave OpenShell binaries installed. | | `--delete-models` | Also remove NemoClaw-pulled Ollama models. | + +`nemoclaw uninstall` preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots that `nemoclaw snapshot create` and `nemoclaw backup-all` write), `~/.nemoclaw/backups/` (workspace backups that `scripts/backup-workspace.sh` writes), and `~/.nemoclaw/sandboxes.json` (the sandbox registry) by default. +Uninstall removes every other entry under `~/.nemoclaw/`. +Interactive runs prompt before they remove the preserved entries; the default answer keeps them. +For non-interactive runs (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell), set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` to acknowledge data loss and remove the preserved entries as well. +See [`nemoclaw uninstall`](/reference/commands#nemoclaw-uninstall) for the full preservation contract. + + `nemoclaw uninstall` runs the version-pinned `uninstall.sh` that shipped with your installed CLI, so it does not fetch anything over the network at uninstall time. If the `nemoclaw` CLI is missing or broken, fall back to the hosted script: diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 85451dca94b..d27d90d4bbb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1196,6 +1196,30 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri $ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway ] ``` +##### User-data preservation under `~/.nemoclaw/` + +To avoid uninstall destroying host-side user data, uninstall preserves the following entries under `~/.nemoclaw/` by default: + +| Entry | What it holds | +|---|---| +| `rebuild-backups/` | Host-side snapshots that `nemoclaw snapshot create` and `nemoclaw backup-all` write. `nemoclaw snapshot restore` reads them back after you reinstall. | +| `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes (see [Backup and Restore](/manage-sandboxes/backup-restore)). | +| `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | + +Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). + +Decision matrix: + +| Context | Behaviour | +|---|---| +| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them? [y/N]`. Default `N` keeps the entries. | +| Interactive TTY, user answers `y` | Removes everything under `~/.nemoclaw/` (the previous full-removal behaviour). | +| Non-interactive (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or non-TTY shell) | Preserves the entries and prints a one-line notice. | +| Any context with `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the prompt and removes everything under `~/.nemoclaw/`. | + +The preserved entries survive uninstall as inert files on disk. +Reinstall NemoClaw and re-onboard the sandbox before `nemoclaw snapshot restore` can use them. + #### `nemoclaw uninstall` vs. the hosted `uninstall.sh` Both forms execute the same `uninstall.sh` with the same flags, but differ in where the script comes from and how much they trust the network. diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 15384cd6973..1c8e4700650 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -618,6 +618,285 @@ 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"), "[]"); + fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); + 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); + } + + 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 }, + { + commandExists: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + 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); + expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).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 }, + { + commandExists: () => false, + env: { + HOME: tmpHome, + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1", + } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + 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/."); + expect(logs.every((line) => !line.includes("preserved:"))).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 }, + { + commandExists: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: true, + log: (line) => logs.push(line), + readLine: () => replies.shift() ?? null, + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + 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 }, + { + commandExists: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: true, + log: (line) => logs.push(line), + readLine: () => replies.shift() ?? null, + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + 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); + 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 }, + { + commandExists: () => false, + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "1", + } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + // Simulate a TTY so we exercise the env-var-only branch (the prior + // tests reach the silent-preserve branch via !isTty or assumeYes). + isTty: true, + log: (line) => logs.push(line), + readLine, + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + 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); + expect(logs).toContain(`Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`); + // Interactive y/N prompt must not fire when NEMOCLAW_NON_INTERACTIVE is set. + expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true); + // The earlier generic confirm() prompt still consumes one readLine for "Proceed? [y/N]"; + // resolvePreserveSet must not consume another. + expect(readLine).toHaveBeenCalledTimes(1); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("exits non-zero and warns when lstat on ~/.nemoclaw fails with a non-ENOENT error", () => { + 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 }, + { + commandExists: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + error: (line) => warnings.push(line), + existsSync: tempScopedExistsSync(tmpHome), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + expect(warnings.some((line) => line.startsWith(`Failed to inspect ${stateDir}: `))).toBe(true); + expect(warnings).toContain( + "Uninstall completed with errors. Some state may remain on disk; see warnings above.", + ); + 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("removes ~/.nemoclaw wholesale when it is a symlink rather than a real directory", () => { + 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 result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: (target: string) => + target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(stateDir)).toBe(false); + expect(fs.existsSync(realTarget)).toBe(true); + expect(logs).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: () => false, + env: { HOME: tmpHome } as NodeJS.ProcessEnv, + existsSync: tempScopedExistsSync(tmpHome), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + 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 uninstall (#3516)", () => { const logs: string[] = []; const killed: number[] = []; diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index f288db89c9c..e14aca55fd4 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -111,6 +111,70 @@ function removePath(target: string, deps: Required snapshot create` and `nemoclaw backup-all`; `backups/` +// holds host-side workspace backups from `scripts/backup-workspace.sh`; +// `sandboxes.json` is the host-side sandbox registry. Full wipe still happens +// when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set, or when the user answers +// `y` to the interactive prompt. +const PRESERVED_USER_DATA_ENTRIES: readonly string[] = [ + "rebuild-backups", + "backups", + "sandboxes.json", +]; + +function removePathExcept( + target: string, + preserve: readonly string[], + deps: Required> & + Pick, +): boolean { + if (!deps.existsSync(target)) return true; + if (preserve.length === 0) { + deps.rmSync(target, { force: true, recursive: true }); + 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)}`); + 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}`); + return true; + } + deps.log(`Removed contents of ${target} (preserved: ${preserved.join(", ")})`); + return true; +} + function removeFileWithOptionalSudo(target: string, deps: UninstallRuntime): void { if (!deps.existsSync(target)) return; const parent = path.dirname(target); @@ -197,7 +261,8 @@ function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boole runtime.log("What will be removed:"); runtime.log(` · All OpenShell sandboxes, gateway, and ${branding.display} providers`); runtime.log(" · Related Docker containers, images, and volumes"); - runtime.log(" · ~/.nemoclaw ~/.config/openshell ~/.config/nemoclaw"); + runtime.log(" · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)"); + runtime.log(" · ~/.config/openshell ~/.config/nemoclaw"); runtime.log(` · Global ${branding.display} CLI (npm package: nemoclaw)`); runtime.log(options.deleteModels ? ` · Ollama models: ${NEMOCLAW_OLLAMA_MODELS.join(" ")}` : " · Ollama models: kept"); runtime.log("Proceed? [y/N]"); @@ -565,7 +630,57 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo else runtime.warn("Failed to remove /swapfile."); } -function executePlan(plan: UninstallPlan, paths: UninstallPaths, options: UninstallRunOptions, runtime: UninstallRuntime): void { +function detectPreservableEntries(paths: UninstallPaths, runtime: UninstallRuntime): string[] { + if (!runtime.existsSync(paths.nemoclawStateDir)) return []; + return PRESERVED_USER_DATA_ENTRIES.filter((name) => + runtime.existsSync(path.join(paths.nemoclawStateDir, name)), + ); +} + +function resolvePreserveSet( + paths: UninstallPaths, + options: UninstallRunOptions, + runtime: UninstallRuntime, +): readonly string[] { + // Explicit acknowledgement env var → full purge, matches today's behaviour. + if (runtime.env.NEMOCLAW_UNINSTALL_DESTROY_USER_DATA === "1") { + runtime.log("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/."); + return []; + } + const preservable = detectPreservableEntries(paths, runtime); + // Nothing on disk worth preserving → no message, no prompt; treat as default + // preserve set so a later snapshot-create still survives if the user re-runs. + if (preservable.length === 0) return PRESERVED_USER_DATA_ENTRIES; + // Non-interactive (no TTY, --yes, or NEMOCLAW_NON_INTERACTIVE=1) → preserve + // silently with a one-line notice. Default behaviour is safe; users who want + // a destructive uninstall in CI must set the env var. + const nonInteractive = + !runtime.isTty || options.assumeYes || runtime.env.NEMOCLAW_NON_INTERACTIVE === "1"; + if (nonInteractive) { + runtime.log(`Preserving ${preservable.join(", ")} under ${paths.nemoclawStateDir}.`); + runtime.log(" Set NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 to purge user data on uninstall."); + return PRESERVED_USER_DATA_ENTRIES; + } + runtime.log(`The following user data under ${paths.nemoclawStateDir} is preserved by default:`); + for (const name of preservable) runtime.log(` · ${name}`); + runtime.log("Also remove them? [y/N]"); + const reply = runtime.readLine(); + if (reply && /^(y|yes)$/i.test(reply.trim())) { + runtime.log("Acknowledged; purging user data."); + return []; + } + runtime.log("Keeping user data."); + return PRESERVED_USER_DATA_ENTRIES; +} + +function executePlan( + plan: UninstallPlan, + paths: UninstallPaths, + options: UninstallRunOptions, + runtime: UninstallRuntime, + preserveUnderStateDir: readonly string[], +): { ok: boolean } { + let ok = true; const branding = runtimeBranding(runtime); for (const [index, step] of plan.steps.entries()) { runtime.log(`[${index + 1}/${plan.steps.length}] ${planStepDisplayName(step.name, branding)}`); @@ -601,12 +716,13 @@ function executePlan(plan: UninstallPlan, paths: UninstallPaths, options: Uninst for (const pattern of paths.runtimeTempGlobs) removeGlob(pattern, runtime); if (options.keepOpenShell) runtime.log("Keeping OpenShell binaries as requested."); else for (const target of paths.openshellInstallPaths) removeFileWithOptionalSudo(target, runtime); - removePath(paths.nemoclawStateDir, runtime); + if (!removePathExcept(paths.nemoclawStateDir, preserveUnderStateDir, runtime)) ok = false; removePath(paths.gatewayLocalStateDir, runtime); removePath(paths.openshellConfigDir, runtime); removePath(paths.nemoclawConfigDir, runtime); } } + return { ok }; } export function buildRunPlan(options: UninstallRunOptions, deps: UninstallRunDeps = {}): { paths: UninstallPaths; plan: UninstallPlan } { @@ -632,7 +748,12 @@ export function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRu const { paths, plan } = buildRunPlan(options, { ...deps, env: runtime.env }); printBanner(runtime); if (!confirm(options, runtime)) return { exitCode: 0, plan }; - executePlan(plan, paths, options, runtime); - printBye(runtime); - return { exitCode: 0, plan }; + const preserveUnderStateDir = resolvePreserveSet(paths, options, runtime); + const { ok } = executePlan(plan, paths, options, runtime, preserveUnderStateDir); + if (ok) { + printBye(runtime); + } else { + runtime.error("Uninstall completed with errors. Some state may remain on disk; see warnings above."); + } + return { exitCode: ok ? 0 : 1, plan }; }