Skip to content

Commit d1e42b4

Browse files
authored
fix(uninstall): preserve user data by default (#4229)
## Summary `nemoclaw uninstall` used to `rm -rf ~/.nemoclaw/`, deleting every host-side snapshot and workspace backup. Switch to a selective wipe that keeps `rebuild-backups/`, `backups/`, and `sandboxes.json` by default. Full purge still available via interactive `y` prompt or `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`. ## Related Issue Fixes #4226. ## Changes - `src/lib/actions/uninstall/run-plan.ts`: new `PRESERVED_USER_DATA_ENTRIES`, `removePathExcept` (selective wipe with lstat guard against symlinked state dirs), and `resolvePreserveSet` (env var + interactive `y/N` prompt). - `src/lib/actions/uninstall/run-plan.test.ts`: new tests covering preserve-by-default, env-var purge, interactive `y`/`N`, `NEMOCLAW_NON_INTERACTIVE=1` on a TTY, symlinked state dir, no-preservable-on-disk skip, and non-ENOENT lstat failure exiting non-zero. - `docs/reference/commands.mdx`: new "User-data preservation under `~/.nemoclaw/`" subsection with the decision matrix. - `docs/manage-sandboxes/lifecycle.mdx`: `<Note>` summary above the uninstall flags table. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [X] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [X] `npx prek run --all-files` passes - [X] `npm test` passes - [X] Tests added or updated for new or changed behavior - [X] No secrets, API keys, or credentials committed - [X] Docs updated for user-facing behavior changes - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Tinson Lai <tinsonl@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Uninstall now preserves user data in ~/.nemoclaw by default (rebuild-backups/, backups/, sandboxes.json); other entries are removed. * **Behavior** * Interactive runs prompt before also removing preserved items (default: keep). Non-interactive/--yes preserves by default unless NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 forces full purge. Symlinks to the state path are not followed. * **Tests** * Expanded uninstall tests covering preservation, destructive purge, interactive/non-interactive prompts, symlink handling, and error paths. * **Documentation** * Added notes describing preserved items and prompt/flag behavior. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/4229?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 5fc700b commit d1e42b4

4 files changed

Lines changed: 438 additions & 6 deletions

File tree

docs/manage-sandboxes/lifecycle.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,14 @@ nemoclaw uninstall
257257
| `--keep-openshell` | Leave OpenShell binaries installed. |
258258
| `--delete-models` | Also remove NemoClaw-pulled Ollama models. |
259259

260+
<Note>
261+
`nemoclaw uninstall` preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots that `nemoclaw <name> 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.
262+
Uninstall removes every other entry under `~/.nemoclaw/`.
263+
Interactive runs prompt before they remove the preserved entries; the default answer keeps them.
264+
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.
265+
See [`nemoclaw uninstall`](/reference/commands#nemoclaw-uninstall) for the full preservation contract.
266+
</Note>
267+
260268
`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.
261269

262270
If the `nemoclaw` CLI is missing or broken, fall back to the hosted script:

docs/reference/commands.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,30 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri
12441244
$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway <name>]
12451245
```
12461246

1247+
##### User-data preservation under `~/.nemoclaw/`
1248+
1249+
To avoid uninstall destroying host-side user data, uninstall preserves the following entries under `~/.nemoclaw/` by default:
1250+
1251+
| Entry | What it holds |
1252+
|---|---|
1253+
| `rebuild-backups/` | Host-side snapshots that `nemoclaw <name> snapshot create` and `nemoclaw backup-all` write. `nemoclaw <name> snapshot restore` reads them back after you reinstall. |
1254+
| `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes (see [Backup and Restore](/manage-sandboxes/backup-restore)). |
1255+
| `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. |
1256+
1257+
Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.).
1258+
1259+
Decision matrix:
1260+
1261+
| Context | Behaviour |
1262+
|---|---|
1263+
| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them? [y/N]`. Default `N` keeps the entries. |
1264+
| Interactive TTY, user answers `y` | Removes everything under `~/.nemoclaw/` (the previous full-removal behaviour). |
1265+
| Non-interactive (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or non-TTY shell) | Preserves the entries and prints a one-line notice. |
1266+
| Any context with `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the prompt and removes everything under `~/.nemoclaw/`. |
1267+
1268+
The preserved entries survive uninstall as inert files on disk.
1269+
Reinstall NemoClaw and re-onboard the sandbox before `nemoclaw <name> snapshot restore` can use them.
1270+
12471271
#### `nemoclaw uninstall` vs. the hosted `uninstall.sh`
12481272

12491273
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.

src/lib/actions/uninstall/run-plan.test.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,285 @@ describe("uninstall run plan", () => {
618618
);
619619
});
620620

621+
describe("user-data preservation under ~/.nemoclaw/", () => {
622+
function setupStateDir(): { tmpHome: string; stateDir: string } {
623+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
624+
const stateDir = path.join(tmpHome, ".nemoclaw");
625+
fs.mkdirSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101"), { recursive: true });
626+
fs.writeFileSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"), "{}");
627+
fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true });
628+
fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello");
629+
fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]");
630+
fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234");
631+
fs.mkdirSync(path.join(stateDir, "source"));
632+
return { tmpHome, stateDir };
633+
}
634+
635+
function tempScopedExistsSync(tmpHome: string): (target: string) => boolean {
636+
return (target: string) => target.startsWith(tmpHome) && fs.existsSync(target);
637+
}
638+
639+
it("preserves rebuild-backups/, backups/, and sandboxes.json by default in non-interactive runs", () => {
640+
const { tmpHome, stateDir } = setupStateDir();
641+
try {
642+
const logs: string[] = [];
643+
const result = runUninstallPlan(
644+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
645+
{
646+
commandExists: () => false,
647+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
648+
existsSync: tempScopedExistsSync(tmpHome),
649+
isTty: false,
650+
log: (line) => logs.push(line),
651+
run: vi.fn(() => ok()),
652+
runDocker: () => ok(""),
653+
},
654+
);
655+
656+
expect(result.exitCode).toBe(0);
657+
expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
658+
expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
659+
expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
660+
expect(fs.existsSync(path.join(stateDir, "ollama-auth-proxy.pid"))).toBe(false);
661+
expect(fs.existsSync(path.join(stateDir, "source"))).toBe(false);
662+
expect(logs).toContain(`Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`);
663+
expect(logs.some((line) => line.includes("preserved: rebuild-backups, backups, sandboxes.json"))).toBe(true);
664+
} finally {
665+
fs.rmSync(tmpHome, { recursive: true, force: true });
666+
}
667+
});
668+
669+
it("purges the whole state dir when NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 is set", () => {
670+
const { tmpHome, stateDir } = setupStateDir();
671+
try {
672+
const logs: string[] = [];
673+
const result = runUninstallPlan(
674+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
675+
{
676+
commandExists: () => false,
677+
env: {
678+
HOME: tmpHome,
679+
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1",
680+
} as NodeJS.ProcessEnv,
681+
existsSync: tempScopedExistsSync(tmpHome),
682+
isTty: false,
683+
log: (line) => logs.push(line),
684+
run: vi.fn(() => ok()),
685+
runDocker: () => ok(""),
686+
},
687+
);
688+
689+
expect(result.exitCode).toBe(0);
690+
expect(fs.existsSync(stateDir)).toBe(false);
691+
expect(logs).toContain(`Removed ${stateDir}`);
692+
expect(logs).toContain("NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1 set; purging user data under ~/.nemoclaw/.");
693+
expect(logs.every((line) => !line.includes("preserved:"))).toBe(true);
694+
} finally {
695+
fs.rmSync(tmpHome, { recursive: true, force: true });
696+
}
697+
});
698+
699+
it("purges via interactive y/N prompt when user answers yes", () => {
700+
const { tmpHome, stateDir } = setupStateDir();
701+
try {
702+
const logs: string[] = [];
703+
const replies = ["yes", "y"];
704+
const result = runUninstallPlan(
705+
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
706+
{
707+
commandExists: () => false,
708+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
709+
existsSync: tempScopedExistsSync(tmpHome),
710+
isTty: true,
711+
log: (line) => logs.push(line),
712+
readLine: () => replies.shift() ?? null,
713+
run: vi.fn(() => ok()),
714+
runDocker: () => ok(""),
715+
},
716+
);
717+
718+
expect(result.exitCode).toBe(0);
719+
expect(fs.existsSync(stateDir)).toBe(false);
720+
expect(logs).toContain("Also remove them? [y/N]");
721+
expect(logs).toContain("Acknowledged; purging user data.");
722+
} finally {
723+
fs.rmSync(tmpHome, { recursive: true, force: true });
724+
}
725+
});
726+
727+
it("keeps user data when interactive prompt is declined", () => {
728+
const { tmpHome, stateDir } = setupStateDir();
729+
try {
730+
const logs: string[] = [];
731+
const replies = ["yes", ""];
732+
const result = runUninstallPlan(
733+
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
734+
{
735+
commandExists: () => false,
736+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
737+
existsSync: tempScopedExistsSync(tmpHome),
738+
isTty: true,
739+
log: (line) => logs.push(line),
740+
readLine: () => replies.shift() ?? null,
741+
run: vi.fn(() => ok()),
742+
runDocker: () => ok(""),
743+
},
744+
);
745+
746+
expect(result.exitCode).toBe(0);
747+
expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
748+
expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
749+
expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
750+
expect(logs).toContain("Keeping user data.");
751+
} finally {
752+
fs.rmSync(tmpHome, { recursive: true, force: true });
753+
}
754+
});
755+
756+
it("preserves entries on a TTY when NEMOCLAW_NON_INTERACTIVE=1 is set instead of --yes", () => {
757+
const { tmpHome, stateDir } = setupStateDir();
758+
const readLine = vi.fn(() => "yes");
759+
try {
760+
const logs: string[] = [];
761+
const result = runUninstallPlan(
762+
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
763+
{
764+
commandExists: () => false,
765+
env: {
766+
HOME: tmpHome,
767+
NEMOCLAW_NON_INTERACTIVE: "1",
768+
} as NodeJS.ProcessEnv,
769+
existsSync: tempScopedExistsSync(tmpHome),
770+
// Simulate a TTY so we exercise the env-var-only branch (the prior
771+
// tests reach the silent-preserve branch via !isTty or assumeYes).
772+
isTty: true,
773+
log: (line) => logs.push(line),
774+
readLine,
775+
run: vi.fn(() => ok()),
776+
runDocker: () => ok(""),
777+
},
778+
);
779+
780+
expect(result.exitCode).toBe(0);
781+
expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
782+
expect(fs.existsSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"))).toBe(true);
783+
expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(true);
784+
expect(logs).toContain(`Preserving rebuild-backups, backups, sandboxes.json under ${stateDir}.`);
785+
// Interactive y/N prompt must not fire when NEMOCLAW_NON_INTERACTIVE is set.
786+
expect(logs.every((line) => line !== "Also remove them? [y/N]")).toBe(true);
787+
// The earlier generic confirm() prompt still consumes one readLine for "Proceed? [y/N]";
788+
// resolvePreserveSet must not consume another.
789+
expect(readLine).toHaveBeenCalledTimes(1);
790+
} finally {
791+
fs.rmSync(tmpHome, { recursive: true, force: true });
792+
}
793+
});
794+
795+
it("exits non-zero and warns when lstat on ~/.nemoclaw fails with a non-ENOENT error", () => {
796+
const { tmpHome, stateDir } = setupStateDir();
797+
const realLstat = fs.lstatSync;
798+
const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((p: fs.PathLike) => {
799+
if (String(p) === stateDir) {
800+
const err = new Error("permission denied") as NodeJS.ErrnoException;
801+
err.code = "EACCES";
802+
throw err;
803+
}
804+
return realLstat(p);
805+
});
806+
try {
807+
const logs: string[] = [];
808+
const warnings: string[] = [];
809+
const result = runUninstallPlan(
810+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
811+
{
812+
commandExists: () => false,
813+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
814+
error: (line) => warnings.push(line),
815+
existsSync: tempScopedExistsSync(tmpHome),
816+
isTty: false,
817+
log: (line) => logs.push(line),
818+
run: vi.fn(() => ok()),
819+
runDocker: () => ok(""),
820+
},
821+
);
822+
823+
expect(result.exitCode).toBe(1);
824+
expect(warnings.some((line) => line.startsWith(`Failed to inspect ${stateDir}: `))).toBe(true);
825+
expect(warnings).toContain(
826+
"Uninstall completed with errors. Some state may remain on disk; see warnings above.",
827+
);
828+
expect(logs).not.toContain("Claws retracted. Until next time.");
829+
expect(fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json"))).toBe(true);
830+
} finally {
831+
lstatSpy.mockRestore();
832+
fs.rmSync(tmpHome, { recursive: true, force: true });
833+
}
834+
});
835+
836+
it("removes ~/.nemoclaw wholesale when it is a symlink rather than a real directory", () => {
837+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
838+
const realTarget = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-target-"));
839+
const stateDir = path.join(tmpHome, ".nemoclaw");
840+
fs.symlinkSync(realTarget, stateDir);
841+
// Symlink target intentionally non-empty so that following it would
842+
// tempt the selective-wipe path; lstat must short-circuit that.
843+
fs.writeFileSync(path.join(realTarget, "rebuild-backups"), "should not be followed");
844+
try {
845+
const logs: string[] = [];
846+
const result = runUninstallPlan(
847+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
848+
{
849+
commandExists: () => false,
850+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
851+
existsSync: (target: string) =>
852+
target.startsWith(tmpHome) && fs.existsSync(target),
853+
isTty: false,
854+
log: (line) => logs.push(line),
855+
run: vi.fn(() => ok()),
856+
runDocker: () => ok(""),
857+
},
858+
);
859+
860+
expect(result.exitCode).toBe(0);
861+
expect(fs.existsSync(stateDir)).toBe(false);
862+
expect(fs.existsSync(realTarget)).toBe(true);
863+
expect(logs).toContain(`Removed ${stateDir}`);
864+
} finally {
865+
fs.rmSync(tmpHome, { recursive: true, force: true });
866+
fs.rmSync(realTarget, { recursive: true, force: true });
867+
}
868+
});
869+
870+
it("skips the preservation notice when no protected entries exist on disk", () => {
871+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
872+
const stateDir = path.join(tmpHome, ".nemoclaw");
873+
fs.mkdirSync(stateDir, { recursive: true });
874+
fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234");
875+
try {
876+
const logs: string[] = [];
877+
const result = runUninstallPlan(
878+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
879+
{
880+
commandExists: () => false,
881+
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
882+
existsSync: tempScopedExistsSync(tmpHome),
883+
isTty: false,
884+
log: (line) => logs.push(line),
885+
run: vi.fn(() => ok()),
886+
runDocker: () => ok(""),
887+
},
888+
);
889+
890+
expect(result.exitCode).toBe(0);
891+
expect(fs.existsSync(stateDir)).toBe(false);
892+
expect(logs).toContain(`Removed ${stateDir}`);
893+
expect(logs.every((line) => !line.startsWith("Preserving "))).toBe(true);
894+
} finally {
895+
fs.rmSync(tmpHome, { recursive: true, force: true });
896+
}
897+
});
898+
});
899+
621900
it("kills host openshell-gateway process during uninstall (#3516)", () => {
622901
const logs: string[] = [];
623902
const killed: number[] = [];

0 commit comments

Comments
 (0)