diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e229aac40f6..77f960cc252 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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"] @@ -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 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 ` | Working directory inside the sandbox | +| `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) | +| `--timeout ` | Timeout in seconds (`0` means no timeout) | + ### `nemoclaw recover` Restart the in-sandbox gateway and re-establish the host-side dashboard port-forward without opening an SSH session. @@ -823,7 +843,7 @@ Versions (`v1`, `v2`, ...) are computed on read from timestamp-ascending order, $ nemoclaw my-assistant snapshot list ``` -### `nemoclaw snapshot restore [selector] [--to ]` +### `nemoclaw snapshot restore [selector] [--to ] [--force] [--yes|-y]` Restore sandbox state from a snapshot. The sandbox must be running before you restore. @@ -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 ` 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 ` 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 @@ -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 share mount` diff --git a/src/commands/sandbox/snapshot.test.ts b/src/commands/sandbox/snapshot.test.ts index b83f29ac6ab..e93d1d24a03 100644 --- a/src/commands/sandbox/snapshot.test.ts +++ b/src/commands/sandbox/snapshot.test.ts @@ -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, }); }); diff --git a/src/commands/sandbox/snapshot/restore.ts b/src/commands/sandbox/snapshot/restore.ts index defe9b03c81..7f0244feda4 100644 --- a/src/commands/sandbox/snapshot/restore.ts +++ b/src/commands/sandbox/snapshot/restore.ts @@ -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 = [" [selector] [--to ]"]; + static usage = [" [selector] [--to ] [--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, @@ -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 { @@ -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); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index a6a36937442..7e0d5c56592 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -5,8 +5,10 @@ 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"; @@ -14,6 +16,7 @@ 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 = @@ -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[]; @@ -204,6 +217,68 @@ async function autoCreateSandboxFromSource( console.log(` ${G}\u2713${R} Sandbox '${dstName}' created`); } +// Delete an existing destination sandbox so `snapshot restore --to --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-: PID dir for this sandbox's services + // - OpenShell providers named -{telegram,discord,slack,wechat}-bridge + // and -slack-app: per-sandbox messaging bridges + // - shields-.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 { @@ -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; if (selector) { const { match } = sandboxState.findBackup(sandboxName, selector); if (!match) { @@ -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 { @@ -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 ]`); + console.log( + ` ${CLI_NAME} ${sandboxName} snapshot restore [selector] [--to ] [--force] [--yes|-y]`, + ); console.log( ` Restore by version (v1), name, or timestamp.`, ); @@ -453,6 +588,9 @@ export async function runSandboxSnapshot( console.log( ` Use --to to restore into another sandbox; is auto-created if missing.`, ); + console.log( + ` When already exists, pass --force to delete it and recreate from the snapshot (prompts unless --yes).`, + ); break; } } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 74847ae00f5..070f3cb886e 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -365,7 +365,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { "group": "Sandbox Management", "order": 9, - "flags": "[selector] [--to ]" + "flags": "[selector] [--to ] [--force] [--yes|-y]" } ], "sandbox:status": [ diff --git a/test/snapshot-restore-existing-dest.test.ts b/test/snapshot-restore-existing-dest.test.ts new file mode 100644 index 00000000000..d4bb810c818 --- /dev/null +++ b/test/snapshot-restore-existing-dest.test.ts @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Regression tests for issue #3756: `snapshot restore --to ` used to +// overwrite the destination silently when already existed. The new +// behaviour refuses by default and requires --force (with interactive confirm +// or --yes / NEMOCLAW_NON_INTERACTIVE=1) to delete-and-recreate the +// destination from the snapshot. +// +// The --force path preflights both the snapshot selector and the source pod +// image *before* deleting anything (#3756 P1 Codex). A bad selector, a +// missing snapshot, or an unresolvable source image must not be allowed to +// delete `dst` and only fail afterwards. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, it, expect } from "vitest"; + +import { execTimeout } from "./helpers/timeouts"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); + +type CliRunResult = { code: number; out: string }; + +function runCli(args: readonly string[], env: Record = {}): CliRunResult { + try { + const out = execFileSync("node", [CLI, ...args], { + encoding: "utf-8", + timeout: execTimeout(), + env: { + ...process.env, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + ...env, + }, + }); + return { code: 0, out }; + } catch (err: unknown) { + if (typeof err === "object" && err !== null && "status" in err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + const out = [e.stdout, e.stderr] + .map((b) => (typeof b === "string" ? b : b ? b.toString("utf-8") : "")) + .join(""); + return { code: typeof e.status === "number" ? e.status : 1, out }; + } + return { code: 1, out: String(err) }; + } +} + +interface MakeEnvOptions { + /** When false, omit the snapshot manifest so getLatestBackup returns null. */ + withSnapshot?: boolean; + /** When false, fake docker exec returns an empty image string. */ + withSourceImage?: boolean; +} + +/** + * Build a temp HOME with: + * - registry containing `src` and `dst` + * - snapshot manifest for `src` at ~/.nemoclaw/rebuild-backups/src//rebuild-manifest.json (unless withSnapshot=false) + * - fake openshell that: + * - `sandbox list` reports both `src` and `dst` as Ready + * - `status` reports the gateway as Connected + * - `sandbox delete dst` exits 0 (and logs the call) + * - `sandbox create` exits non-zero (intentional; the integration tests + * only need to verify control flow reached/passed the delete step) + * - fake docker that: + * - `inspect ... State.Running` returns "true" (gateway up) + * - `exec ... kubectl get pod src ...` returns an image string (or empty + * when withSourceImage=false), exercising resolveSrcPodImage's preflight + */ +function makeExistingDestEnv( + prefix: string, + opts: MakeEnvOptions = {}, +): { env: Record; osLog: string } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + src: { + name: "src", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + dst: { + name: "dst", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "src", + }), + { mode: 0o600 }, + ); + + if (opts.withSnapshot !== false) { + const timestamp = "2026-05-19T12-34-56-789Z"; + const snapshotDir = path.join(registryDir, "rebuild-backups", "src", timestamp); + fs.mkdirSync(snapshotDir, { recursive: true }); + fs.writeFileSync( + path.join(snapshotDir, "rebuild-manifest.json"), + JSON.stringify({ + version: 2, + sandboxName: "src", + timestamp, + agentType: "openclaw", + agentVersion: "2026.4.24", + expectedVersion: null, + stateDirs: [], + dir: snapshotDir, + backupPath: snapshotDir, + blueprintDigest: null, + }), + { mode: 0o600 }, + ); + } + + const osLog = path.join(home, "openshell.log"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >> ${JSON.stringify(osLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\nsrc Ready\\ndst Ready\\n"', + " exit 0", + "fi", + 'if [ "$1" = "status" ]; then', + ' printf "Status: Connected\\n"', + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "delete" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "create" ]; then', + // Intentional non-zero: the test only needs to confirm delete fired + // and create was reached; not exercising the full create stream. + ' echo "fake-openshell: sandbox create not mocked end-to-end" >&2', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const sourceImageOutput = + opts.withSourceImage === false ? "" : "ghcr.io/nvidia/nemoclaw/sandbox-src:test"; + fs.writeFileSync( + path.join(localBin, "docker"), + [ + "#!/bin/sh", + 'if [ "$1" = "inspect" ]; then', + ' echo "true"', + " exit 0", + "fi", + 'if [ "$1" = "exec" ]; then', + // The action calls `docker exec kubectl get pod ...`. + // Return the configured image (or an empty string to simulate + // "image cannot be resolved", which #3756 P1 says must abort before + // we touch the destination). + ` printf '%s' ${JSON.stringify(sourceImageOutput)}`, + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + return { env: { HOME: home, PATH: `${localBin}:${process.env.PATH ?? ""}` }, osLog }; +} + +describe("snapshot restore --to existing destination (#3756)", () => { + it("refuses by default when the destination sandbox already exists", () => { + const { env, osLog } = makeExistingDestEnv("nemoclaw-snap-restore-refuse-"); + const r = runCli(["src", "snapshot", "restore", "--to", "dst"], env); + expect(r.code).toBe(1); + expect(r.out).toMatch(/Destination sandbox 'dst' already exists/); + expect(r.out).toMatch(/Re-run with --force/); + // Critically, no delete is attempted in the refuse path. + const log = fs.existsSync(osLog) ? fs.readFileSync(osLog, "utf-8") : ""; + expect(log).not.toMatch(/sandbox delete dst/); + }); + + it("refuses by default before running source-image preflight (Codex #3796 P2)", () => { + // Existing destination + unresolvable source image. The user must see the + // precise "destination exists" error, not the "cannot resolve image" + // misdirection that would land if the refusal came after preflight. + const { env } = makeExistingDestEnv("nemoclaw-snap-restore-refuse-before-preflight-", { + withSourceImage: false, + }); + const r = runCli(["src", "snapshot", "restore", "--to", "dst"], env); + expect(r.code).toBe(1); + expect(r.out).toMatch(/Destination sandbox 'dst' already exists/); + expect(r.out).not.toMatch(/Cannot resolve image/); + }); + + it("deletes the destination when --force --yes is set, then proceeds (#3756)", () => { + const { env, osLog } = makeExistingDestEnv("nemoclaw-snap-restore-force-"); + const r = runCli(["src", "snapshot", "restore", "--to", "dst", "--force", "--yes"], env); + // Auto-create is intentionally mocked to fail end-to-end (the fake + // openshell exits non-zero on `sandbox create`); the test only proves the + // new --force branch ran through the delete step. + expect(r.out).toMatch(/Deleting existing destination 'dst'/); + const log = fs.existsSync(osLog) ? fs.readFileSync(osLog, "utf-8") : ""; + expect(log).toMatch(/sandbox delete dst/); + }); + + it("skips the prompt under NEMOCLAW_NON_INTERACTIVE=1 even without --yes", () => { + const base = makeExistingDestEnv("nemoclaw-snap-restore-noninteractive-"); + const env = { ...base.env, NEMOCLAW_NON_INTERACTIVE: "1" }; + const r = runCli(["src", "snapshot", "restore", "--to", "dst", "--force"], env); + expect(r.out).toMatch(/Deleting existing destination 'dst'/); + const log = fs.existsSync(base.osLog) ? fs.readFileSync(base.osLog, "utf-8") : ""; + expect(log).toMatch(/sandbox delete dst/); + }); + + // #3756 P1: preflight failures must not delete the destination. + it("does NOT delete the destination when no snapshot is found (--force --yes)", () => { + const { env, osLog } = makeExistingDestEnv("nemoclaw-snap-restore-no-snap-", { + withSnapshot: false, + }); + const r = runCli(["src", "snapshot", "restore", "--to", "dst", "--force", "--yes"], env); + expect(r.code).toBe(1); + expect(r.out).toMatch(/No snapshots found for 'src'/); + expect(r.out).not.toMatch(/Deleting existing destination 'dst'/); + const log = fs.existsSync(osLog) ? fs.readFileSync(osLog, "utf-8") : ""; + expect(log).not.toMatch(/sandbox delete dst/); + }); + + it("does NOT delete the destination when the selector resolves to nothing (--force --yes)", () => { + const { env, osLog } = makeExistingDestEnv("nemoclaw-snap-restore-bad-selector-"); + const r = runCli( + ["src", "snapshot", "restore", "not-a-real-snap", "--to", "dst", "--force", "--yes"], + env, + ); + expect(r.code).toBe(1); + expect(r.out).toMatch(/No snapshot matching 'not-a-real-snap' found/); + expect(r.out).not.toMatch(/Deleting existing destination 'dst'/); + const log = fs.existsSync(osLog) ? fs.readFileSync(osLog, "utf-8") : ""; + expect(log).not.toMatch(/sandbox delete dst/); + }); + + it("does NOT delete the destination when the source pod image cannot be resolved (--force --yes)", () => { + const { env, osLog } = makeExistingDestEnv("nemoclaw-snap-restore-no-image-", { + withSourceImage: false, + }); + const r = runCli(["src", "snapshot", "restore", "--to", "dst", "--force", "--yes"], env); + expect(r.code).toBe(1); + expect(r.out).toMatch(/Cannot resolve image for source sandbox 'src'/); + expect(r.out).toMatch(/aborting before deleting 'dst'/); + expect(r.out).not.toMatch(/Deleting existing destination 'dst'/); + const log = fs.existsSync(osLog) ? fs.readFileSync(osLog, "utf-8") : ""; + expect(log).not.toMatch(/sandbox delete dst/); + }); +});