Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -116,6 +116,19 @@ $$nemoclaw my-assistant snapshot restore before-upgrade
$$nemoclaw my-assistant snapshot restore 2026-04-14T
```

<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.
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 @@ -2993,6 +2993,19 @@ It preserves directories that exist only in the target manifest or whose backup
The state replacement, mutable-config permission repair, and policy reconciliation run under the same per-sandbox transition.
An expired auto-restore timer can interrupt that work and restore lockdown.

<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
18 changes: 15 additions & 3 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 @@
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");

Check failure on line 147 in src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts

View workflow job for this annotation

GitHub Actions / cli-test-shards (1)

[cli] src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts > runSandboxSnapshot restore: lifecycle and destination safety > keeps active-timer restore, permission repair, and policy reconciliation serialized

AssertionError: expected "vi.fn()" to be called with arguments: [ 'alpha', 'github' ] Received: 1st vi.fn() call: [ "alpha", "github", + { + "nonFatal": true, + }, ] Number of calls: 1 ❯ src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts:147:31
});

it("hardens an active timer window before force-deleting a restore destination", async () => {
Expand Down Expand Up @@ -732,8 +732,12 @@
});

describe("runSandboxSnapshot restore: gateway pairing on a freshly created destination", () => {
it("provokes and approves device pairing after a cross-sandbox restore", async () => {
it("continues to gateway pairing after a nonfatal policy failure", async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-pairing-"));
tempHomes.push(tempHome);
vi.stubEnv("HOME", tempHome);
vi.spyOn(console, "log").mockImplementation(() => {});
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
f.getSandboxMock.mockImplementation((name) =>
name === "alpha"
? {
Expand All @@ -753,7 +757,11 @@
"sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
}),
);
f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture });
f.getLatestBackupMock.mockReturnValue({
...f.latestBackupFixture,
policyPresets: ["github"],
});
f.applyPresetMock.mockReturnValue(false);
f.restoreSandboxStateMock.mockReturnValue({
success: true,
restoredDirs: ["workspace"],
Expand All @@ -763,9 +771,13 @@
});
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");
expect(f.applyPresetMock).toHaveBeenCalledWith("beta", "github", { nonFatal: true });
expect(consoleWarn.mock.calls.flat().join("\n")).toContain("github (apply failed)");
expect(f.establishRestoredSandboxGatewayPairingMock).toHaveBeenCalledWith("beta");
});

Expand Down
73 changes: 35 additions & 38 deletions src/lib/actions/sandbox/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ describe("runSandboxSnapshot", () => {
expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot");
expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha");
expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha");
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github");
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github", { nonFatal: true });
});

it("hardens an active timer window before force-deleting a restore destination", async () => {
Expand Down Expand Up @@ -1011,7 +1011,9 @@ describe("runSandboxSnapshot", () => {
getAppliedPresetsMock.mockReturnValue(["npm"]);
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore" });
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(removePresetMock).not.toHaveBeenCalled();
});

Expand All @@ -1023,8 +1025,7 @@ describe("runSandboxSnapshot", () => {
policyTier: "balanced",
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
...latestBackupFixture,
policyPresets: ["npm", "observability-otlp-local"],
});
getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]);
Expand All @@ -1033,7 +1034,9 @@ describe("runSandboxSnapshot", () => {

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

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

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

Expand Down Expand Up @@ -1084,11 +1089,7 @@ describe("runSandboxSnapshot", () => {
policyTier: "balanced",
policies: [],
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
policyPresets: [],
});
getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] });
getAppliedPresetsMock.mockReturnValue([]);
getPresetContentGatewayStateMock.mockReturnValue("match");
configureRemoval();
Expand All @@ -1097,7 +1098,9 @@ describe("runSandboxSnapshot", () => {

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

expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2);
expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(updateSandboxMock).toHaveBeenCalledWith("alpha", {
policies: ["observability-otlp-local"],
});
Expand All @@ -1118,11 +1121,7 @@ describe("runSandboxSnapshot", () => {
updateSandboxMock.mockImplementation((_sandboxName, update) => {
registryEntry = { ...registryEntry, ...(update as Partial<typeof registryEntry>) };
});
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
policyPresets: [],
});
getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] });
getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]);
getPresetContentGatewayStateMock.mockReturnValue("match");
removePresetMock
Expand Down Expand Up @@ -1179,8 +1178,7 @@ describe("runSandboxSnapshot", () => {
policies: recordedPolicies,
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
...latestBackupFixture,
policyPresets: ["npm"],
});
getAppliedPresetsMock.mockReturnValue(recordedPolicies);
Expand All @@ -1207,8 +1205,7 @@ describe("runSandboxSnapshot", () => {
policyTier: "balanced",
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
...latestBackupFixture,
policyPresets: [customPolicy.name],
customPolicies: [customPolicy],
});
Expand All @@ -1223,10 +1220,12 @@ describe("runSandboxSnapshot", () => {
"alpha",
customPolicy.name,
customPolicy.content,
{ custom: { sourcePath: customPolicy.sourcePath } },
{ custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true },
);
expect(removePresetMock).toHaveBeenCalledTimes(1);
expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name);
expect(updateSandboxMock).not.toHaveBeenCalled();
});
Expand All @@ -1246,8 +1245,7 @@ describe("runSandboxSnapshot", () => {
policies: ["npm", "observability-otlp-local"],
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
...latestBackupFixture,
policyPresets: ["npm", "observability-otlp-local"],
customPolicies: [customPolicy],
});
Expand All @@ -1263,7 +1261,7 @@ describe("runSandboxSnapshot", () => {
"alpha",
customPolicy.name,
customPolicy.content,
{ custom: { sourcePath: customPolicy.sourcePath } },
{ custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true },
);
expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
Expand Down Expand Up @@ -1303,7 +1301,9 @@ describe("runSandboxSnapshot", () => {
await runSandboxSnapshot("alpha", { kind: "restore" });

expect(consoleWarn.mock.calls.flat().join("\n")).toContain("corp-otel (apply failed)");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", {
nonFatal: true,
});
expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2);
expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith(
"alpha",
Expand Down Expand Up @@ -1336,7 +1336,9 @@ describe("runSandboxSnapshot", () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore" });
expect(removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name);
expect(removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name, {
nonFatal: true,
});
expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local");
expect(consoleWarn.mock.calls.flat().join("\n")).toContain(
"leaving live policy presets unchanged",
Expand All @@ -1353,8 +1355,7 @@ describe("runSandboxSnapshot", () => {
policyTier: "balanced",
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
...latestBackupFixture,
policyPresets: ["observability-otlp-local"],
});
getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]);
Expand All @@ -1377,11 +1378,7 @@ describe("runSandboxSnapshot", () => {
observabilityEnabled: true,
policyTier: " Restricted ",
} as never);
getLatestBackupMock.mockReturnValue({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
policyPresets: [],
});
getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] });
const { runSandboxSnapshot } = await import("./snapshot");

await runSandboxSnapshot("alpha", { kind: "restore" });
Expand Down Expand Up @@ -1461,9 +1458,9 @@ describe("runSandboxSnapshot", () => {
await runSandboxSnapshot("alpha", { kind: "restore" });

expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-preset");
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-custom");
expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-preset", { nonFatal: true });
expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github", { nonFatal: true });
expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-custom", { nonFatal: true });
expect(removePresetMock).not.toHaveBeenCalledWith("alpha", "team-egress");
expect(applyPresetContentMock).not.toHaveBeenCalled();
const output = consoleLog.mock.calls.flat().join("\n");
Expand Down
16 changes: 12 additions & 4 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,7 @@ function reconcileSnapshotPolicyPresets(
targetSandbox,
builtinObservabilityContent,
policies,
{ knownBefore: builtinObservabilityState },
{ knownBefore: builtinObservabilityState, removeOptions: { nonFatal: true } },
);
builtinObservabilityState = removal.after;
if (removal.verifiedAbsent) {
Expand All @@ -893,15 +893,21 @@ function reconcileSnapshotPolicyPresets(
continue;
}
try {
if (!policies.removePreset(targetSandbox, preset)) failed.push(`${preset} (remove failed)`);
// Post-restore policy reconciliation is best-effort by design: a failed
// gateway policy mutation must be reported as a warning, not terminate
// the restore before gateway pairing. Pass nonFatal so setPolicyFile
// returns false on failure instead of exiting the process (#8210).
if (!policies.removePreset(targetSandbox, preset, { nonFatal: true }))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
failed.push(`${preset} (remove failed)`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failed.push(`${preset} (remove: ${message})`);
}
}
for (const preset of toAdd) {
try {
if (!policies.applyPreset(targetSandbox, preset)) failed.push(`${preset} (apply failed)`);
if (!policies.applyPreset(targetSandbox, preset, { nonFatal: true }))
failed.push(`${preset} (apply failed)`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failed.push(`${preset} (apply: ${message})`);
Expand Down Expand Up @@ -936,7 +942,8 @@ function reconcileSnapshotCustomPolicies(
const failed: string[] = [];
for (const entry of toRemove) {
try {
if (!policies.removePreset(targetSandbox, entry.name)) {
// Best-effort like the built-in preset reconciliation above (#8210).
if (!policies.removePreset(targetSandbox, entry.name, { nonFatal: true })) {
failed.push(`${entry.name} (remove failed)`);
}
} catch (err) {
Expand All @@ -949,6 +956,7 @@ function reconcileSnapshotCustomPolicies(
if (
!policies.applyPresetContent(targetSandbox, entry.name, entry.content, {
custom: { sourcePath: entry.sourcePath },
nonFatal: true,
})
) {
failed.push(`${entry.name} (apply failed)`);
Expand Down
12 changes: 12 additions & 0 deletions test/policy-roundtrip-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const ROUND_TRIP_DOCS = [
"docs/reference/cli-selection-guide.mdx",
"docs/reference/network-policies.mdx",
];
const SNAPSHOT_RESTORE_DOCS = [
"docs/manage-sandboxes/backup-restore.mdx",
"docs/reference/commands.mdx",
];

function readDoc(docPath: string): string {
return readFileSync(path.join(process.cwd(), docPath), "utf8");
Expand Down Expand Up @@ -59,4 +63,12 @@ describe("policy round-trip documentation examples", () => {
expect(commands).toContain("$$nemoclaw my-assistant policy get --raw");
expect(commands).toContain("Do not pass `--raw` output to `openshell policy set`");
});

it("defines the matching policy states after a restore warning", () => {
for (const docPath of SNAPSHOT_RESTORE_DOCS) {
expect(readDoc(docPath), docPath).toContain(
"recorded in the sandbox registry and active on the gateway, or absent from both",
);
}
});
});
Loading