Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
412ebf0
fix(uninstall): gate destruction of host snapshots behind explicit ack
laitingsheng May 26, 2026
2fd61ae
feat(uninstall): bundle host snapshot gate, --save-host, download/upl…
laitingsheng May 26, 2026
cec34c1
fix(uninstall): harden gate, save-host path, docs parity
laitingsheng May 26, 2026
06fce3c
fix(uninstall): fail closed on unreadable sandbox registry
laitingsheng May 26, 2026
4e0ec06
fix(uninstall): split detect errors and validate registry shape
laitingsheng May 26, 2026
6aaa7c3
fix(uninstall): tidy ack path and reject unsafe --save-host dests
laitingsheng May 26, 2026
128dbc1
fix(uninstall): include Docker container probe in user-data gate
laitingsheng May 26, 2026
e4ae80a
fix(uninstall): protect ~/.nemoclaw/backups and resolve save-host sym…
laitingsheng May 26, 2026
112bcf8
fix(uninstall): docker-only remediation, default backups outside ~/.n…
laitingsheng May 26, 2026
032147a
fix(uninstall): per-signal remediation hints
laitingsheng May 26, 2026
3b947a2
revert: roll back review-iteration churn to cec34c1 baseline
laitingsheng May 26, 2026
d7d13b6
fix(uninstall): fail closed on unreadable sandbox registry
laitingsheng May 26, 2026
4a31f06
Merge remote-tracking branch 'origin/main' into fix-4226-uninstall-co…
laitingsheng May 28, 2026
c676738
fix(uninstall): preserve rebuild-backups/ and sandboxes.json by default
laitingsheng May 28, 2026
b7ada9a
fix(uninstall): preserve backups/, harden symlinked state dir
laitingsheng May 28, 2026
95929e6
fix(uninstall): sync confirm prompt and docs with preserve-by-default
laitingsheng May 28, 2026
9d175ac
Merge branch 'main' into fix-4226-uninstall-confirm-backups
laitingsheng May 28, 2026
2c070fa
fix(uninstall): surface lstat failures and tidy doc/code wording
laitingsheng May 28, 2026
9c883db
fix(uninstall): exit non-zero when state dir lstat fails
laitingsheng May 28, 2026
0418a11
fix(uninstall): suppress goodbye on failure, cover NON_INTERACTIVE en…
laitingsheng May 28, 2026
3393b36
fix(uninstall): treat NEMOCLAW_NON_INTERACTIVE=1 as implicit --yes
laitingsheng May 28, 2026
48792d1
revert(uninstall): drop NEMOCLAW_NON_INTERACTIVE=1 implicit --yes
laitingsheng May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/manage-sandboxes/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,14 @@ nemoclaw uninstall
| `--keep-openshell` | Leave OpenShell binaries installed. |
| `--delete-models` | Also remove NemoClaw-pulled Ollama models. |

<Note>
`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.
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.
</Note>

`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:
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,30 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri
$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway <name>]
```

##### 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 <name> snapshot create` and `nemoclaw backup-all` write. `nemoclaw <name> 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 <name> 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.
Expand Down
279 changes: 279 additions & 0 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading
Loading