Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 33 additions & 5 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
title: "CLI Commands Reference"
sidebar-title: "CLI Commands Reference"
title: "NemoClaw CLI Commands Reference"
sidebar-title: "Commands"
description: "Full CLI reference for slash commands and standalone NemoClaw commands."
description-agent: "Includes the full CLI reference for slash commands and standalone NemoClaw commands. Use when looking up a specific `nemoclaw` or `/nemoclaw` subcommand, flag, argument, or exit code."
keywords: ["nemoclaw cli commands", "nemoclaw command reference"]
Expand Down Expand Up @@ -320,6 +320,26 @@ $ nemoclaw my-assistant connect [--probe-only]
The `--probe-only` flag verifies the sandbox is reachable over SSH and exits without opening a shell.
Use it for health checks and scripted readiness probes.

### `nemoclaw <name> exec`

Run a single command non-interactively in a running sandbox via the OpenShell exec endpoint.
The command runs as the sandbox user with `HOME=/sandbox`, so in-sandbox tooling resolves NemoClaw-provisioned config under `/sandbox/.openclaw` the same way it does for `connect` and `openshell sandbox connect`.
This is the supported substitute for `docker exec` on the sandbox container; raw `docker exec` runs as root and lands on `HOME=/root`, where the agent config is not present and `openclaw agent` falls back to its built-in defaults.

```console
$ nemoclaw my-assistant exec -- openclaw agent -m "What is 2+2?"
$ nemoclaw my-assistant exec --workdir /sandbox/workspace -- ls -la
```

Everything after `--` is forwarded verbatim to the sandbox command, including flags the inner command needs.
The exit code is the remote command's exit code.

| Flag | Description |
|------|-------------|
| `--workdir <dir>` | Working directory inside the sandbox |
| `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) |
| `--timeout <seconds>` | Timeout in seconds (`0` means no timeout) |

### `nemoclaw <name> recover`

Restart the in-sandbox gateway and re-establish the host-side dashboard port-forward without opening an SSH session.
Expand Down Expand Up @@ -823,7 +843,7 @@ Versions (`v1`, `v2`, ...) are computed on read from timestamp-ascending order,
$ nemoclaw my-assistant snapshot list
```

### `nemoclaw <name> snapshot restore [selector] [--to <dst>]`
### `nemoclaw <name> snapshot restore [selector] [--to <dst>] [--force] [--yes|-y]`

Restore sandbox state from a snapshot.
The sandbox must be running before you restore.
Expand All @@ -834,10 +854,15 @@ The selector accepts any of:

- A version (`v1`, `v2`, ..., `vN`) from `snapshot list`.
- An exact name passed to `snapshot create --name`.
- An exact or prefix timestamp (partial prefixes are accepted when they match exactly one snapshot).
- An exact timestamp.

Pass `--to <dst>` to restore the snapshot into a different sandbox instead of the source.
When `dst` does not exist, it is auto-created by reusing the source sandbox's container image — no re-onboarding needed.
When `dst` already exists, `snapshot restore --to <dst>` refuses by default to avoid silently mutating the destination's filesystem.
To overwrite an existing destination, pass `--force`: the command deletes `dst`, then recreates it from the source's image and restores the snapshot into the fresh copy.
The `--force` path prompts interactively to confirm the destination name before deleting.
Pass `--yes` (or set `NEMOCLAW_NON_INTERACTIVE=1`) to skip the prompt.
The snapshot selector and source pod image are both validated before any deletion, so a bad selector or unresolvable image cannot destroy `dst` and only fail afterwards.

```console
# restore latest snapshot in-place
Expand All @@ -852,8 +877,11 @@ $ nemoclaw my-assistant snapshot restore before-upgrade
# restore by exact timestamp
$ nemoclaw my-assistant snapshot restore 2026-04-21T07-35-55-987Z

# clone v3 into another sandbox
# clone v3 into a new sandbox
$ nemoclaw my-assistant snapshot restore v3 --to my-assistant-clone

# overwrite an existing destination with v3, non-interactively
$ nemoclaw my-assistant snapshot restore v3 --to my-assistant-clone --force --yes
```

### `nemoclaw <name> share mount`
Expand Down
14 changes: 14 additions & 0 deletions src/commands/sandbox/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ describe("snapshot oclif commands", () => {
kind: "restore",
selector: "v2",
to: "beta",
force: undefined,
yes: undefined,
});
});

it("threads --force and --yes into the typed restore action (#3756)", async () => {
await SnapshotRestoreCommand.run(["alpha", "--to", "beta", "--force", "--yes"], rootDir);

expect(runSandboxSnapshot).toHaveBeenCalledWith("alpha", {
kind: "restore",
selector: undefined,
to: "beta",
force: true,
yes: true,
});
});

Expand Down
13 changes: 12 additions & 1 deletion src/commands/sandbox/snapshot/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export default class SnapshotRestoreCommand extends NemoClawCommand {
static strict = true;
static summary = "Restore state from a snapshot";
static description = "Restore sandbox workspace state from a snapshot.";
static usage = ["<name> [selector] [--to <dst>]"];
static usage = ["<name> [selector] [--to <dst>] [--force] [--yes|-y]"];
static examples = [
"<%= config.bin %> sandbox snapshot restore alpha",
"<%= config.bin %> sandbox snapshot restore alpha v2",
"<%= config.bin %> sandbox snapshot restore alpha before-upgrade --to beta",
"<%= config.bin %> sandbox snapshot restore alpha v2 --to beta --force --yes",
];
static args = {
sandboxName: sandboxNameArg,
Expand All @@ -28,6 +29,14 @@ export default class SnapshotRestoreCommand extends NemoClawCommand {
};
static flags = {
to: Flags.string({ description: "Restore into another sandbox" }),
force: Flags.boolean({
description:
"When --to names an existing sandbox, delete it before restoring. Refuses by default.",
}),
yes: Flags.boolean({
char: "y",
description: "Skip the interactive confirmation when --force is used.",
}),
};

public async run(): Promise<void> {
Expand All @@ -37,6 +46,8 @@ export default class SnapshotRestoreCommand extends NemoClawCommand {
kind: "restore",
selector: args.selector,
to: flags.to,
force: flags.force,
yes: flags.yes,
});
} catch (error) {
const snapshotError = snapshotCommandError(error);
Expand Down
188 changes: 163 additions & 25 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
import fs from "node:fs";
import path from "node:path";
import { dockerCapture, dockerInspect } from "../../adapters/docker";
import { captureOpenshell, getOpenshellBinary } from "../../adapters/openshell/runtime";
import { captureOpenshell, getOpenshellBinary, runOpenshell } from "../../adapters/openshell/runtime";
import { CLI_NAME } from "../../cli/branding";
import { prompt as askPrompt } from "../../credentials/store";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
import * as policies from "../../policy";
import { ROOT, run, shellQuote, validateName } from "../../runner";
import { parseLiveSandboxNames } from "../../runtime-recovery";
import { isGatewayHealthy } from "../../state/gateway";
import type { SandboxEntry } from "../../state/registry";
import * as registry from "../../state/registry";
import * as sandboxState from "../../state/sandbox";
import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy";

const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY;
const trueColor =
Expand All @@ -29,7 +32,17 @@ export type SnapshotRequest =
| { kind: "help" }
| { kind: "create"; name?: string }
| { kind: "list" }
| { kind: "restore"; selector?: string; to?: string };
| {
kind: "restore";
selector?: string;
to?: string;
/** #3756: required when `to` names an existing sandbox. Deletes the
* destination first, then recreates it from the source's image. */
force?: boolean;
/** Skip the --force interactive confirmation. Implied by
* NEMOCLAW_NON_INTERACTIVE=1. */
yes?: boolean;
};

export class SnapshotCommandError extends Error {
readonly lines: readonly string[];
Expand Down Expand Up @@ -204,6 +217,68 @@ async function autoCreateSandboxFromSource(
console.log(` ${G}\u2713${R} Sandbox '${dstName}' created`);
}

// Delete an existing destination sandbox so `snapshot restore --to <dst> --force`
// can recreate it from the source's image. Stops the destination's NIM
// container, runs `openshell sandbox delete`, performs the destination-only
// cleanups that `sandboxDestroy` does (PID dir, per-sandbox messaging
// providers, shields state), then drops the NemoClaw registry entry. Throws
// SnapshotCommandError on failure so the caller does not proceed into a
// partially-deleted target.
//
// Host-shared cleanups that destroy.ts performs \u2014 Ollama auth proxy
// (`killStaleProxy`), host services (`cleanupSandboxServices` with
// `stopHostServices`), Ollama model unload, gateway teardown \u2014 are
// deliberately skipped here because they can also affect the source sandbox
// we are about to clone from.
function deleteSandboxForRestore(name: string): void {
const nim = require("../../inference/nim") as {
stopNimContainer: (sandboxName: string, opts?: { silent?: boolean }) => void;
stopNimContainerByName: (name: string) => void;
};
const sbMeta = registry.getSandbox(name);
if (sbMeta?.nimContainer) {
nim.stopNimContainerByName(sbMeta.nimContainer);
} else {
nim.stopNimContainer(name, { silent: true });
}
console.log(` Deleting existing destination '${name}' before restore...`);
const deleteResult = runOpenshell(["sandbox", "delete", name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
const { alreadyGone } = getSandboxDeleteOutcome(deleteResult);
if (deleteResult.status !== 0 && !alreadyGone) {
console.error(` Failed to delete '${name}' (exit ${deleteResult.status}). Aborting restore.`);
snapshotExit(1);
}
// Destination-only cleanup so the recreated sandbox does not inherit stale
// host-side state or hit provider-name conflicts (Codex #3796 P2):
// - /tmp/nemoclaw-services-<name>: PID dir for this sandbox's services
// - OpenShell providers named <name>-{telegram,discord,slack,wechat}-bridge
// and <name>-slack-app: per-sandbox messaging bridges
// - shields-<name>.json + shields timer: per-sandbox shields artifacts
try {
fs.rmSync(`/tmp/nemoclaw-services-${name}`, { recursive: true, force: true });
} catch {
// PID dir may not exist \u2014 ignore.
}
for (const suffix of [
"telegram-bridge",
"discord-bridge",
"slack-bridge",
"slack-app",
"wechat-bridge",
]) {
runOpenshell(["provider", "delete", `${name}-${suffix}`], {
ignoreError: true,
stdio: ["ignore", "ignore", "ignore"],
});
}
cleanupShieldsDestroyArtifacts(name);
removeSandboxRegistryEntry(name);
console.log(` ${G}\u2713${R} '${name}' deleted`);
}

// Docker/VM-driver sandboxes do not expose the legacy cluster container, so
// verify gateway health through OpenShell metadata instead.
function probeGatewayMetadataHealth(): boolean {
Expand Down Expand Up @@ -315,29 +390,16 @@ export async function runSandboxSnapshot(
}
const isLive = captureOpenshell(["sandbox", "list"], { ignoreError: true });
const liveNames = parseLiveSandboxNames(isLive.output || "");
if (!liveNames.has(targetSandbox)) {
// Self-restore: cannot auto-create, there is no source to clone from.
if (targetSandbox === sandboxName) {
console.error(` Sandbox '${targetSandbox}' is not running. Cannot restore snapshot.`);
snapshotExit(1);
}
// Cross-sandbox restore into a sandbox that doesn't exist yet:
// auto-create it by cloning the source's running pod image. The
// source must exist so we can probe its image via kubectl; the
// registry entry is used to seed dst's agent/model/provider fields.
if (!liveNames.has(sandboxName)) {
console.error(
` Cannot auto-create '${targetSandbox}': source '${sandboxName}' not found.`,
);
console.error(` Create '${targetSandbox}' manually with '${CLI_NAME} onboard'.`);
snapshotExit(1);
}
const srcEntry = registry.getSandbox(sandboxName) || { name: sandboxName };
await autoCreateSandboxFromSource(sandboxName, targetSandbox, srcEntry);
}
const isCrossSandboxRestore = targetSandbox !== sandboxName;
const targetExists = liveNames.has(targetSandbox);

// #3756 P1 preflight: resolve the snapshot selector AND the source pod
// image before any destructive action. A bad selector, missing snapshot,
// or unresolvable source image must not be allowed to delete the
// destination first and only fail afterwards.
const selector = request.selector ?? null;
let backupPath;
let resolvedSnapshot = null;
let backupPath: string;
let resolvedSnapshot: ReturnType<typeof sandboxState.getLatestBackup>;
if (selector) {
const { match } = sandboxState.findBackup(sandboxName, selector);
if (!match) {
Expand All @@ -363,6 +425,77 @@ export async function runSandboxSnapshot(
const nameSuffix = latest.name ? ` name=${latest.name}` : "";
console.log(` Using latest snapshot ${v}${nameSuffix} (${latest.timestamp})`);
}

if (!isCrossSandboxRestore) {
// Self-restore: target is `sandboxName`. Cannot auto-create; the
// source pod is the target, so it must already be live.
if (!targetExists) {
console.error(` Sandbox '${targetSandbox}' is not running. Cannot restore snapshot.`);
snapshotExit(1);
}
} else {
// #3756: cross-sandbox restore into a destination that already exists
// used to overlay onto the live filesystem silently. Refuse by default
// *before* doing any source-side preflight, so the user sees the
// precise "destination exists" error instead of a misleading
// "source not found" or "cannot resolve image" message when both are
// also broken.
if (targetExists && !request.force) {
console.error(` Destination sandbox '${targetSandbox}' already exists.`);
console.error(
" Restoring into an existing sandbox is unsupported because it would silently mutate its filesystem.",
);
console.error(
` Re-run with --force to delete '${targetSandbox}' and recreate it from the snapshot, or pick a different name.`,
);
snapshotExit(1);
}
// Cross-sandbox restore — whether dst exists (with --force) or not,
// we must be able to clone the source's running pod image. Resolve it
// upfront so a missing source / unresolvable image cannot delete the
// destination first (#3756 P1).
if (!liveNames.has(sandboxName)) {
if (targetExists) {
console.error(
` Cannot recreate '${targetSandbox}' from snapshot: source '${sandboxName}' not found.`,
);
} else {
console.error(
` Cannot auto-create '${targetSandbox}': source '${sandboxName}' not found.`,
);
console.error(` Create '${targetSandbox}' manually with '${CLI_NAME} onboard'.`);
}
snapshotExit(1);
}
const srcEntry = registry.getSandbox(sandboxName) || { name: sandboxName };
const fromImage = resolveSrcPodImage(sandboxName, srcEntry);
if (!fromImage) {
console.error(
` Cannot resolve image for source sandbox '${sandboxName}' — aborting before ` +
(targetExists ? `deleting '${targetSandbox}'.` : `creating '${targetSandbox}'.`),
);
snapshotExit(1);
}
if (targetExists) {
// --force confirmed above. Prompt for the destination name (unless
// --yes or NEMOCLAW_NON_INTERACTIVE=1), then delete and recreate.
const nonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE === "1";
if (!request.yes && !nonInteractive) {
const answer = (
await askPrompt(
` This will DELETE sandbox '${targetSandbox}' and restore the snapshot into a fresh copy.\n` +
` Type '${targetSandbox}' to confirm: `,
)
).trim();
if (answer !== targetSandbox) {
console.error(" Confirmation did not match — aborting.");
snapshotExit(1);
}
}
deleteSandboxForRestore(targetSandbox);
}
await autoCreateSandboxFromSource(sandboxName, targetSandbox, srcEntry);
}
if (targetSandbox !== sandboxName) {
console.log(` Restoring snapshot from '${sandboxName}' into '${targetSandbox}'...`);
} else {
Expand Down Expand Up @@ -443,7 +576,9 @@ export async function runSandboxSnapshot(
console.log(
` ${CLI_NAME} ${sandboxName} snapshot list List available snapshots`,
);
console.log(` ${CLI_NAME} ${sandboxName} snapshot restore [selector] [--to <dst>]`);
console.log(
` ${CLI_NAME} ${sandboxName} snapshot restore [selector] [--to <dst>] [--force] [--yes|-y]`,
);
console.log(
` Restore by version (v1), name, or timestamp.`,
);
Expand All @@ -453,6 +588,9 @@ export async function runSandboxSnapshot(
console.log(
` Use --to to restore into another sandbox; <dst> is auto-created if missing.`,
);
console.log(
` When <dst> already exists, pass --force to delete it and recreate from the snapshot (prompts unless --yes).`,
);
break;
}
}
2 changes: 1 addition & 1 deletion src/lib/cli/public-display-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
{
"group": "Sandbox Management",
"order": 9,
"flags": "[selector] [--to <dst>]"
"flags": "[selector] [--to <dst>] [--force] [--yes|-y]"
}
],
"sandbox:status": [
Expand Down
Loading
Loading