Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
55 changes: 55 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,36 @@ $ nemoclaw my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>]
| `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection |
| `--timeout <s>` | Timeout in seconds. Use `0` for no timeout |

### `nemoclaw <name> download`

Download a file or directory from inside a running sandbox to the host.
The command is a thin wrapper around `openshell sandbox download` so you can pull workspace files out without remembering the raw OpenShell command surface.
Sandbox workspace files (`/sandbox/.openclaw/workspace/`) live in the container's writable layer, so manual downloads are also the only way to preserve them outside of `nemoclaw <name> snapshot create` and `nemoclaw backup-all`.

```console
$ nemoclaw my-assistant download <sandbox-path> [<dest>]
```

| Argument | Description |
|----------|-------------|
| `<sandbox-path>` | Path inside the sandbox to download |
| `<dest>` | Local destination (defaults to the current directory) |

### `nemoclaw <name> upload`

Upload a local file or directory into a running sandbox.
Thin wrapper around `openshell sandbox upload`.

```console
$ nemoclaw my-assistant upload <local-path> [<dest>] [--no-git-ignore]
```

| Argument or flag | Description |
|------------------|-------------|
| `<local-path>` | Local path to upload |
| `<dest>` | Destination path inside the sandbox (defaults to the working directory) |
| `--no-git-ignore` | Disable `.gitignore` filtering so the upload includes everything |

### `nemoclaw <name> logs`

View sandbox logs.
Expand Down Expand Up @@ -838,6 +868,14 @@ $ nemoclaw backup-all

The installer calls `backup-all` automatically before onboarding to protect against data loss during OpenShell upgrades.

| Flag | Description |
|------|-------------|
| `--save-host <path>` | Copy each successful backup directory into `<path>/<sandbox>/<timestamp>/` after the snapshot is written. The destination is created if it does not exist. Use this before `nemoclaw uninstall` so the backups survive the `~/.nemoclaw/` wipe |

```console
$ nemoclaw backup-all --save-host ~/nemoclaw-backups
```

### `nemoclaw <name> snapshot create`

Create a timestamped snapshot of sandbox state.
Expand All @@ -850,11 +888,13 @@ $ nemoclaw my-assistant snapshot create
| Flag | Description |
|------|-------------|
| `--name <label>` | Attach a human-readable label to the snapshot so you can restore by name later |
| `--save-host <path>` | Copy the snapshot directory into `<path>/<sandbox>/<timestamp>/` after creation. The destination is created if it does not exist |

Names must be 1 to 63 characters from `[A-Za-z0-9._-]`, start with an alphanumeric character, and cannot look like a version selector (`v1`, `v2`, ...). Duplicate names per sandbox are rejected; pick a different name or delete the existing snapshot first.

```console
$ nemoclaw my-assistant snapshot create --name before-upgrade
$ nemoclaw my-assistant snapshot create --save-host ~/nemoclaw-backups
```

### `nemoclaw <name> snapshot list`
Expand Down Expand Up @@ -1178,6 +1218,21 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri
$ nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--gateway <name>]
```

When uninstall detects snapshots or rebuild backups under any of the protected paths inside `~/.nemoclaw/` (currently `~/.nemoclaw/rebuild-backups/`), it prints the affected sandboxes, names the destructive surface, and refuses to proceed.
In non-interactive runs (including `--yes`) the gate exits with status 1.
In interactive runs the gate prompts for `y/N` confirmation and aborts on the default.
The list of protected directories lives in `PROTECTED_USER_DATA_DIRS` in `src/lib/actions/uninstall/run-plan.ts`; additions to this list automatically extend the gate.

To keep your workspace state across uninstall, copy fresh backups into a path outside `~/.nemoclaw/` before re-attempting uninstall.
Both `nemoclaw backup-all` and `nemoclaw <name> snapshot create` expose a host-copy option that places each backup directory into a destination you supply, so the data survives the `~/.nemoclaw/` wipe; see those command sections for the exact invocation.

Or set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` to acknowledge the loss and proceed without preservation.

Workspace files inside the sandbox container (`/sandbox/.openclaw/workspace/`) live in the container's writable layer.
OpenShell's `sandbox create` does not currently expose host mount or volume options, so NemoClaw cannot mount workspace state on a persistent host volume; the host-copy flow referenced above is the workaround for that constraint.
`docker rm -f` destroys the writable layer with the container, regardless of this gate.
Run `nemoclaw <name> snapshot create` (which extracts workspace files via `docker exec` + tar) or `nemoclaw <name> download` (a thin wrapper around `openshell sandbox download`) before destroying the sandbox if you want to keep specific workspace files.

#### `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
19 changes: 14 additions & 5 deletions src/commands/backup-all.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flags } from "@oclif/core";
import { runBackupAllAction } from "../lib/actions/global";
import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";

Expand All @@ -9,12 +10,20 @@ export default class BackupAllCommand extends NemoClawCommand {
static strict = true;
static summary = "Back up all sandbox state before upgrade";
static description = "Back up registered, running sandbox state before upgrading.";
static usage = ["backup-all"];
static examples = ["<%= config.bin %> backup-all"];
static flags = {};
static usage = ["backup-all [--save-host <path>]"];
static examples = [
"<%= config.bin %> backup-all",
"<%= config.bin %> backup-all --save-host ~/nemoclaw-backups",
];
static flags = {
"save-host": Flags.string({
description:
"Copy every successful backup to this host path so it survives ~/.nemoclaw removal",
}),
};

public async run(): Promise<void> {
await this.parse(BackupAllCommand);
await runBackupAllAction();
const { flags } = await this.parse(BackupAllCommand);
await runBackupAllAction({ saveHost: flags["save-host"] });
}
}
2 changes: 1 addition & 1 deletion src/commands/global-oclif-command-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe("global oclif command adapters", () => {
await UpgradeSandboxesCommand.run(["--check", "--yes"], rootDir);
await GarbageCollectImagesCommand.run(["--dry-run", "--force"], rootDir);

expect(mocks.runBackupAllAction).toHaveBeenCalledWith();
expect(mocks.runBackupAllAction).toHaveBeenCalledWith({ saveHost: undefined });
expect(mocks.runUpgradeSandboxesAction).toHaveBeenCalledWith({
auto: false,
check: true,
Expand Down
39 changes: 39 additions & 0 deletions src/commands/sandbox/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Args } from "@oclif/core";
import { downloadSandbox } from "../../lib/actions/sandbox/transfer";
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";

export default class SandboxDownloadCommand extends NemoClawCommand {
static id = "sandbox:download";
static strict = true;
static summary = "Download a file or directory from a running sandbox to the host";
static description =
"Thin wrapper around `openshell sandbox download` so users can pull workspace files out of a sandbox without remembering the raw OpenShell command. Useful as a manual escape hatch when snapshot or backup-all coverage is incomplete.";
static usage = ["<name> <sandbox-path> [<dest>]"];
static examples = [
"<%= config.bin %> sandbox download alpha /sandbox/.openclaw/workspace/USER.md",
"<%= config.bin %> sandbox download alpha /sandbox/.openclaw/workspace ./alpha-workspace",
];
static args = {
sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }),
sandboxPath: Args.string({
name: "sandbox-path",
description: "Path inside the sandbox to download",
required: true,
}),
destination: Args.string({
name: "dest",
description: "Local destination (defaults to the current directory)",
required: false,
}),
};

public async run(): Promise<void> {
const { args } = await this.parse(SandboxDownloadCommand);
await downloadSandbox(args.sandboxName, args.sandboxPath, {
destination: args.destination,
});
}
}
12 changes: 10 additions & 2 deletions src/commands/sandbox/snapshot/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,30 @@ export default class SnapshotCreateCommand extends NemoClawCommand {
static strict = true;
static summary = "Create a snapshot of sandbox state";
static description = "Create an auto-versioned snapshot of sandbox workspace state.";
static usage = ["<name> [--name <label>]"];
static usage = ["<name> [--name <label>] [--save-host <path>]"];
static examples = [
"<%= config.bin %> sandbox snapshot create alpha",
"<%= config.bin %> sandbox snapshot create alpha --name before-upgrade",
"<%= config.bin %> sandbox snapshot create alpha --save-host ~/nemoclaw-backups",
];
static args = {
sandboxName: sandboxNameArg,
};
static flags = {
name: Flags.string({ description: "Optional snapshot label" }),
"save-host": Flags.string({
description: "Copy the snapshot to this host path after creation",
}),
};

public async run(): Promise<void> {
const { args, flags } = await this.parse(SnapshotCreateCommand);
try {
await runSandboxSnapshot(args.sandboxName, { kind: "create", name: flags.name });
await runSandboxSnapshot(args.sandboxName, {
kind: "create",
name: flags.name,
saveHost: flags["save-host"],
});
} catch (error) {
const snapshotError = snapshotCommandError(error);
if (snapshotError) {
Expand Down
45 changes: 45 additions & 0 deletions src/commands/sandbox/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Args, Flags } from "@oclif/core";
import { uploadSandbox } from "../../lib/actions/sandbox/transfer";
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";

export default class SandboxUploadCommand extends NemoClawCommand {
static id = "sandbox:upload";
static strict = true;
static summary = "Upload a local file or directory into a running sandbox";
static description =
"Thin wrapper around `openshell sandbox upload` so users can push host files into a sandbox without remembering the raw OpenShell command. Useful for restoring workspace files after a wipe or for staging configuration the agent needs.";
static usage = ["<name> <local-path> [<dest>] [--no-git-ignore]"];
static examples = [
"<%= config.bin %> sandbox upload alpha ./USER.md /sandbox/.openclaw/workspace/USER.md",
"<%= config.bin %> sandbox upload alpha ./workspace /sandbox/.openclaw/workspace --no-git-ignore",
];
static args = {
sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }),
localPath: Args.string({
name: "local-path",
description: "Local path to upload",
required: true,
}),
destination: Args.string({
name: "dest",
description: "Destination path inside the sandbox (defaults to the working directory)",
required: false,
}),
};
static flags = {
"no-git-ignore": Flags.boolean({
description: "Disable `.gitignore` filtering so the upload includes everything",
}),
};

public async run(): Promise<void> {
const { args, flags } = await this.parse(SandboxUploadCommand);
await uploadSandbox(args.sandboxName, args.localPath, {
destination: args.destination,
noGitIgnore: flags["no-git-ignore"],
});
}
}
2 changes: 1 addition & 1 deletion src/lib/actions/global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("global cli action facade", () => {
expect(mocks.runSetupAction).toHaveBeenCalledWith(["--fresh"]);
expect(mocks.runSetupSparkAction).toHaveBeenCalledWith(["--name", "alpha"]);
expect(mocks.runDeployAction).toHaveBeenCalledWith("gpu-alpha");
expect(mocks.backupAll).toHaveBeenCalledWith();
expect(mocks.backupAll).toHaveBeenCalledWith({});
expect(mocks.garbageCollectImages).toHaveBeenCalledWith({ dryRun: true });
expect(mocks.help).toHaveBeenCalledWith();
expect(mocks.version).toHaveBeenCalledWith();
Expand Down
4 changes: 2 additions & 2 deletions src/lib/actions/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export async function runDeployAction(instanceName?: string): Promise<void> {
await executeDeployAction(instanceName);
}

export async function runBackupAllAction(): Promise<void> {
await executeBackupAllAction();
export async function runBackupAllAction(options: { saveHost?: string | null } = {}): Promise<void> {
await executeBackupAllAction(options);
}

export async function runUpgradeSandboxesAction(
Expand Down
18 changes: 17 additions & 1 deletion src/lib/actions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../openshell-sandbox-list";
import { parseLiveSandboxNames } from "../runtime-recovery";
import * as registry from "../state/registry";
import { saveBackupToHost } from "../state/save-host";
import * as sandboxState from "../state/sandbox";

const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY;
Expand All @@ -32,7 +33,11 @@ const R = useColor ? "\x1b[0m" : "";
const RD = useColor ? "\x1b[1;31m" : "";
const YW = useColor ? "\x1b[1;33m" : "";

export async function backupAll(): Promise<void> {
export interface BackupAllOptions {
saveHost?: string | null;
}

export async function backupAll(options: BackupAllOptions = {}): Promise<void> {
const { sandboxes } = registry.listSandboxes();
if (sandboxes.length === 0) {
console.log(" No sandboxes registered. Nothing to back up.");
Expand Down Expand Up @@ -80,6 +85,17 @@ export async function backupAll(): Promise<void> {
` ${G}✓${R} ${sb.name}: ${result.backedUpDirs.length} dirs, ${result.backedUpFiles.length} files → ${result.manifest?.backupPath || "unknown"}`,
);
backed++;
if (options.saveHost && result.manifest?.backupPath) {
const save = saveBackupToHost(result.manifest.backupPath, options.saveHost);
if (save.ok) {
Comment thread
laitingsheng marked this conversation as resolved.
Outdated
console.log(` Saved to host: ${save.destination}`);
} else {
console.error(
` ${RD}✗${R} ${sb.name}: failed to copy snapshot to ${options.saveHost}: ${save.message ?? ""}`,
);
failed++;
}
}
} else {
const failedItems = [...result.failedDirs, ...result.failedFiles];
console.error(` ${RD}✗${R} ${sb.name}: backup failed (${failedItems.join(", ")})`);
Expand Down
12 changes: 11 additions & 1 deletion src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { parseLiveSandboxNames } from "../../runtime-recovery";
import { isGatewayHealthy } from "../../state/gateway";
import type { SandboxEntry } from "../../state/registry";
import * as registry from "../../state/registry";
import { saveBackupToHost } from "../../state/save-host";
import * as sandboxState from "../../state/sandbox";
import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy";

Expand All @@ -30,7 +31,7 @@ const NEMOCLAW_GATEWAY_NAME = "nemoclaw";

export type SnapshotRequest =
| { kind: "help" }
| { kind: "create"; name?: string }
| { kind: "create"; name?: string; saveHost?: string }
| { kind: "list" }
| {
kind: "restore";
Expand Down Expand Up @@ -346,6 +347,15 @@ export async function runSandboxSnapshot(
` ${G}\u2713${R} Snapshot ${v}${nameSuffix} created (${itemSummary})`,
);
console.log(` ${manifest.backupPath}`);
if (request.saveHost) {
const save = saveBackupToHost(manifest.backupPath, request.saveHost);
if (save.ok) {
console.log(` Saved to host: ${save.destination}`);
} else {
console.error(` Failed to copy snapshot to ${request.saveHost}: ${save.message ?? ""}`);
snapshotExit(1);
}
}
} else {
if (result.error) {
console.error(` ${result.error}`);
Expand Down
Loading