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", + ); + } + }); });