diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx
index 20f5bf8711b..2bfc645592d 100644
--- a/docs/manage-sandboxes/backup-restore.mdx
+++ b/docs/manage-sandboxes/backup-restore.mdx
@@ -123,6 +123,19 @@ $$nemoclaw my-assistant snapshot restore before-upgrade
$$nemoclaw my-assistant snapshot restore 2026-04-14T09-40-09-760Z
```
+
+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 policy list`.
+Confirm that the named preset is recorded in the sandbox registry and active on the gateway, or absent from both.
+
+
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.
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index 966af77fcd5..a08173460a8 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -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 shields status` to resume recovery or receive exact-generation recovery guidance.
+
+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 policy list`.
+Confirm that the named preset is recorded in the sandbox registry and active on the gateway, or absent from both.
+
+
The selector accepts any of:
- A version (`v1`, `v2`, ..., `vN`) from `snapshot list`.
diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts
index db2c42cf0ae..cf86576aece 100644
--- a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts
+++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts
@@ -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 () => {
@@ -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) =>
@@ -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"],
@@ -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");
});
diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts
index 08aa8e6005e..3f1c511674e 100644
--- a/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts
+++ b/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts
@@ -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();
});
@@ -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");
});
@@ -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();
});
diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts
index be0e810023e..56171687a42 100644
--- a/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts
+++ b/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts
@@ -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();
});
@@ -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");
@@ -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",
@@ -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",
diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts
index 19cf6428de0..d3a5c298348 100644
--- a/src/lib/actions/sandbox/snapshot.test.ts
+++ b/src/lib/actions/sandbox/snapshot.test.ts
@@ -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 () => {
@@ -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();
});
@@ -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"]);
@@ -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");
});
@@ -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();
});
@@ -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();
@@ -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"],
});
@@ -1118,11 +1121,7 @@ describe("runSandboxSnapshot", () => {
updateSandboxMock.mockImplementation((_sandboxName, update) => {
registryEntry = { ...registryEntry, ...(update as Partial) };
});
- 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
@@ -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);
@@ -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],
});
@@ -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();
});
@@ -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],
});
@@ -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");
@@ -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",
@@ -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",
@@ -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"]);
@@ -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" });
@@ -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");
diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts
index 77595a0ab18..b4c4bec30de 100644
--- a/src/lib/actions/sandbox/snapshot.ts
+++ b/src/lib/actions/sandbox/snapshot.ts
@@ -878,7 +878,7 @@ function reconcileSnapshotPolicyPresets(
targetSandbox,
builtinObservabilityContent,
policies,
- { knownBefore: builtinObservabilityState },
+ { knownBefore: builtinObservabilityState, removeOptions: { nonFatal: true } },
);
builtinObservabilityState = removal.after;
if (removal.verifiedAbsent) {
@@ -893,7 +893,12 @@ 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 }))
+ failed.push(`${preset} (remove failed)`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failed.push(`${preset} (remove: ${message})`);
@@ -901,7 +906,8 @@ function reconcileSnapshotPolicyPresets(
}
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})`);
@@ -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) {
@@ -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)`);
diff --git a/test/policy-roundtrip-docs.test.ts b/test/policy-roundtrip-docs.test.ts
index 45e60b414ef..61ad69b13ed 100644
--- a/test/policy-roundtrip-docs.test.ts
+++ b/test/policy-roundtrip-docs.test.ts
@@ -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");
@@ -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 (#8210)", () => {
+ 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",
+ );
+ }
+ });
});