33
44import { isDeepStrictEqual } from "node:util" ;
55
6+ import { dockerSpawnSync } from "../../../adapters/docker/exec" ;
67import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/contract" ;
78import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../../onboard/runtime-provider/current" ;
89import {
@@ -12,6 +13,10 @@ import {
1213import { requireRuntimeProviderBundleForSandbox } from "../../../onboard/runtime-provider/registry" ;
1314import type { SandboxEntry } from "../../../state/registry/types" ;
1415import * as sandboxState from "../../../state/sandbox" ;
16+ import {
17+ privilegedSandboxExecArgv ,
18+ withPrivilegedSandboxExecutionLease ,
19+ } from "../../../sandbox/privileged-exec" ;
1520import { readManagedSnapshotProfileAuthority } from "./managed-profile" ;
1621import { 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
36141const 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
47153function failure ( error : unknown ) : sandboxState . BackupResult {
@@ -59,9 +165,9 @@ function failure(error: unknown): sandboxState.BackupResult {
59165function 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