From f244cec365cefdccb3eeb4941d9ddea722d3f683 Mon Sep 17 00:00:00 2001 From: Hokonoken <41166525+Hokonoken@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:36:20 +0200 Subject: [PATCH 1/5] fix(backup): back up stopped docker sandboxes by starting them for the backup A registered docker-driver sandbox whose container is exited or created is backupable: the backup transport is SSH+tar through the container's PID 1 and does not need the agent gateway. backup-all now starts such a container for the duration of the backup and returns it to its stopped state after, so the installer-strict gate (#6114) can pass without weakening what it protects. Containers that are running-but-not-Ready, paused, absent, or fail to start keep the existing skip and remediation message. Addresses #6500 Signed-off-by: Hokonoken <41166525+Hokonoken@users.noreply.github.com> --- docs/manage-sandboxes/backup-restore.mdx | 3 +- docs/reference/commands.mdx | 5 +- src/lib/actions/maintenance.test.ts | 135 +++++++++++++++++ src/lib/actions/maintenance.ts | 37 ++++- .../sandbox/stopped-sandbox-backup.test.ts | 141 ++++++++++++++++++ .../actions/sandbox/stopped-sandbox-backup.ts | 129 ++++++++++++++++ 6 files changed, 442 insertions(+), 8 deletions(-) create mode 100644 src/lib/actions/sandbox/stopped-sandbox-backup.test.ts create mode 100644 src/lib/actions/sandbox/stopped-sandbox-backup.ts diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 3f171735ce7..96e67d85a29 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -268,7 +268,8 @@ $$nemoclaw backup-all ``` `backup-all` walks the sandboxes registered on the host, creates a snapshot for each running sandbox, and stores the snapshot bundles under `~/.nemoclaw/rebuild-backups//`. -If a registered sandbox is stopped, start the sandbox or its container and rerun `$$nemoclaw backup-all` so NemoClaw can capture a fresh snapshot before maintenance. +If a registered docker-driver sandbox's container is stopped, `backup-all` starts the container for the duration of the backup and returns it to its stopped state afterwards. +If a sandbox is not running and its container cannot be started this way, start the sandbox or its container and rerun `$$nemoclaw backup-all` so NemoClaw can capture a fresh snapshot before maintenance. When a backup fails, NemoClaw identifies the affected state item and reports one of these causes when available: `permission denied`, `tar read error`, or `absent after extraction`. Use `$$nemoclaw snapshot list` and `$$nemoclaw snapshot restore` to inspect or restore one sandbox's bundles later. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index cc217d27a50..bb303cacaa6 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2497,7 +2497,8 @@ A custom OpenClaw sandbox is recoverable only when the selected backup independe ### `$$nemoclaw backup-all` Back up all registered running sandboxes to `~/.nemoclaw/rebuild-backups/`. -Sandboxes that are not running are skipped with remediation guidance. +A registered docker-driver sandbox whose container is stopped is started for the duration of the backup and returned to its stopped state afterwards. +Sandboxes that are not running and cannot be started this way are skipped with remediation guidance. ```bash $$nemoclaw backup-all @@ -2505,7 +2506,7 @@ $$nemoclaw backup-all Before an OpenShell upgrade, the installer prepares the current release CLI and uses it to run `backup-all` in strict mode. Strict mode requires every registered sandbox to produce a fresh backup and aborts before gateway changes if any sandbox is skipped or fails. -When strict mode reports a skipped stopped sandbox, start that sandbox or its container and rerun the installer or `$$nemoclaw backup-all`. +When strict mode reports a skipped sandbox, start that sandbox or its container and rerun the installer or `$$nemoclaw backup-all`. A running sandbox whose in-sandbox SSH endpoint does not answer fails its backup and aborts the run. For a standalone `$$nemoclaw backup-all` run, set `NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP=1` exactly to skip such sandboxes instead of failing. diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index e8684bc7438..e451dbf7653 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ dockerListImagesFormat: vi.fn().mockReturnValue(""), dockerRmi: vi.fn(), prompt: vi.fn(), + startStoppedSandboxContainerForBackup: vi.fn(), + backupStartedSandboxState: vi.fn(), + returnSandboxContainerToStopped: vi.fn(), })); vi.mock("../state/registry", () => ({ @@ -36,6 +39,11 @@ vi.mock("../cli/branding", () => ({ vi.mock("../credentials/store", () => ({ prompt: mocks.prompt, })); +vi.mock("./sandbox/stopped-sandbox-backup", () => ({ + startStoppedSandboxContainerForBackup: mocks.startStoppedSandboxContainerForBackup, + backupStartedSandboxState: mocks.backupStartedSandboxState, + returnSandboxContainerToStopped: mocks.returnSandboxContainerToStopped, +})); vi.mock("../domain/lifecycle/options", () => ({ normalizeGarbageCollectImagesOptions: (o: unknown) => o || {}, })); @@ -54,6 +62,8 @@ describe("backupAll", () => { output: "sb-good\nsb-bad\n", }); mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good", "sb-bad"])); + mocks.startStoppedSandboxContainerForBackup.mockReturnValue(null); + mocks.returnSandboxContainerToStopped.mockReturnValue(true); }); afterEach(() => { @@ -191,6 +201,131 @@ describe("backupAll", () => { expect(errorOutput).not.toContain("prepare the upgrade manually"); }); + it("starts a stopped container, backs it up, and returns it to stopped so strict mode passes (#6500)", async () => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-good" }, { name: "sb-stopped" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good"])); + mocks.backupSandboxState.mockReturnValue({ + success: true, + backedUpDirs: ["workspace"], + failedDirs: [], + backedUpFiles: [], + failedFiles: [], + manifest: { backupPath: "/backups/sb-good/timestamp" }, + }); + mocks.startStoppedSandboxContainerForBackup.mockImplementation((name: string) => + name === "sb-stopped" ? { containerName: "openshell-sb-stopped-abc" } : null, + ); + mocks.backupStartedSandboxState.mockResolvedValue({ + success: true, + backedUpDirs: ["workspace"], + failedDirs: [], + backedUpFiles: [], + failedFiles: [], + manifest: { backupPath: "/backups/sb-stopped/timestamp" }, + }); + process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + await backupAll(); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(mocks.backupStartedSandboxState).toHaveBeenCalledWith("sb-stopped"); + expect(mocks.backupSandboxState).toHaveBeenCalledWith("sb-good"); + expect(mocks.returnSandboxContainerToStopped).toHaveBeenCalledWith("openshell-sb-stopped-abc"); + const logOutput = logSpy.mock.calls.flat().join("\n"); + expect(logOutput).toContain("Starting stopped sandbox 'sb-stopped' to back it up"); + expect(logOutput).toContain("Returned 'sb-stopped' to its stopped state"); + expect(logOutput).toContain("2 backed up, 0 failed, 0 skipped"); + expect(logOutput).not.toContain("Skipping 'sb-stopped'"); + }); + + it("returns the container to stopped and counts a failure when the started backup fails (#6500)", async () => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-stopped" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set()); + mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ + containerName: "openshell-sb-stopped-abc", + }); + mocks.backupStartedSandboxState.mockResolvedValue({ + success: false, + backedUpDirs: [], + failedDirs: ["identity"], + failedDirReasons: { identity: "permission denied" }, + backedUpFiles: [], + failedFiles: [], + }); + process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + await expect(backupAll()).rejects.toThrow("exit:1"); + + expect(mocks.returnSandboxContainerToStopped).toHaveBeenCalledWith("openshell-sb-stopped-abc"); + expect(logSpy.mock.calls.flat().join("\n")).toContain("0 backed up, 1 failed, 0 skipped"); + expect(errorSpy.mock.calls.flat().join("\n")).toContain( + "backup failed (identity (permission denied))", + ); + }); + + it("warns when the started container cannot be returned to its stopped state (#6500)", async () => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-stopped" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set()); + mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ + containerName: "openshell-sb-stopped-abc", + }); + mocks.backupStartedSandboxState.mockResolvedValue({ + success: true, + backedUpDirs: ["workspace"], + failedDirs: [], + backedUpFiles: [], + failedFiles: [], + manifest: { backupPath: "/backups/sb-stopped/timestamp" }, + }); + mocks.returnSandboxContainerToStopped.mockReturnValue(false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await backupAll(); + + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "Could not return 'sb-stopped' to its stopped state after backup", + ); + }); + + it("keeps the not-running skip when no stopped container can be started (#6114)", async () => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-stopped" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set()); + mocks.startStoppedSandboxContainerForBackup.mockReturnValue(null); + process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + await expect(backupAll()).rejects.toThrow("exit:1"); + + expect(mocks.backupStartedSandboxState).not.toHaveBeenCalled(); + expect(mocks.returnSandboxContainerToStopped).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.flat().join("\n")).toContain("Skipping 'sb-stopped' (not running"); + }); + it("continues backup loop when backupSandboxState throws for one sandbox", async () => { mocks.listSandboxes.mockReturnValue({ sandboxes: [{ name: "sb-bad" }, { name: "sb-good" }], diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index 77a7ee1f203..bc8f1be6afd 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -15,6 +15,12 @@ import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandb import { parseReadySandboxNames } from "../runtime-recovery"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; +import { + backupStartedSandboxState, + returnSandboxContainerToStopped, + type StartedForBackup, + startStoppedSandboxContainerForBackup, +} from "./sandbox/stopped-sandbox-backup"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = @@ -54,16 +60,27 @@ export async function backupAll(): Promise { let unreachableRunning = 0; let notRunningSkipped = 0; for (const sb of sandboxes) { + // A registered docker-driver sandbox whose container is merely stopped is + // backupable: start it for the duration of the backup and return it to + // its stopped state after (#6500). Anything else that is not Ready keeps + // the existing skip (and, under installer-strict mode, the #6114 gate). + let startedForBackup: StartedForBackup | null = null; if (!readyNames.has(sb.name)) { - console.log(` ${D}${notRunningBackupSkipMessage(sb.name)}${R}`); - skipped++; - notRunningSkipped++; - continue; + startedForBackup = startStoppedSandboxContainerForBackup(sb.name); + if (!startedForBackup) { + console.log(` ${D}${notRunningBackupSkipMessage(sb.name)}${R}`); + skipped++; + notRunningSkipped++; + continue; + } + console.log(` Starting stopped sandbox '${sb.name}' to back it up...`); } console.log(` Backing up '${sb.name}'...`); let result: sandboxState.BackupResult; try { - result = sandboxState.backupSandboxState(sb.name); + result = startedForBackup + ? await backupStartedSandboxState(sb.name) + : sandboxState.backupSandboxState(sb.name); } catch (err: unknown) { // Source-of-truth review (#5734 / #5819): // @@ -103,6 +120,16 @@ export async function backupAll(): Promise { console.log(` ${YW}⚠${R} Skipped '${sb.name}' (orphan manifest): ${msg}`); skipped++; continue; + } finally { + if (startedForBackup) { + if (returnSandboxContainerToStopped(startedForBackup.containerName)) { + console.log(` ${D}Returned '${sb.name}' to its stopped state.${R}`); + } else { + console.log( + ` ${YW}⚠${R} Could not return '${sb.name}' to its stopped state after backup; its container was left running.`, + ); + } + } } if (result.success) { console.log( diff --git a/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts b/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts new file mode 100644 index 00000000000..e6762eda540 --- /dev/null +++ b/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../state/registry", () => ({ + getSandbox: vi.fn(), + listSandboxes: vi.fn(), +})); +vi.mock("../../state/sandbox", () => ({ + backupSandboxState: vi.fn(), +})); + +import { + backupStartedSandboxState, + returnSandboxContainerToStopped, + startStoppedSandboxContainerForBackup, +} from "./stopped-sandbox-backup"; + +describe("startStoppedSandboxContainerForBackup", () => { + const deps = (over: Record = {}) => ({ + getSandboxDriver: vi.fn().mockReturnValue("docker"), + listSandboxNames: vi.fn().mockReturnValue(["my-sb"]), + dockerPsAllNames: vi.fn().mockReturnValue("openshell-my-sb-abc123\n"), + dockerInspectStatus: vi.fn().mockReturnValue("exited"), + dockerStart: vi.fn().mockReturnValue("openshell-my-sb-abc123"), + ...over, + }); + + it("starts an exited docker-driver container and reports its name", () => { + const d = deps(); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toEqual({ + containerName: "openshell-my-sb-abc123", + }); + expect(d.dockerStart).toHaveBeenCalledWith("openshell-my-sb-abc123"); + }); + + it("starts a created container (onboarded but never run)", () => { + const d = deps({ dockerInspectStatus: vi.fn().mockReturnValue("created") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).not.toBeNull(); + }); + + it("leaves non-docker-driver sandboxes alone", () => { + const d = deps({ getSandboxDriver: vi.fn().mockReturnValue("kubernetes") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerPsAllNames).not.toHaveBeenCalled(); + }); + + it("returns null when no container owns the sandbox name", () => { + const d = deps({ dockerPsAllNames: vi.fn().mockReturnValue("openshell-other-x\n") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("leaves a running-but-not-Ready container alone (crash loop, gateway drift)", () => { + const d = deps({ dockerInspectStatus: vi.fn().mockReturnValue("running") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("leaves a paused container alone (#4495)", () => { + const d = deps({ dockerInspectStatus: vi.fn().mockReturnValue("paused") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("returns null when docker start fails", () => { + const d = deps({ dockerStart: vi.fn().mockReturnValue("") }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + }); +}); + +describe("returnSandboxContainerToStopped", () => { + it("reports success when docker stop echoes the container name", () => { + const dockerStop = vi.fn().mockReturnValue("openshell-my-sb-abc123"); + expect(returnSandboxContainerToStopped("openshell-my-sb-abc123", { dockerStop })).toBe(true); + expect(dockerStop).toHaveBeenCalledWith("openshell-my-sb-abc123"); + }); + + it("reports failure when docker stop produces no output", () => { + const dockerStop = vi.fn().mockReturnValue(""); + expect(returnSandboxContainerToStopped("openshell-my-sb-abc123", { dockerStop })).toBe(false); + }); +}); + +describe("backupStartedSandboxState", () => { + const ok = { + success: true, + backedUpDirs: [], + failedDirs: [], + backedUpFiles: [], + failedFiles: [], + }; + const unreachable = { ...ok, success: false, unreachable: true }; + const denied = { ...ok, success: false }; + + it("retries while the just-started container's SSH endpoint is unreachable (#6500)", async () => { + const backup = vi + .fn() + .mockReturnValueOnce(unreachable) + .mockReturnValueOnce(unreachable) + .mockReturnValueOnce(ok); + const sleep = vi.fn().mockResolvedValue(undefined); + const result = await backupStartedSandboxState("my-sb", { + backup, + sleep, + attempts: 5, + delayMs: 1, + }); + expect(result.success).toBe(true); + expect(backup).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("returns a non-transport failure without retrying", async () => { + const backup = vi.fn().mockReturnValue(denied); + const sleep = vi.fn().mockResolvedValue(undefined); + const result = await backupStartedSandboxState("my-sb", { + backup, + sleep, + attempts: 5, + delayMs: 1, + }); + expect(result.success).toBe(false); + expect(backup).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("gives up after the attempt budget while still unreachable", async () => { + const backup = vi.fn().mockReturnValue(unreachable); + const sleep = vi.fn().mockResolvedValue(undefined); + const result = await backupStartedSandboxState("my-sb", { + backup, + sleep, + attempts: 3, + delayMs: 1, + }); + expect(result.unreachable).toBe(true); + expect(backup).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/actions/sandbox/stopped-sandbox-backup.ts b/src/lib/actions/sandbox/stopped-sandbox-backup.ts new file mode 100644 index 00000000000..d90a409b524 --- /dev/null +++ b/src/lib/actions/sandbox/stopped-sandbox-backup.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { dockerContainerInspectFormat } from "../../adapters/docker/inspect"; +import { dockerCapture } from "../../adapters/docker/run"; +import * as registry from "../../state/registry"; +import * as sandboxState from "../../state/sandbox"; +import { resolveSandboxContainerOwner } from "./sandbox-container-owner"; + +/** + * Backup support for registered docker-driver sandboxes whose container is + * stopped. `backup-all` skips sandboxes the gateway does not report Ready, + * which under installer-strict mode (#6114) fails the whole run — but a + * stopped container's state is backupable: the backup transport is SSH+tar + * through the container's PID 1 and does not need the agent gateway, so + * `docker start` alone is enough to capture it (#6500). These helpers start + * such a container for the duration of the backup and return it to its + * stopped state afterwards, so the strict gate can pass without weakening + * what it protects. + * + * Only containers whose `.State.Status` is `exited` or `created` qualify. + * A running-but-not-Ready container (crash loop, gateway drift, paused) is + * left alone: starting or stopping it could destroy diagnostic state, and + * the existing skip message already names the remediation. + */ + +export interface StartedForBackup { + containerName: string; +} + +interface StartDeps { + getSandboxDriver: (name: string) => string | null | undefined; + listSandboxNames: () => string[]; + dockerPsAllNames: () => string; + dockerInspectStatus: (containerName: string) => string; + dockerStart: (containerName: string) => string; +} + +const defaultStartDeps: StartDeps = { + getSandboxDriver: (name) => { + try { + return registry.getSandbox(name)?.openshellDriver; + } catch { + return undefined; + } + }, + listSandboxNames: () => registry.listSandboxes().sandboxes.map((entry) => entry.name), + dockerPsAllNames: () => + dockerCapture(["ps", "-a", "--format", "{{.Names}}"], { ignoreError: true }), + dockerInspectStatus: (containerName) => + dockerContainerInspectFormat("{{.State.Status}}", containerName, { ignoreError: true }), + // `docker start` echoes the container name on success and prints nothing to + // stdout on failure, so a non-empty capture doubles as the success signal. + dockerStart: (containerName) => dockerCapture(["start", containerName], { ignoreError: true }), +}; + +export function startStoppedSandboxContainerForBackup( + sandboxName: string, + depsOverride: Partial = {}, +): StartedForBackup | null { + const deps: StartDeps = { ...defaultStartDeps, ...depsOverride }; + if (deps.getSandboxDriver(sandboxName) !== "docker") return null; + const containerName = resolveSandboxContainerOwner( + deps.dockerPsAllNames(), + sandboxName, + deps.listSandboxNames(), + ); + if (!containerName) return null; + const status = deps.dockerInspectStatus(containerName).trim().toLowerCase(); + if (status !== "exited" && status !== "created") return null; + if (deps.dockerStart(containerName).trim() === "") return null; + return { containerName }; +} + +interface StopDeps { + dockerStop: (containerName: string) => string; +} + +const defaultStopDeps: StopDeps = { + dockerStop: (containerName) => dockerCapture(["stop", containerName], { ignoreError: true }), +}; + +/** Return a container started by {@link startStoppedSandboxContainerForBackup} + * to its stopped state. Returns false when `docker stop` fails. */ +export function returnSandboxContainerToStopped( + containerName: string, + depsOverride: Partial = {}, +): boolean { + const deps: StopDeps = { ...defaultStopDeps, ...depsOverride }; + return deps.dockerStop(containerName).trim() !== ""; +} + +interface BackupRetryDeps { + backup: (name: string) => sandboxState.BackupResult; + sleep: (ms: number) => Promise; + attempts: number; + delayMs: number; +} + +const defaultBackupRetryDeps: BackupRetryDeps = { + backup: (name) => sandboxState.backupSandboxState(name), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + attempts: 5, + delayMs: 2000, +}; + +/** + * Back up a sandbox whose container was just started. The container's SSH + * endpoint can take a few seconds to answer after `docker start`, so retry + * while — and only while — the result is a transport-level `unreachable` + * failure. Every other outcome (success, permission failure, precondition) + * is returned as-is on first sight. + */ +export async function backupStartedSandboxState( + sandboxName: string, + depsOverride: Partial = {}, +): Promise { + const deps: BackupRetryDeps = { ...defaultBackupRetryDeps, ...depsOverride }; + let result = deps.backup(sandboxName); + for ( + let attempt = 1; + attempt < deps.attempts && !result.success && result.unreachable; + attempt++ + ) { + await deps.sleep(deps.delayMs); + result = deps.backup(sandboxName); + } + return result; +} From 9d9b84bf79e2440c3eaa534774eacc2db11f9d2c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 12 Jul 2026 20:51:45 -0700 Subject: [PATCH 2/5] fix(backup): harden stopped sandbox lifecycle Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/backup-restore.mdx | 3 +- docs/reference/commands.mdx | 3 +- src/lib/actions/maintenance.test.ts | 37 +++++++- src/lib/actions/maintenance.ts | 23 +++-- .../sandbox/stopped-sandbox-backup.test.ts | 66 +++++++++++++-- .../actions/sandbox/stopped-sandbox-backup.ts | 23 +++-- test/e2e/live/snapshot-commands.test.ts | 84 +++++++++++++++++++ 7 files changed, 215 insertions(+), 24 deletions(-) diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 96e67d85a29..fac57e2c60e 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -268,7 +268,8 @@ $$nemoclaw backup-all ``` `backup-all` walks the sandboxes registered on the host, creates a snapshot for each running sandbox, and stores the snapshot bundles under `~/.nemoclaw/rebuild-backups//`. -If a registered docker-driver sandbox's container is stopped, `backup-all` starts the container for the duration of the backup and returns it to its stopped state afterwards. +If a registered docker-driver sandbox's container is stopped, `backup-all` starts the container for the duration of the backup and returns it to its stopped state afterward. +If the container cannot be returned to the stopped state, the backup run fails and reports that the container was left running. If a sandbox is not running and its container cannot be started this way, start the sandbox or its container and rerun `$$nemoclaw backup-all` so NemoClaw can capture a fresh snapshot before maintenance. When a backup fails, NemoClaw identifies the affected state item and reports one of these causes when available: `permission denied`, `tar read error`, or `absent after extraction`. Use `$$nemoclaw snapshot list` and `$$nemoclaw snapshot restore` to inspect or restore one sandbox's bundles later. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index bb303cacaa6..136609bb7cd 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2497,7 +2497,8 @@ A custom OpenClaw sandbox is recoverable only when the selected backup independe ### `$$nemoclaw backup-all` Back up all registered running sandboxes to `~/.nemoclaw/rebuild-backups/`. -A registered docker-driver sandbox whose container is stopped is started for the duration of the backup and returned to its stopped state afterwards. +A registered docker-driver sandbox whose container is stopped is started for the duration of the backup and returned to its stopped state afterward. +If the container cannot be returned to the stopped state, the command fails and reports that the container was left running. Sandboxes that are not running and cannot be started this way are skipped with remediation guidance. ```bash diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index e451dbf7653..b66ae250169 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -56,6 +56,7 @@ import { backupAll, garbageCollectImages, shouldSkipUnreachableSandboxBackup } f describe("backupAll", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.backupStartedSandboxState.mockReset(); delete process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS; mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ status: 0, @@ -278,7 +279,7 @@ describe("backupAll", () => { ); }); - it("warns when the started container cannot be returned to its stopped state (#6500)", async () => { + it("fails when the started container cannot be returned to its stopped state (#6500)", async () => { mocks.listSandboxes.mockReturnValue({ sandboxes: [{ name: "sb-stopped" }], defaultSandbox: null, @@ -296,15 +297,43 @@ describe("backupAll", () => { manifest: { backupPath: "/backups/sb-stopped/timestamp" }, }); mocks.returnSandboxContainerToStopped.mockReturnValue(false); + process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); - await backupAll(); + await expect(backupAll()).rejects.toThrow("exit:1"); - expect(logSpy.mock.calls.flat().join("\n")).toContain( - "Could not return 'sb-stopped' to its stopped state after backup", + expect(logSpy.mock.calls.flat().join("\n")).toContain("0 backed up, 1 failed, 0 skipped"); + expect(errorSpy.mock.calls.flat().join("\n")).toContain( + "backup cleanup failed (could not return its container to the stopped state", ); }); + it("returns a started container to stopped when an orphan manifest skips backup (#6500)", async () => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-stopped" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set()); + mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ + containerName: "openshell-sb-stopped-abc", + }); + mocks.backupStartedSandboxState.mockRejectedValue( + new Error("Agent 'sb-stopped' not found: /path/to/manifest.yaml"), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await backupAll(); + + expect(mocks.returnSandboxContainerToStopped).toHaveBeenCalledWith("openshell-sb-stopped-abc"); + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Returned 'sb-stopped' to its stopped state"); + expect(output).toContain("Skipped 'sb-stopped' (orphan manifest)"); + }); + it("keeps the not-running skip when no stopped container can be started (#6114)", async () => { mocks.listSandboxes.mockReturnValue({ sandboxes: [{ name: "sb-stopped" }], diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index bc8f1be6afd..08e73a665a3 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -76,7 +76,9 @@ export async function backupAll(): Promise { console.log(` Starting stopped sandbox '${sb.name}' to back it up...`); } console.log(` Backing up '${sb.name}'...`); - let result: sandboxState.BackupResult; + let result: sandboxState.BackupResult | null = null; + let orphanManifestMessage: string | null = null; + let returnedToStopped = true; try { result = startedForBackup ? await backupStartedSandboxState(sb.name) @@ -117,20 +119,29 @@ export async function backupAll(): Promise { if (!/^Agent '[^']+' not found: .+\/manifest\.yaml$/.test(msg)) { throw err; } - console.log(` ${YW}⚠${R} Skipped '${sb.name}' (orphan manifest): ${msg}`); - skipped++; - continue; + orphanManifestMessage = msg; } finally { if (startedForBackup) { if (returnSandboxContainerToStopped(startedForBackup.containerName)) { console.log(` ${D}Returned '${sb.name}' to its stopped state.${R}`); } else { - console.log( - ` ${YW}⚠${R} Could not return '${sb.name}' to its stopped state after backup; its container was left running.`, + returnedToStopped = false; + console.error( + ` ${RD}✗${R} ${sb.name}: backup cleanup failed (could not return its container to the stopped state; the container was left running)`, ); } } } + if (!returnedToStopped) { + failed++; + continue; + } + if (orphanManifestMessage) { + console.log(` ${YW}⚠${R} Skipped '${sb.name}' (orphan manifest): ${orphanManifestMessage}`); + skipped++; + continue; + } + if (!result) throw new Error(`Backup for '${sb.name}' completed without a result`); if (result.success) { console.log( ` ${G}✓${R} ${sb.name}: ${result.backedUpDirs.length} dirs, ${result.backedUpFiles.length} files → ${result.manifest?.backupPath || "unknown"}`, diff --git a/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts b/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts index e6762eda540..b52442fd3ff 100644 --- a/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts +++ b/src/lib/actions/sandbox/stopped-sandbox-backup.test.ts @@ -21,7 +21,7 @@ describe("startStoppedSandboxContainerForBackup", () => { const deps = (over: Record = {}) => ({ getSandboxDriver: vi.fn().mockReturnValue("docker"), listSandboxNames: vi.fn().mockReturnValue(["my-sb"]), - dockerPsAllNames: vi.fn().mockReturnValue("openshell-my-sb-abc123\n"), + listLabeledContainerNames: vi.fn().mockReturnValue(["openshell-my-sb-abc123"]), dockerInspectStatus: vi.fn().mockReturnValue("exited"), dockerStart: vi.fn().mockReturnValue("openshell-my-sb-abc123"), ...over, @@ -43,11 +43,38 @@ describe("startStoppedSandboxContainerForBackup", () => { it("leaves non-docker-driver sandboxes alone", () => { const d = deps({ getSandboxDriver: vi.fn().mockReturnValue("kubernetes") }); expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); - expect(d.dockerPsAllNames).not.toHaveBeenCalled(); + expect(d.listLabeledContainerNames).not.toHaveBeenCalled(); }); - it("returns null when no container owns the sandbox name", () => { - const d = deps({ dockerPsAllNames: vi.fn().mockReturnValue("openshell-other-x\n") }); + it("returns null when no labeled container owns the sandbox name", () => { + const d = deps({ listLabeledContainerNames: vi.fn().mockReturnValue([]) }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("refuses ambiguous labeled containers", () => { + const d = deps({ + listLabeledContainerNames: vi + .fn() + .mockReturnValue(["openshell-my-sb-old", "openshell-my-sb-new"]), + }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerInspectStatus).not.toHaveBeenCalled(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("refuses a labeled container whose name does not belong to the sandbox", () => { + const d = deps({ listLabeledContainerNames: vi.fn().mockReturnValue(["openshell-other-x"]) }); + expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); + expect(d.dockerStart).not.toHaveBeenCalled(); + }); + + it("leaves GPU recovery backup siblings to the dedicated recovery flow", () => { + const d = deps({ + listLabeledContainerNames: vi + .fn() + .mockReturnValue(["openshell-my-sb-nemoclaw-gpu-backup-123"]), + }); expect(startStoppedSandboxContainerForBackup("my-sb", d)).toBeNull(); expect(d.dockerStart).not.toHaveBeenCalled(); }); @@ -71,15 +98,40 @@ describe("startStoppedSandboxContainerForBackup", () => { }); describe("returnSandboxContainerToStopped", () => { - it("reports success when docker stop echoes the container name", () => { + it("reports success when docker stop echoes the name and inspect confirms exited", () => { const dockerStop = vi.fn().mockReturnValue("openshell-my-sb-abc123"); - expect(returnSandboxContainerToStopped("openshell-my-sb-abc123", { dockerStop })).toBe(true); + const dockerInspectStatus = vi.fn().mockReturnValue("exited"); + expect( + returnSandboxContainerToStopped("openshell-my-sb-abc123", { + dockerStop, + dockerInspectStatus, + }), + ).toBe(true); expect(dockerStop).toHaveBeenCalledWith("openshell-my-sb-abc123"); + expect(dockerInspectStatus).toHaveBeenCalledWith("openshell-my-sb-abc123"); }); it("reports failure when docker stop produces no output", () => { const dockerStop = vi.fn().mockReturnValue(""); - expect(returnSandboxContainerToStopped("openshell-my-sb-abc123", { dockerStop })).toBe(false); + const dockerInspectStatus = vi.fn(); + expect( + returnSandboxContainerToStopped("openshell-my-sb-abc123", { + dockerStop, + dockerInspectStatus, + }), + ).toBe(false); + expect(dockerInspectStatus).not.toHaveBeenCalled(); + }); + + it("reports failure when the container is still running after docker stop", () => { + const dockerStop = vi.fn().mockReturnValue("openshell-my-sb-abc123"); + const dockerInspectStatus = vi.fn().mockReturnValue("running"); + expect( + returnSandboxContainerToStopped("openshell-my-sb-abc123", { + dockerStop, + dockerInspectStatus, + }), + ).toBe(false); }); }); diff --git a/src/lib/actions/sandbox/stopped-sandbox-backup.ts b/src/lib/actions/sandbox/stopped-sandbox-backup.ts index d90a409b524..04fef0e7bcb 100644 --- a/src/lib/actions/sandbox/stopped-sandbox-backup.ts +++ b/src/lib/actions/sandbox/stopped-sandbox-backup.ts @@ -3,6 +3,7 @@ import { dockerContainerInspectFormat } from "../../adapters/docker/inspect"; import { dockerCapture } from "../../adapters/docker/run"; +import { findLabeledSandboxContainers } from "../../onboard/docker-driver-sandbox-recovery"; import * as registry from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; import { resolveSandboxContainerOwner } from "./sandbox-container-owner"; @@ -31,7 +32,7 @@ export interface StartedForBackup { interface StartDeps { getSandboxDriver: (name: string) => string | null | undefined; listSandboxNames: () => string[]; - dockerPsAllNames: () => string; + listLabeledContainerNames: (sandboxName: string) => string[]; dockerInspectStatus: (containerName: string) => string; dockerStart: (containerName: string) => string; } @@ -45,8 +46,8 @@ const defaultStartDeps: StartDeps = { } }, listSandboxNames: () => registry.listSandboxes().sandboxes.map((entry) => entry.name), - dockerPsAllNames: () => - dockerCapture(["ps", "-a", "--format", "{{.Names}}"], { ignoreError: true }), + listLabeledContainerNames: (sandboxName) => + findLabeledSandboxContainers(sandboxName).map((container) => container.name), dockerInspectStatus: (containerName) => dockerContainerInspectFormat("{{.State.Status}}", containerName, { ignoreError: true }), // `docker start` echoes the container name on success and prints nothing to @@ -60,12 +61,20 @@ export function startStoppedSandboxContainerForBackup( ): StartedForBackup | null { const deps: StartDeps = { ...defaultStartDeps, ...depsOverride }; if (deps.getSandboxDriver(sandboxName) !== "docker") return null; + const labeledContainerNames = deps.listLabeledContainerNames(sandboxName); + // Lifecycle mutation must fail closed on missing or ambiguous ownership. + // Name matching alone is insufficient because starting a container executes + // its entrypoint; label discovery establishes the OpenShell owner first. + if (labeledContainerNames.length !== 1) return null; const containerName = resolveSandboxContainerOwner( - deps.dockerPsAllNames(), + labeledContainerNames[0] ?? "", sandboxName, deps.listSandboxNames(), ); if (!containerName) return null; + // GPU recovery siblings must be renamed through the dedicated recovery flow + // before they are startable as the sandbox's active container. + if (/-nemoclaw-gpu-backup-\d+$/.test(containerName)) return null; const status = deps.dockerInspectStatus(containerName).trim().toLowerCase(); if (status !== "exited" && status !== "created") return null; if (deps.dockerStart(containerName).trim() === "") return null; @@ -74,10 +83,13 @@ export function startStoppedSandboxContainerForBackup( interface StopDeps { dockerStop: (containerName: string) => string; + dockerInspectStatus: (containerName: string) => string; } const defaultStopDeps: StopDeps = { dockerStop: (containerName) => dockerCapture(["stop", containerName], { ignoreError: true }), + dockerInspectStatus: (containerName) => + dockerContainerInspectFormat("{{.State.Status}}", containerName, { ignoreError: true }), }; /** Return a container started by {@link startStoppedSandboxContainerForBackup} @@ -87,7 +99,8 @@ export function returnSandboxContainerToStopped( depsOverride: Partial = {}, ): boolean { const deps: StopDeps = { ...defaultStopDeps, ...depsOverride }; - return deps.dockerStop(containerName).trim() !== ""; + if (deps.dockerStop(containerName).trim() === "") return false; + return deps.dockerInspectStatus(containerName).trim().toLowerCase() === "exited"; } interface BackupRetryDeps { diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index 0452a31beef..08021cb1f5c 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -104,6 +104,19 @@ function firstSnapshotTimestamp(listOutput: string): string { return match[0]; } +function snapshotManifestDirectories(): string[] { + if (!fs.existsSync(BACKUP_DIR)) return []; + return fs + .readdirSync(BACKUP_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(BACKUP_DIR, entry.name, "rebuild-manifest.json")), + ) + .map((entry) => entry.name) + .sort(); +} + test("snapshot commands preserve create/list/latest restore/targeted restore/no-leak lifecycle", { timeout: LIVE_TIMEOUT_MS, }, async ({ artifacts, cleanup, host, sandbox, skip }) => { @@ -121,6 +134,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- "timestamp-targeted restore recovers the first snapshot state", "snapshot directory excludes credential-bearing env/json files", "snapshot help advertises create/list/restore", + "strict backup-all starts a stopped Docker sandbox, creates a snapshot, and returns it to exited state", ], }); @@ -352,10 +366,80 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- expect(resultText(help)).toContain("snapshot list"); expect(resultText(help)).toContain("snapshot restore"); + const snapshotsBeforeStoppedBackup = snapshotManifestDirectories(); + const containerLookup = await host.command( + "docker", + [ + "ps", + "-aq", + "--filter", + "label=openshell.ai/managed-by=openshell", + "--filter", + `label=openshell.ai/sandbox-name=${SANDBOX_NAME}`, + ], + { + artifactName: "phase-10-stopped-backup-container-lookup", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(containerLookup.exitCode, resultText(containerLookup)).toBe(0); + const containerIds = containerLookup.stdout.split(/\r?\n/).filter(Boolean); + expect(containerIds).toHaveLength(1); + const containerId = containerIds[0] as string; + + const stop = await host.command("docker", ["stop", containerId], { + artifactName: "phase-10-stop-sandbox-container", + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(stop.exitCode, resultText(stop)).toBe(0); + + const strictBackup = await host.command("nemoclaw", ["backup-all"], { + artifactName: "phase-10-strict-backup-all-stopped", + env: { ...commandEnv(), NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS: "1" }, + timeoutMs: 180_000, + }); + expect(strictBackup.exitCode, resultText(strictBackup)).toBe(0); + expect(resultText(strictBackup)).toContain(`Starting stopped sandbox '${SANDBOX_NAME}'`); + expect(resultText(strictBackup)).toContain(`Returned '${SANDBOX_NAME}' to its stopped state`); + expect(resultText(strictBackup)).toContain("1 backed up, 0 failed, 0 skipped"); + + const finalContainerState = await host.command( + "docker", + ["inspect", "--format", "{{.State.Status}}", containerId], + { + artifactName: "phase-10-final-container-state", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(finalContainerState.exitCode, resultText(finalContainerState)).toBe(0); + expect(finalContainerState.stdout.trim()).toBe("exited"); + + const snapshotsAfterStoppedBackup = snapshotManifestDirectories(); + const stoppedBackupSnapshots = snapshotsAfterStoppedBackup.filter( + (entry) => !snapshotsBeforeStoppedBackup.includes(entry), + ); + expect(stoppedBackupSnapshots).toHaveLength(1); + const stoppedBackupTimestamp = stoppedBackupSnapshots[0] as string; + const stoppedBackupManifest = JSON.parse( + fs.readFileSync(path.join(BACKUP_DIR, stoppedBackupTimestamp, "rebuild-manifest.json"), "utf8"), + ) as { sandboxName?: unknown; backedUpDirs?: unknown }; + expect(stoppedBackupManifest.sandboxName).toBe(SANDBOX_NAME); + expect(stoppedBackupManifest.backedUpDirs).toEqual(expect.arrayContaining(["workspace"])); + expect(scanSnapshotCredentialLeaks(BACKUP_DIR)).toEqual([]); + await artifacts.writeJson("phase-10-stopped-backup-proof.json", { + containerId, + finalContainerState: finalContainerState.stdout.trim(), + stoppedBackupTimestamp, + }); + await artifacts.target.complete({ id: "snapshot-commands", status: "passed", firstSnapshotTimestamp: timestamp, + stoppedBackupTimestamp, backupDir: BACKUP_DIR, }); }); From 70e558b52fee2ebadc496cec20eacd406ed89efe Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 12 Jul 2026 20:53:37 -0700 Subject: [PATCH 3/5] test(e2e): keep stopped backup proof linear Signed-off-by: Prekshi Vyas --- test/e2e/live/snapshot-commands.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index 08021cb1f5c..692d8893536 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -105,7 +105,6 @@ function firstSnapshotTimestamp(listOutput: string): string { } function snapshotManifestDirectories(): string[] { - if (!fs.existsSync(BACKUP_DIR)) return []; return fs .readdirSync(BACKUP_DIR, { withFileTypes: true }) .filter( From 6010e93b86ad32dc05ac49d1607a639aff339df4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 12 Jul 2026 21:01:36 -0700 Subject: [PATCH 4/5] test(e2e): prove stopped backup restore --- test/e2e/live/snapshot-commands.test.ts | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index 692d8893536..3007a696412 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -427,6 +427,55 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- ) as { sandboxName?: unknown; backedUpDirs?: unknown }; expect(stoppedBackupManifest.sandboxName).toBe(SANDBOX_NAME); expect(stoppedBackupManifest.backedUpDirs).toEqual(expect.arrayContaining(["workspace"])); + + const restart = await host.command("docker", ["start", containerId], { + artifactName: "phase-10-restart-for-stopped-snapshot-restore", + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(restart.exitCode, resultText(restart)).toBe(0); + const waitForExec = await host.command( + "bash", + [ + "-lc", + 'name="$1"; for _i in $(seq 1 30); do openshell sandbox exec --name "$name" -- true >/dev/null 2>&1 && exit 0; sleep 2; done; openshell sandbox exec --name "$name" -- true', + "wait-for-sandbox-exec", + SANDBOX_NAME, + ], + { + artifactName: "phase-10-wait-for-restarted-sandbox-exec", + env: commandEnv(), + timeoutMs: 90_000, + }, + ); + expect(waitForExec.exitCode, resultText(waitForExec)).toBe(0); + const perturbAfterStoppedBackup = await sandbox.exec( + SANDBOX_NAME, + ["sh", "-lc", `printf '%s' 'BROKEN_AFTER_STOPPED_BACKUP' > ${MARKER_FILE}`], + { + artifactName: "phase-10-perturb-after-stopped-backup", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(perturbAfterStoppedBackup.exitCode, resultText(perturbAfterStoppedBackup)).toBe(0); + const restoreStoppedBackup = await host.command( + "nemoclaw", + [SANDBOX_NAME, "snapshot", "restore", stoppedBackupTimestamp], + { + artifactName: "phase-10-restore-stopped-backup", + env: commandEnv(), + timeoutMs: 120_000, + }, + ); + expect(restoreStoppedBackup.exitCode, resultText(restoreStoppedBackup)).toBe(0); + expect(resultText(restoreStoppedBackup)).toContain("Restored"); + await expectSandboxFileContent( + sandbox, + MARKER_FILE, + markerContent, + "phase-10-read-marker-after-stopped-backup-restore", + ); expect(scanSnapshotCredentialLeaks(BACKUP_DIR)).toEqual([]); await artifacts.writeJson("phase-10-stopped-backup-proof.json", { containerId, From 9a82e0e463b9f422bf191af7dbc37ffc9c9fbd1a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 12 Jul 2026 21:03:27 -0700 Subject: [PATCH 5/5] docs(backup): clarify stopped sandbox coverage --- docs/manage-sandboxes/backup-restore.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index fac57e2c60e..dfc0d895994 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -258,9 +258,9 @@ Restore safe Deep Agents files only, then run the managed rebuild or onboarding -## Back Up All Running Sandboxes +## Back Up Registered Sandboxes -To back up every registered, running sandbox in one step, run `$$nemoclaw backup-all`. +To back up every registered sandbox that is running, or whose stopped Docker-driver container can be started temporarily, run `$$nemoclaw backup-all`. Use this host-installed command before broad maintenance such as `$$nemoclaw update`, `$$nemoclaw upgrade-sandboxes`, or an OpenShell gateway migration. ```bash