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
53 changes: 53 additions & 0 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 33 additions & 2 deletions src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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>`.
name?: string;
Expand Down Expand Up @@ -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 (
Expand All @@ -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)))) &&
Expand Down Expand Up @@ -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,
Expand All @@ -1051,6 +1081,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
backupPath,
blueprintDigest: computeBlueprintDigest(),
policyPresets,
customPolicies,
...(providedName !== null ? { name: providedName } : {}),
};

Expand Down
42 changes: 42 additions & 0 deletions test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
Loading