Skip to content

Commit 535b00b

Browse files
committed
fix(snapshot): preserve sealed OpenClaw config
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 5562e36 commit 535b00b

4 files changed

Lines changed: 321 additions & 14 deletions

File tree

src/lib/actions/sandbox/snapshot/backup-authority.test.ts

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,23 @@
33

44
import { createHash } from "node:crypto";
55

6-
import { describe, expect, it, vi } from "vitest";
6+
import { beforeEach, describe, expect, it, vi } from "vitest";
7+
8+
const privilegedCaptureMocks = vi.hoisted(() => ({
9+
dockerSpawnSync: vi.fn(),
10+
privilegedSandboxExecArgv: vi.fn(() => ["exec", "container", "python3"]),
11+
withPrivilegedSandboxExecutionLease: vi.fn(
12+
(_sandboxName: string, _operation: string, run: () => unknown) => run(),
13+
),
14+
}));
15+
16+
vi.mock("../../../adapters/docker/exec", () => ({
17+
dockerSpawnSync: privilegedCaptureMocks.dockerSpawnSync,
18+
}));
19+
vi.mock("../../../sandbox/privileged-exec", () => ({
20+
privilegedSandboxExecArgv: privilegedCaptureMocks.privilegedSandboxExecArgv,
21+
withPrivilegedSandboxExecutionLease: privilegedCaptureMocks.withPrivilegedSandboxExecutionLease,
22+
}));
723

824
import { managedStartupE2eProfile } from "../../../../../scripts/checks/generate-managed-startup-profile-fixture.mts";
925
import {
@@ -18,8 +34,11 @@ import { encodeManagedStartupProfile } from "../../../onboard/managed-startup/pr
1834
import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/contract";
1935
import type { SandboxEntry, SandboxWorkloadReceipt } from "../../../state/registry/types";
2036
import { createSandboxHostLocalInferenceProvenance } from "../../../state/registry/host-local-inference";
21-
import type { BackupOptions, BackupResult } from "../../../state/sandbox";
22-
import { backupSandboxStateWithManagedAuthority } from "./backup-authority";
37+
import type { BackupOptions, BackupResult, StateFileCaptureRequest } from "../../../state/sandbox";
38+
import {
39+
backupSandboxStateWithManagedAuthority,
40+
captureOpenClawStateFile,
41+
} from "./backup-authority";
2342

2443
function workload(
2544
agent: ShippedManagedImageAgent,
@@ -166,6 +185,76 @@ function explicitLlamaSandbox(agent: "openclaw" | "hermes" | "langchain-deepagen
166185
}
167186

168187
describe("managed snapshot backup authority", () => {
188+
beforeEach(() => {
189+
privilegedCaptureMocks.dockerSpawnSync.mockReset();
190+
privilegedCaptureMocks.privilegedSandboxExecArgv.mockClear();
191+
privilegedCaptureMocks.withPrivilegedSandboxExecutionLease.mockClear();
192+
});
193+
194+
it("captures the exact OpenClaw config through bounded privileged execution", () => {
195+
const data = Buffer.from('{"models":{"default":"nvidia/test"}}\n');
196+
privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({
197+
status: 0,
198+
signal: null,
199+
error: undefined,
200+
stdout: data,
201+
stderr: Buffer.alloc(0),
202+
} as never);
203+
204+
const result = captureOpenClawStateFile("alpha", {
205+
sandboxName: "alpha",
206+
dir: "/sandbox/.openclaw",
207+
spec: { path: "openclaw.json", strategy: "copy" },
208+
});
209+
210+
expect(result).toEqual({ outcome: "backed_up", data });
211+
expect(privilegedCaptureMocks.withPrivilegedSandboxExecutionLease).toHaveBeenCalledWith(
212+
"alpha",
213+
"OpenClaw config snapshot capture",
214+
expect.any(Function),
215+
);
216+
expect(privilegedCaptureMocks.privilegedSandboxExecArgv).toHaveBeenCalledWith(
217+
"alpha",
218+
expect.arrayContaining(["/usr/bin/python3", "-I", "-S", "-c"]),
219+
false,
220+
true,
221+
);
222+
expect(privilegedCaptureMocks.dockerSpawnSync).toHaveBeenCalledWith(
223+
["exec", "container", "python3"],
224+
expect.objectContaining({
225+
encoding: null,
226+
timeout: 30_000,
227+
maxBuffer: 17 * 1024 * 1024,
228+
}),
229+
);
230+
});
231+
232+
it("does not grant privileged capture to undeclared paths or strategies", () => {
233+
const requests: StateFileCaptureRequest[] = [
234+
{
235+
sandboxName: "alpha",
236+
dir: "/sandbox/.openclaw",
237+
spec: { path: "credentials/token", strategy: "copy" },
238+
},
239+
{
240+
sandboxName: "alpha",
241+
dir: "/sandbox/.openclaw",
242+
spec: { path: "openclaw.json", strategy: "sqlite_backup" },
243+
},
244+
{
245+
sandboxName: "alpha",
246+
dir: "/sandbox/other",
247+
spec: { path: "openclaw.json", strategy: "copy" },
248+
},
249+
];
250+
251+
for (const request of requests) {
252+
expect(captureOpenClawStateFile("alpha", request)).toBeNull();
253+
}
254+
expect(privilegedCaptureMocks.withPrivilegedSandboxExecutionLease).not.toHaveBeenCalled();
255+
expect(privilegedCaptureMocks.dockerSpawnSync).not.toHaveBeenCalled();
256+
});
257+
169258
it.each(["openclaw", "hermes", "langchain-deepagents-code"] as const)(
170259
"captures and republishes exact %s provider authority",
171260
(agent) => {
@@ -287,7 +376,10 @@ describe("managed snapshot backup authority", () => {
287376
);
288377

289378
expect(result.success).toBe(true);
290-
expect(backup).toHaveBeenCalledWith("alpha", { name: "legacy" });
379+
expect(backup).toHaveBeenCalledWith(
380+
"alpha",
381+
expect.objectContaining({ name: "legacy", captureStateFile: expect.any(Function) }),
382+
);
291383
expect(requireProvider).not.toHaveBeenCalled();
292384
expect(captureRuntime).not.toHaveBeenCalled();
293385
});

src/lib/actions/sandbox/snapshot/backup-authority.ts

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { isDeepStrictEqual } from "node:util";
55

6+
import { dockerSpawnSync } from "../../../adapters/docker/exec";
67
import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/contract";
78
import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../../onboard/runtime-provider/current";
89
import {
@@ -12,6 +13,10 @@ import {
1213
import { requireRuntimeProviderBundleForSandbox } from "../../../onboard/runtime-provider/registry";
1314
import type { SandboxEntry } from "../../../state/registry/types";
1415
import * as sandboxState from "../../../state/sandbox";
16+
import {
17+
privilegedSandboxExecArgv,
18+
withPrivilegedSandboxExecutionLease,
19+
} from "../../../sandbox/privileged-exec";
1520
import { readManagedSnapshotProfileAuthority } from "./managed-profile";
1621
import { captureSandboxRuntimeSnapshot } from "./provider-lifecycle";
1722

@@ -31,6 +36,106 @@ interface SnapshotBackupAuthorityDependencies {
3136
readonly prepareHostLocalInference: typeof prepareSandboxHostLocalInferenceAuthority;
3237
readonly confirmHostLocalInference: typeof confirmHostLocalInferenceAuthority;
3338
readonly backup: typeof sandboxState.backupSandboxState;
39+
readonly captureOpenClawStateFile: typeof captureOpenClawStateFile;
40+
}
41+
42+
const MAX_OPENCLAW_CONFIG_BYTES = 16 * 1024 * 1024;
43+
const OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER = MAX_OPENCLAW_CONFIG_BYTES + 1024 * 1024;
44+
const OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS = 30_000;
45+
const OPENCLAW_CONFIG_CAPTURE_SCRIPT = `import os, stat, sys
46+
maximum = ${MAX_OPENCLAW_CONFIG_BYTES}
47+
directory = "/sandbox/.openclaw"
48+
name = "openclaw.json"
49+
directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
50+
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
51+
try:
52+
directory_fd = os.open(directory, directory_flags)
53+
except OSError:
54+
raise SystemExit(10)
55+
try:
56+
try:
57+
file_fd = os.open(name, file_flags, dir_fd=directory_fd)
58+
except FileNotFoundError:
59+
raise SystemExit(2)
60+
except OSError:
61+
raise SystemExit(10)
62+
try:
63+
before = os.fstat(file_fd)
64+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size > maximum:
65+
raise SystemExit(11)
66+
chunks = []
67+
total = 0
68+
while True:
69+
chunk = os.read(file_fd, min(64 * 1024, maximum + 1 - total))
70+
if not chunk:
71+
break
72+
chunks.append(chunk)
73+
total += len(chunk)
74+
if total > maximum:
75+
raise SystemExit(12)
76+
after = os.fstat(file_fd)
77+
current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
78+
identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns, value.st_nlink)
79+
if identity(before) != identity(after) or identity(before) != identity(current) or not stat.S_ISREG(current.st_mode):
80+
raise SystemExit(13)
81+
sys.stdout.buffer.write(b"".join(chunks))
82+
finally:
83+
os.close(file_fd)
84+
finally:
85+
os.close(directory_fd)
86+
`;
87+
88+
export function captureOpenClawStateFile(
89+
sandboxName: string,
90+
request: sandboxState.StateFileCaptureRequest,
91+
): sandboxState.StateFileCaptureResult | null {
92+
if (
93+
request.dir !== "/sandbox/.openclaw" ||
94+
request.spec.path !== "openclaw.json" ||
95+
request.spec.strategy !== "copy"
96+
) {
97+
return null;
98+
}
99+
try {
100+
return withPrivilegedSandboxExecutionLease(
101+
sandboxName,
102+
"OpenClaw config snapshot capture",
103+
() => {
104+
const argv = privilegedSandboxExecArgv(
105+
sandboxName,
106+
["/usr/bin/python3", "-I", "-S", "-c", OPENCLAW_CONFIG_CAPTURE_SCRIPT],
107+
false,
108+
true,
109+
);
110+
const result = dockerSpawnSync(argv, {
111+
encoding: null,
112+
stdio: ["ignore", "pipe", "pipe"],
113+
timeout: OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS,
114+
maxBuffer: OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER,
115+
});
116+
if (result.status === 2 && result.signal === null && !result.error) {
117+
return { outcome: "missing" };
118+
}
119+
if (
120+
result.status !== 0 ||
121+
result.signal !== null ||
122+
result.error ||
123+
!Buffer.isBuffer(result.stdout)
124+
) {
125+
const detail =
126+
result.error?.message ??
127+
(result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`);
128+
return { outcome: "failed", error: `privileged config capture failed: ${detail}` };
129+
}
130+
return { outcome: "backed_up", data: result.stdout };
131+
},
132+
);
133+
} catch (error) {
134+
return {
135+
outcome: "failed",
136+
error: error instanceof Error ? error.message : String(error),
137+
};
138+
}
34139
}
35140

36141
const defaultDependencies: Omit<SnapshotBackupAuthorityDependencies, "getSandbox"> = {
@@ -42,6 +147,7 @@ const defaultDependencies: Omit<SnapshotBackupAuthorityDependencies, "getSandbox
42147
// Keep the call late-bound so tests and alternative state stores can replace
43148
// the module export without this adapter retaining an import-time reference.
44149
backup: (...args) => sandboxState.backupSandboxState(...args),
150+
captureOpenClawStateFile,
45151
};
46152

47153
function failure(error: unknown): sandboxState.BackupResult {
@@ -59,9 +165,9 @@ function failure(error: unknown): sandboxState.BackupResult {
59165
function backupStateOnly(
60166
dependencies: SnapshotBackupAuthorityDependencies,
61167
sandboxName: string,
62-
options: Pick<sandboxState.BackupOptions, "name">,
168+
options: Pick<sandboxState.BackupOptions, "name" | "captureStateFile">,
63169
): sandboxState.BackupResult {
64-
return options.name === undefined
170+
return options.name === undefined && options.captureStateFile === undefined
65171
? dependencies.backup(sandboxName)
66172
: dependencies.backup(sandboxName, options);
67173
}
@@ -201,13 +307,22 @@ export function backupSandboxStateWithManagedAuthority(
201307
const entry = dependencies.getSandbox(sandboxName);
202308
if (!entry) return backupStateOnly(dependencies, sandboxName, options);
203309

310+
const stateFileOptions: Pick<sandboxState.BackupOptions, "captureStateFile"> =
311+
!entry.agent || entry.agent === "openclaw"
312+
? {
313+
captureStateFile: (request) =>
314+
dependencies.captureOpenClawStateFile(sandboxName, request),
315+
}
316+
: {};
317+
const backupOptions = { ...options, ...stateFileOptions };
318+
204319
let authority: SnapshotBackupAuthority | null;
205320
try {
206321
authority = captureSnapshotAuthority(entry, dependencies);
207322
} catch (error) {
208323
return failure(error);
209324
}
210325
return authority
211-
? dependencies.backup(sandboxName, { ...options, ...authority })
212-
: backupStateOnly(dependencies, sandboxName, options);
326+
? dependencies.backup(sandboxName, { ...backupOptions, ...authority })
327+
: backupStateOnly(dependencies, sandboxName, backupOptions);
213328
}

0 commit comments

Comments
 (0)