From ceddddca4b6f9e82c9842400e0537f77c285291b Mon Sep 17 00:00:00 2001 From: latenighthackathon Date: Thu, 11 Jun 2026 16:43:53 +0000 Subject: [PATCH] fix(sandbox): preserve custom policy presets across snapshot restore snapshot create/restore reconciled built-in policy presets (policyPresets) but dropped custom presets applied via `policy --from-file`/`--from-dir`. Custom presets live only in the gateway policy engine, not on the sandbox filesystem, so restore (including `--to `) silently lost them: the restored sandbox kept its built-in presets but none of the custom ones. Capture customPolicies (each CustomPolicyEntry carries full content) into the rebuild manifest and reconcile them on restore the same way policyPresets are: re-apply by content via applyPresetContent (which re-records them in the target registry) and remove any custom presets the snapshot did not record. Reconcile by content, not only by name: a same-name preset whose body or source path changed is re-applied. Always persist customPolicies in the manifest (even an empty array) so restore can tell a zero-custom snapshot (reconcile, removing stale custom presets on the target) from a legacy snapshot that predates the field (skip reconcile). Closes #5103 Signed-off-by: latenighthackathon --- src/lib/actions/sandbox/snapshot.ts | 53 +++++++++++++++++++++++++++++ src/lib/state/sandbox.ts | 35 +++++++++++++++++-- test/snapshot.test.ts | 42 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 47e2398de41..a00813212c4 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -620,6 +620,59 @@ export async function runSandboxSnapshot( } } } + // Reconcile custom policy presets (applied via --from-file/--from-dir). + // Their full content travels in the manifest, so re-apply by content + // (which also re-records them in the registry). Diff by content + source, + // not just name: a same-name preset whose body changed must be re-applied. + // Full replacement, mirroring the built-in preset reconcile above; skipped + // for legacy snapshots that predate the `customPolicies` field. + if (resolvedSnapshot && Array.isArray(resolvedSnapshot.customPolicies)) { + const snapshotCustom = resolvedSnapshot.customPolicies; + const currentCustom = registry.getCustomPolicies(targetSandbox); + const snapshotByName = new Map(snapshotCustom.map((entry) => [entry.name, entry])); + const currentByName = new Map(currentCustom.map((entry) => [entry.name, entry])); + const toRemove = currentCustom.filter((c) => !snapshotByName.has(c.name)); + const toAdd = snapshotCustom.filter((sp) => { + const current = currentByName.get(sp.name); + return !current || current.content !== sp.content || current.sourcePath !== sp.sourcePath; + }); + + if (toRemove.length > 0 || toAdd.length > 0) { + const summary: string[] = []; + if (toAdd.length > 0) summary.push(`add ${toAdd.map((c) => c.name).join(", ")}`); + if (toRemove.length > 0) summary.push(`remove ${toRemove.map((c) => c.name).join(", ")}`); + console.log(` Reconciling custom policies on '${targetSandbox}': ${summary.join("; ")}`); + + const failed: string[] = []; + for (const entry of toRemove) { + try { + if (!policies.removePreset(targetSandbox, entry.name)) { + failed.push(`${entry.name} (remove failed)`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failed.push(`${entry.name} (remove: ${message})`); + } + } + for (const entry of toAdd) { + try { + if ( + !policies.applyPresetContent(targetSandbox, entry.name, entry.content, { + custom: { sourcePath: entry.sourcePath }, + }) + ) { + failed.push(`${entry.name} (apply failed)`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failed.push(`${entry.name} (apply: ${message})`); + } + } + if (failed.length > 0) { + console.warn(` Warning: could not reconcile custom policy(ies): ${failed.join("; ")}`); + } + } + } break; } default: diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 55442b29de5..7d4133806f3 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -31,12 +31,13 @@ import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isRecord, type UnknownRecord } from "../core/json-types.js"; +import { shellQuote } from "../runner.js"; +import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; import { buildOpenClawConfigRestoreInputFromSandbox, shouldMergeOpenClawConfigStateFile, } from "./openclaw-config-restore-input.js"; -import { shellQuote } from "../runner.js"; -import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; +import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { runTarListing } from "./tar-listing.js"; @@ -69,6 +70,15 @@ export interface RebuildManifest { backupPath: string; blueprintDigest: string | null; policyPresets?: string[]; + /** + * Custom policy presets applied via `--from-file`/`--from-dir`, captured with + * full content so they can be re-applied on restore without the source file. + * Like `policyPresets`, these live in the gateway policy engine and are + * otherwise lost on destroy/recreate. Always present on snapshots created since + * this field was added (possibly an empty array, so restore can reconcile a + * zero-custom snapshot); absent only on legacy manifests. + */ + customPolicies?: CustomPolicyEntry[]; instances?: InstanceBackup[]; // Optional user-provided label for `snapshot restore `. name?: string; @@ -154,6 +164,19 @@ function isInstanceBackup(value: unknown): value is InstanceBackup { ); } +function isCustomPolicyEntryArray(value: unknown): value is CustomPolicyEntry[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + typeof entry === "object" && + entry !== null && + typeof (entry as { name?: unknown }).name === "string" && + typeof (entry as { content?: unknown }).content === "string", + ) + ); +} + function isRebuildManifest(value: unknown): value is RebuildManifest { if (!isRecord(value) || !isStateDirArray(value.stateDirs)) return false; return ( @@ -172,6 +195,7 @@ function isRebuildManifest(value: unknown): value is RebuildManifest { value.blueprintDigest === null || typeof value.blueprintDigest === "string") && (value.policyPresets === undefined || isStringArray(value.policyPresets)) && + (value.customPolicies === undefined || isCustomPolicyEntryArray(value.customPolicies)) && (value.instances === undefined || (Array.isArray(value.instances) && value.instances.every((entry) => isInstanceBackup(entry)))) && @@ -1037,6 +1061,12 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = // not on the sandbox filesystem, so they are lost on destroy/recreate. const policyPresets: string[] = sb?.policies && sb.policies.length > 0 ? [...sb.policies] : []; _log(`policyPresets from registry: [${policyPresets.join(",")}]`); + // Custom presets (--from-file/--from-dir) also live only in the gateway policy + // engine, so capture their full content for replay. Always record the field + // (even empty) so restore can tell a zero-custom snapshot (reconcile, remove + // any stale custom presets on the target) from a legacy snapshot (skip). + const customPolicies: CustomPolicyEntry[] = sb?.customPolicies ? [...sb.customPolicies] : []; + _log(`customPolicies from registry: [${customPolicies.map((c) => c.name).join(",")}]`); const manifest: RebuildManifest = { version: MANIFEST_VERSION, @@ -1051,6 +1081,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = backupPath, blueprintDigest: computeBlueprintDigest(), policyPresets, + customPolicies, ...(providedName !== null ? { name: providedName } : {}), }; diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 64a0d93e350..64f31eb0385 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -10,6 +10,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; + // Override HOME BEFORE importing sandbox-state — it reads process.env.HOME // at module-load time to compute REBUILD_BACKUPS_DIR. Captured original is // restored in afterAll so sibling tests running in the same worker don't @@ -159,6 +160,47 @@ describe("listBackups computes virtual versions", () => { expect(entry.name).toBe("before-upgrade"); expect(entry.snapshotVersion).toBe(1); }); + + it("surfaces customPolicies (name + content + sourcePath) through the manifest round-trip", () => { + const custom = [ + { + name: "my-custom", + content: "version: 1\n\nnetwork_policies: {}\n", + sourcePath: "/host/policy.yaml", + }, + ]; + writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { customPolicies: custom }); + const [entry] = sandboxState.listBackups("test-sandbox"); + expect(entry.customPolicies).toEqual(custom); + }); + + it("preserves an empty customPolicies array so restore can distinguish zero-custom from legacy snapshots", () => { + writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { customPolicies: [] }); + const [entry] = sandboxState.listBackups("test-sandbox"); + expect(entry.customPolicies).toEqual([]); + }); + + it("ignores rebuild manifests with malformed customPolicies (entry missing content)", () => { + const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T14-02-00-000Z"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "test-sandbox", + timestamp: "2026-04-21T14-02-00-000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + dir: "/sandbox/.openclaw", + backupPath: dir, + blueprintDigest: null, + customPolicies: [{ name: "no-content" }], + }), + ); + expect(sandboxState.listBackups("test-sandbox")).toEqual([]); + }); it("preserves legacy manifests created before blueprintDigest existed", () => { const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T13-59-00-000Z"); fs.mkdirSync(dir, { recursive: true });