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
13 changes: 13 additions & 0 deletions docs/manage-sandboxes/backup-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ $$nemoclaw my-assistant snapshot restore before-upgrade
$$nemoclaw my-assistant snapshot restore 2026-04-14T09-40-09-760Z
```

<Warning>
Post-restore policy reconciliation is best-effort.
NemoClaw warns and continues the remaining restore steps in these cases:

- NemoClaw cannot verify whether a custom policy owns the live `observability-otlp-local` policy entry.
- The built-in `observability-otlp-local` policy preset has drifted or cannot be inspected.
- NemoClaw cannot add or remove a recorded policy preset.

The live network policy can then retain unwanted egress or omit expected egress until you repair the named preset.
After a warning, run `$$nemoclaw <name> policy list`.
Confirm that the named preset is recorded in the sandbox registry and active on the gateway, or absent from both.
</Warning>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<AgentOnly variant="hermes">
A running Hermes gateway keeps serving its pre-restore state databases until it reopens them.
After a restore that includes Hermes state databases, the CLI prints a reminder to restart the gateway.
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3101,6 +3101,19 @@ If the containment commit fails, NemoClaw retains any exact lifecycle and deadli
A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition.
Correct the reported state-directory write failure, then run `$$nemoclaw <name> shields status` to resume recovery or receive exact-generation recovery guidance.

<Warning>
Post-restore policy reconciliation is best-effort.
NemoClaw warns and continues the remaining restore steps in these cases:

- NemoClaw cannot verify whether a custom policy owns the live `observability-otlp-local` policy entry.
- The built-in `observability-otlp-local` policy preset has drifted or cannot be inspected.
- NemoClaw cannot add or remove a recorded policy preset.

The live network policy can then retain unwanted egress or omit expected egress until you repair the named preset.
After a warning, run `$$nemoclaw <name> policy list`.
Confirm that the named preset is recorded in the sandbox registry and active on the gateway, or absent from both.
</Warning>

The selector accepts any of:

- A version (`v1`, `v2`, ..., `vN`) from `snapshot list`.
Expand Down
118 changes: 104 additions & 14 deletions src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => {
expect(f.lifecycleMock.events).toContain("lock:restore sandbox snapshot");
expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha");
expect(f.shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha");
expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "github");
expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "github", { nonFatal: true });
});

it("hardens an active timer window before force-deleting a restore destination", async () => {
Expand Down Expand Up @@ -732,19 +732,101 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => {
});

describe("runSandboxSnapshot restore: gateway pairing on a freshly created destination", () => {
it("provokes and approves device pairing after a cross-sandbox restore", async () => {
const removedCustomPolicy = {
name: "legacy-custom",
content: "network_policies:\n legacy-custom: {}\n",
sourcePath: "/policies/legacy-custom.yaml",
};
const appliedCustomPolicy = {
name: "new-custom",
content: "network_policies:\n new-custom: {}\n",
sourcePath: "/policies/new-custom.yaml",
};

it.each([
{
label: "built-in preset application",
snapshot: { ...f.latestBackupFixture, policyPresets: ["github"] },
configureFailure: () => f.applyPresetMock.mockReturnValue(false),
expectedWarning: "github (apply failed)",
assertMutation: () =>
expect(f.applyPresetMock).toHaveBeenCalledWith("beta", "github", { nonFatal: true }),
},
{
label: "built-in OTLP removal",
snapshot: { ...f.latestBackupFixture, policyPresets: [] },
configureFailure: () => {
f.getPresetContentGatewayStateMock.mockReturnValue("match");
f.removePresetMock.mockReturnValue(false);
},
expectedWarning:
"observability-otlp-local (remove failed; exact content still live after remove)",
assertMutation: () =>
expect(f.removePresetMock).toHaveBeenCalledWith("beta", "observability-otlp-local", {
nonFatal: true,
}),
},
{
label: "custom policy removal",
snapshot: { ...f.latestBackupFixture, policyPresets: [], customPolicies: [] },
configureFailure: () => {
f.getCustomPoliciesMock.mockReturnValue([removedCustomPolicy]);
f.removePresetMock.mockReturnValue(false);
},
expectedWarning: "legacy-custom (remove failed)",
assertMutation: () =>
expect(f.removePresetMock).toHaveBeenCalledWith("beta", removedCustomPolicy.name, {
nonFatal: true,
}),
},
{
label: "custom policy application",
snapshot: {
...f.latestBackupFixture,
policyPresets: [],
customPolicies: [appliedCustomPolicy],
},
configureFailure: () => f.applyPresetContentMock.mockReturnValue(false),
expectedWarning: "new-custom (apply failed)",
assertMutation: () =>
expect(f.applyPresetContentMock).toHaveBeenCalledWith(
"beta",
appliedCustomPolicy.name,
appliedCustomPolicy.content,
{ custom: { sourcePath: appliedCustomPolicy.sourcePath }, nonFatal: true },
),
},
])("warns before gateway pairing and continues after $label failure (#8210)", async ({
snapshot,
configureFailure,
expectedWarning,
assertMutation,
}) => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-pairing-"));
tempHomes.push(tempHome);
vi.stubEnv("HOME", tempHome);
vi.spyOn(console, "log").mockImplementation(() => {});
const events: string[] = [];
const consoleWarn = vi.spyOn(console, "warn").mockImplementation((...args) => {
events.push(`warn:${args.join(" ")}`);
});
f.establishRestoredSandboxGatewayPairingMock.mockImplementation(() => {
events.push("pairing");
});
let registeredClone: f.SandboxRecord | null = null;
f.registerSandboxMock.mockImplementation((entry) => {
registeredClone = entry as f.SandboxRecord;
});
const alphaEntry = {
name: "alpha",
agent: "openclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
} as f.SandboxRecord;
f.getSandboxMock.mockImplementation((name) =>
name === "alpha"
? {
name: "alpha",
agent: "openclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
}
: null,
name === "alpha" ? alphaEntry : registeredClone,
);
f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"]));
f.captureOpenshellMock.mockImplementation((args) =>
Expand All @@ -753,7 +835,8 @@ describe("runSandboxSnapshot restore: gateway pairing on a freshly created desti
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
f.getLatestBackupMock.mockReturnValue(snapshot);
configureFailure();
f.restoreSandboxStateMock.mockReturnValue({
success: true,
restoredDirs: ["workspace"],
Expand All @@ -763,9 +846,16 @@ describe("runSandboxSnapshot restore: gateway pairing on a freshly created desti
});
const { runSandboxSnapshot } = await import("./snapshot");

await runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true });
await expect(
runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }),
).resolves.toBeUndefined();

expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha");
assertMutation();
expect(consoleWarn.mock.calls.flat().join("\n")).toContain(expectedWarning);
const warningIndex = events.findIndex((event) => event.includes(expectedWarning));
expect(warningIndex).toBeGreaterThanOrEqual(0);
expect(warningIndex).toBeLessThan(events.indexOf("pairing"));
expect(f.establishRestoredSandboxGatewayPairingMock).toHaveBeenCalledWith("beta");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ describe("runSandboxSnapshot restore: observability policy replay", () => {
f.getAppliedPresetsMock.mockReturnValue(["npm"]);
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore" });
expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(f.removePresetMock).not.toHaveBeenCalled();
});

Expand All @@ -94,7 +96,9 @@ describe("runSandboxSnapshot restore: observability policy replay", () => {

await runSandboxSnapshot("alpha", { kind: "restore" });

expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
});

Expand All @@ -117,7 +121,9 @@ describe("runSandboxSnapshot restore: observability policy replay", () => {
"alpha",
f.builtinObservabilityPolicy,
);
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(f.updateSandboxMock).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ describe("runSandboxSnapshot restore: observability policy reconciliation", () =
"alpha",
customPolicy.name,
customPolicy.content,
{ custom: { sourcePath: customPolicy.sourcePath } },
{ custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true },
);
expect(f.removePresetMock).toHaveBeenCalledTimes(1);
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name);
expect(f.updateSandboxMock).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -165,7 +167,7 @@ describe("runSandboxSnapshot restore: observability policy reconciliation", () =
"alpha",
customPolicy.name,
customPolicy.content,
{ custom: { sourcePath: customPolicy.sourcePath } },
{ custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true },
);
expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
Expand Down Expand Up @@ -205,7 +207,9 @@ describe("runSandboxSnapshot restore: observability policy reconciliation", () =
await runSandboxSnapshot("alpha", { kind: "restore" });

expect(consoleWarn.mock.calls.flat().join("\n")).toContain("corp-otel (apply failed)");
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2);
expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledWith(
"alpha",
Expand Down Expand Up @@ -238,7 +242,9 @@ describe("runSandboxSnapshot restore: observability policy reconciliation", () =
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore" });
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name);
expect(f.removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name, {
nonFatal: true,
});
expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(consoleWarn.mock.calls.flat().join("\n")).toContain(
"leaving live policy presets unchanged",
Expand Down
Loading
Loading