diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 44b9b95776a..9a4c10cb596 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -84,7 +84,10 @@ describe("rebuildSandbox flow: lifecycle", () => { ).resolves.toBeUndefined(); expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledWith("alpha"); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ captureStateFile: expect.any(Function) }), + ); expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); expect(harness.prepareMcpBridgesForRebuildSpy.mock.invocationCallOrder[0]).toBeLessThan( harness.warnUnpreservedUserManagedFilesSpy.mock.invocationCallOrder[0], diff --git a/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts b/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts new file mode 100644 index 00000000000..903ad029a91 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot/backup-authority-script.test.ts @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { OPENCLAW_CONFIG_CAPTURE_SCRIPT } from "./backup-authority"; + +const CONFIG_NAME = "openclaw.json"; +const MAX_CONFIG_BYTES = 16 * 1024 * 1024; +const PROTOCOL_PREFIX = "nemoclaw-openclaw-config-capture:"; +const fixtureRoots: string[] = []; + +interface CaptureResult { + readonly status: number | null; + readonly stdout: Buffer; + readonly stderr: string; +} + +function fixtureDirectory(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-capture-")); + fixtureRoots.push(root); + const directory = path.join(root, ".openclaw"); + fs.mkdirSync(directory); + return directory; +} + +function runCapture(directory: string, script = OPENCLAW_CONFIG_CAPTURE_SCRIPT): CaptureResult { + const result = spawnSync("/usr/bin/python3", ["-I", "-S", "-c", script, directory, CONFIG_NAME], { + encoding: null, + timeout: 30_000, + maxBuffer: MAX_CONFIG_BYTES + 1024 * 1024, + }); + return { + status: result.status, + stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), + stderr: Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : "", + }; +} + +function mutationHarness(mutation: string): string { + return `import os, sys +capture_script = ${JSON.stringify(OPENCLAW_CONFIG_CAPTURE_SCRIPT)} +directory = sys.argv[1] +name = sys.argv[2] +real_read = os.read +mutated = False +def mutate_after_first_read(fd, size): + global mutated + data = real_read(fd, size) + if not mutated: + mutated = True +${mutation} + return data +os.read = mutate_after_first_read +exec(capture_script) +`; +} + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("OpenClaw privileged config capture script", () => { + it("returns bytes only for a stable regular file", () => { + const directory = fixtureDirectory(); + const expected = Buffer.from('{"models":{"default":"nvidia/test"}}\n'); + fs.writeFileSync(path.join(directory, CONFIG_NAME), expected); + + const result = runCapture(directory); + + expect(result).toEqual({ status: 0, stdout: expected, stderr: "" }); + }); + + it("returns all bytes for a stable file at the 16 MiB limit", () => { + const directory = fixtureDirectory(); + const expected = Buffer.alloc(MAX_CONFIG_BYTES, 0xa5); + fs.writeFileSync(path.join(directory, CONFIG_NAME), expected); + + const result = runCapture(directory); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toHaveLength(expected.length); + expect(result.stdout.equals(expected)).toBe(true); + }); + + it.each([ + { + kind: "symbolic link", + setup(directory: string) { + const target = path.join(path.dirname(directory), "target.json"); + fs.writeFileSync(target, "target"); + fs.symlinkSync(target, path.join(directory, CONFIG_NAME)); + }, + }, + { + kind: "hard link", + setup(directory: string) { + const target = path.join(path.dirname(directory), "target.json"); + fs.writeFileSync(target, "target"); + fs.linkSync(target, path.join(directory, CONFIG_NAME)); + }, + }, + { + kind: "FIFO", + setup(directory: string) { + const result = spawnSync("mkfifo", [path.join(directory, CONFIG_NAME)]); + expect(result.status).toBe(0); + }, + }, + { + kind: "directory", + setup(directory: string) { + fs.mkdirSync(path.join(directory, CONFIG_NAME)); + }, + }, + { + kind: "oversized file", + setup(directory: string) { + const descriptor = fs.openSync(path.join(directory, CONFIG_NAME), "w"); + try { + fs.ftruncateSync(descriptor, MAX_CONFIG_BYTES + 1); + } finally { + fs.closeSync(descriptor); + } + }, + }, + ])("rejects a $kind without returning captured bytes", ({ setup }) => { + const directory = fixtureDirectory(); + setup(directory); + + const result = runCapture(directory); + + expect(result.status).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).toContain(PROTOCOL_PREFIX); + }); + + it("rejects a file replaced during the read without returning captured bytes", () => { + const directory = fixtureDirectory(); + fs.writeFileSync(path.join(directory, CONFIG_NAME), "original"); + const script = mutationHarness( + ` original = os.path.join(directory, name)\n` + + ` os.rename(original, original + ".old")\n` + + ` with open(original, "wb") as replacement:\n` + + ` replacement.write(b"replacement")`, + ); + + const result = runCapture(directory, script); + + expect(result.status).toBe(13); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).toBe(`${PROTOCOL_PREFIX}file-changed-during-read\n`); + }); + + it("rejects a directory replaced during the read without returning captured bytes", () => { + const directory = fixtureDirectory(); + fs.writeFileSync(path.join(directory, CONFIG_NAME), "original"); + const script = mutationHarness( + ` os.rename(directory, directory + ".old")\n` + + ` os.mkdir(directory)\n` + + ` with open(os.path.join(directory, name), "wb") as replacement:\n` + + ` replacement.write(b"replacement")`, + ); + + const result = runCapture(directory, script); + + expect(result.status).toBe(13); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).toBe(`${PROTOCOL_PREFIX}directory-changed-during-read\n`); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot/backup-authority.test.ts b/src/lib/actions/sandbox/snapshot/backup-authority.test.ts index 9f6561eb96c..b6ace737f39 100644 --- a/src/lib/actions/sandbox/snapshot/backup-authority.test.ts +++ b/src/lib/actions/sandbox/snapshot/backup-authority.test.ts @@ -3,7 +3,23 @@ import { createHash } from "node:crypto"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const privilegedCaptureMocks = vi.hoisted(() => ({ + dockerSpawnSync: vi.fn(), + privilegedSandboxExecArgv: vi.fn(() => ["exec", "container", "python3"]), + withPrivilegedSandboxExecutionLease: vi.fn( + (_sandboxName: string, _operation: string, run: () => unknown) => run(), + ), +})); + +vi.mock("../../../adapters/docker/exec", () => ({ + dockerSpawnSync: privilegedCaptureMocks.dockerSpawnSync, +})); +vi.mock("../../../sandbox/privileged-exec", () => ({ + privilegedSandboxExecArgv: privilegedCaptureMocks.privilegedSandboxExecArgv, + withPrivilegedSandboxExecutionLease: privilegedCaptureMocks.withPrivilegedSandboxExecutionLease, +})); import { managedStartupE2eProfile } from "../../../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; import { @@ -19,7 +35,10 @@ import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/co import type { SandboxEntry, SandboxWorkloadReceipt } from "../../../state/registry/types"; import { createSandboxHostLocalInferenceProvenance } from "../../../state/registry/host-local-inference"; import type { BackupOptions, BackupResult } from "../../../state/sandbox"; -import { backupSandboxStateWithManagedAuthority } from "./backup-authority"; +import { + backupSandboxStateWithManagedAuthority, + captureOpenClawStateFile, +} from "./backup-authority"; function workload( agent: ShippedManagedImageAgent, @@ -166,6 +185,168 @@ function explicitLlamaSandbox(agent: "openclaw" | "hermes" | "langchain-deepagen } describe("managed snapshot backup authority", () => { + beforeEach(() => { + privilegedCaptureMocks.dockerSpawnSync.mockReset(); + privilegedCaptureMocks.privilegedSandboxExecArgv.mockClear(); + privilegedCaptureMocks.withPrivilegedSandboxExecutionLease.mockClear(); + }); + + it("captures the exact OpenClaw configuration with bounded privileged execution", () => { + const data = Buffer.from('{"models":{"default":"nvidia/test"}}\n'); + privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({ + status: 0, + signal: null, + error: undefined, + stdout: data, + stderr: Buffer.alloc(0), + } as never); + + const result = captureOpenClawStateFile("alpha", { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + + expect(result).toEqual({ outcome: "backed_up", data }); + expect(privilegedCaptureMocks.withPrivilegedSandboxExecutionLease).toHaveBeenCalledWith( + "alpha", + "OpenClaw config snapshot capture", + expect.any(Function), + ); + expect(privilegedCaptureMocks.privilegedSandboxExecArgv).toHaveBeenCalledWith( + "alpha", + expect.arrayContaining(["/usr/bin/python3", "-I", "-S", "-c"]), + false, + true, + ); + expect(privilegedCaptureMocks.dockerSpawnSync).toHaveBeenCalledWith( + ["exec", "container", "python3"], + expect.objectContaining({ + encoding: null, + timeout: 30_000, + maxBuffer: 17 * 1024 * 1024, + }), + ); + }); + + it("recognizes only the fixed missing-file failure protocol", () => { + privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({ + status: 2, + signal: null, + error: undefined, + stdout: Buffer.alloc(0), + stderr: Buffer.from("nemoclaw-openclaw-config-capture:missing\n"), + } as never); + + const result = captureOpenClawStateFile("alpha", { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + + expect(result).toEqual({ outcome: "missing" }); + }); + + it("returns a fixed failure reason when privileged capture rejects unsafe file metadata", () => { + privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({ + status: 11, + signal: null, + error: undefined, + stdout: Buffer.alloc(0), + stderr: Buffer.from("nemoclaw-openclaw-config-capture:unsafe-file-metadata\n"), + } as never); + + const result = captureOpenClawStateFile("alpha", { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + + expect(result).toEqual({ + outcome: "failed", + error: "privileged config capture failed: exit 11; reason unsafe-file-metadata", + }); + }); + + it("bounds and redacts untrusted privileged stderr", () => { + privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({ + status: 10, + signal: null, + error: undefined, + stdout: Buffer.alloc(0), + stderr: Buffer.from(`permission denied apiKey=secret-value\u0000${"x".repeat(2048)}`), + } as never); + + const result = captureOpenClawStateFile("alpha", { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + + expect(result).toMatchObject({ outcome: "failed" }); + const failedResult = result as Extract< + NonNullable, + { outcome: "failed" } + >; + const error = failedResult.error ?? ""; + expect(error).toContain("permission denied apiKey="); + expect(error).not.toContain("secret-value"); + expect(error).not.toContain("\u0000"); + expect(error.length).toBeLessThan(320); + }); + + it("does not confuse an unrecognized exit 2 with a missing config", () => { + privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({ + status: 2, + signal: null, + error: undefined, + stdout: Buffer.alloc(0), + stderr: Buffer.from("docker exec usage error"), + } as never); + + const result = captureOpenClawStateFile("alpha", { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + + expect(result).toEqual({ + outcome: "failed", + error: "privileged config capture failed: exit 2; docker exec usage error", + }); + }); + + it.each([ + { + input: "an undeclared OpenClaw state file path", + request: { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "credentials/token", strategy: "copy" }, + }, + }, + { + input: "an undeclared OpenClaw state file strategy", + request: { + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "sqlite_backup" }, + }, + }, + { + input: "an undeclared OpenClaw state directory", + request: { + sandboxName: "alpha", + dir: "/sandbox/other", + spec: { path: "openclaw.json", strategy: "copy" }, + }, + }, + ] as const)("rejects $input before privileged capture", ({ request }) => { + expect(captureOpenClawStateFile("alpha", request)).toBeNull(); + expect(privilegedCaptureMocks.withPrivilegedSandboxExecutionLease).not.toHaveBeenCalled(); + expect(privilegedCaptureMocks.dockerSpawnSync).not.toHaveBeenCalled(); + }); + it.each(["openclaw", "hermes", "langchain-deepagents-code"] as const)( "captures and republishes exact %s provider authority", (agent) => { @@ -287,7 +468,10 @@ describe("managed snapshot backup authority", () => { ); expect(result.success).toBe(true); - expect(backup).toHaveBeenCalledWith("alpha", { name: "legacy" }); + expect(backup).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ name: "legacy", captureStateFile: expect.any(Function) }), + ); expect(requireProvider).not.toHaveBeenCalled(); expect(captureRuntime).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/snapshot/backup-authority.ts b/src/lib/actions/sandbox/snapshot/backup-authority.ts index 7ace8e104d3..620e32a7a79 100644 --- a/src/lib/actions/sandbox/snapshot/backup-authority.ts +++ b/src/lib/actions/sandbox/snapshot/backup-authority.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util"; +import { dockerSpawnSync } from "../../../adapters/docker/exec"; import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/contract"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../../onboard/runtime-provider/current"; import { @@ -12,6 +13,11 @@ import { import { requireRuntimeProviderBundleForSandbox } from "../../../onboard/runtime-provider/registry"; import type { SandboxEntry } from "../../../state/registry/types"; import * as sandboxState from "../../../state/sandbox"; +import { + privilegedSandboxExecArgv, + withPrivilegedSandboxExecutionLease, +} from "../../../sandbox/privileged-exec"; +import { sanitizeReadinessText } from "../../../readiness/sanitize"; import { readManagedSnapshotProfileAuthority } from "./managed-profile"; import { captureSandboxRuntimeSnapshot } from "./provider-lifecycle"; @@ -31,6 +37,192 @@ interface SnapshotBackupAuthorityDependencies { readonly prepareHostLocalInference: typeof prepareSandboxHostLocalInferenceAuthority; readonly confirmHostLocalInference: typeof confirmHostLocalInferenceAuthority; readonly backup: typeof sandboxState.backupSandboxState; + readonly captureOpenClawStateFile: typeof captureOpenClawStateFile; +} + +const MAX_OPENCLAW_CONFIG_BYTES = 16 * 1024 * 1024; +const OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER = MAX_OPENCLAW_CONFIG_BYTES + 1024 * 1024; +const OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS = 30_000; +const OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX = "nemoclaw-openclaw-config-capture:"; +const OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES = 128; +const OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES = 1024; +const OPENCLAW_CONFIG_DIRECTORY = "/sandbox/.openclaw"; +const OPENCLAW_CONFIG_NAME = "openclaw.json"; +export const OPENCLAW_CONFIG_CAPTURE_SCRIPT = `import os, stat, sys +maximum = ${MAX_OPENCLAW_CONFIG_BYTES} +directory = sys.argv[1] +name = sys.argv[2] +protocol = "${OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX}" +def fail(status, reason): + print(protocol + reason, file=sys.stderr) + raise SystemExit(status) +directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) +file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) +try: + directory_fd = os.open(directory, directory_flags) +except OSError: + fail(10, "directory-unavailable") +try: + directory_before = os.fstat(directory_fd) + try: + file_fd = os.open(name, file_flags, dir_fd=directory_fd) + except FileNotFoundError: + fail(2, "missing") + except OSError: + fail(10, "file-unavailable") + try: + before = os.fstat(file_fd) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + fail(11, "unsafe-file-metadata") + if before.st_size > maximum: + fail(12, "size-limit-exceeded") + chunks = [] + total = 0 + while True: + chunk = os.read(file_fd, min(64 * 1024, maximum + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > maximum: + fail(12, "size-limit-exceeded") + after = os.fstat(file_fd) + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns, value.st_nlink) + if identity(before) != identity(after) or identity(before) != identity(current) or not stat.S_ISREG(current.st_mode): + fail(13, "file-changed-during-read") + directory_current = os.stat(directory, follow_symlinks=False) + if (directory_before.st_dev, directory_before.st_ino) != (directory_current.st_dev, directory_current.st_ino) or not stat.S_ISDIR(directory_current.st_mode): + fail(13, "directory-changed-during-read") + sys.stdout.buffer.write(b"".join(chunks)) + finally: + os.close(file_fd) +finally: + os.close(directory_fd) +`; + +type OpenClawConfigCaptureFailure = + | "missing" + | "directory-unavailable" + | "file-unavailable" + | "unsafe-file-metadata" + | "size-limit-exceeded" + | "file-changed-during-read" + | "directory-changed-during-read"; + +function captureFailureProtocol(stderr: unknown): OpenClawConfigCaptureFailure | null { + if ( + (Buffer.isBuffer(stderr) && stderr.length > OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES) || + (typeof stderr === "string" && + Buffer.byteLength(stderr) > OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES) + ) { + return null; + } + const value = Buffer.isBuffer(stderr) + ? stderr.toString("utf8") + : typeof stderr === "string" + ? stderr + : ""; + const line = value.endsWith("\n") ? value.slice(0, -1) : value; + if (!line.startsWith(OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX) || /[\r\n]/.test(line)) { + return null; + } + const reason = line.slice(OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX.length); + switch (reason) { + case "missing": + case "directory-unavailable": + case "file-unavailable": + case "unsafe-file-metadata": + case "size-limit-exceeded": + case "file-changed-during-read": + case "directory-changed-during-read": + return reason; + default: + return null; + } +} + +function captureFailureDiagnostic(stderr: unknown): string | null { + const value = Buffer.isBuffer(stderr) + ? stderr.subarray(0, OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES).toString("utf8") + : typeof stderr === "string" + ? Buffer.from(stderr) + .subarray(0, OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES) + .toString("utf8") + : ""; + const sanitized = sanitizeReadinessText(value, 240).replace(/\s+/g, " ").trim(); + return sanitized || null; +} + +export function captureOpenClawStateFile( + sandboxName: string, + request: sandboxState.StateFileCaptureRequest, +): sandboxState.StateFileCaptureResult | null { + if ( + request.dir !== "/sandbox/.openclaw" || + request.spec.path !== "openclaw.json" || + request.spec.strategy !== "copy" + ) { + return null; + } + try { + return withPrivilegedSandboxExecutionLease( + sandboxName, + "OpenClaw config snapshot capture", + () => { + const argv = privilegedSandboxExecArgv( + sandboxName, + [ + "/usr/bin/python3", + "-I", + "-S", + "-c", + OPENCLAW_CONFIG_CAPTURE_SCRIPT, + OPENCLAW_CONFIG_DIRECTORY, + OPENCLAW_CONFIG_NAME, + ], + false, + true, + ); + const result = dockerSpawnSync(argv, { + encoding: null, + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS, + maxBuffer: OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER, + }); + const protocolFailure = captureFailureProtocol(result.stderr); + if ( + result.status === 2 && + result.signal === null && + !result.error && + protocolFailure === "missing" + ) { + return { outcome: "missing" }; + } + if ( + result.status !== 0 || + result.signal !== null || + result.error || + !Buffer.isBuffer(result.stdout) + ) { + const primaryDetail = + result.error?.message ?? + (result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`); + const stderrDetail = protocolFailure + ? `reason ${protocolFailure}` + : captureFailureDiagnostic(result.stderr); + const detail = stderrDetail ? `${primaryDetail}; ${stderrDetail}` : primaryDetail; + return { outcome: "failed", error: `privileged config capture failed: ${detail}` }; + } + return { outcome: "backed_up", data: result.stdout }; + }, + ); + } catch (error) { + return { + outcome: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } } const defaultDependencies: Omit = { @@ -42,6 +234,7 @@ const defaultDependencies: Omit sandboxState.backupSandboxState(...args), + captureOpenClawStateFile, }; function failure(error: unknown): sandboxState.BackupResult { @@ -59,9 +252,9 @@ function failure(error: unknown): sandboxState.BackupResult { function backupStateOnly( dependencies: SnapshotBackupAuthorityDependencies, sandboxName: string, - options: Pick, + options: Pick, ): sandboxState.BackupResult { - return options.name === undefined + return options.name === undefined && options.captureStateFile === undefined ? dependencies.backup(sandboxName) : dependencies.backup(sandboxName, options); } @@ -201,6 +394,15 @@ export function backupSandboxStateWithManagedAuthority( const entry = dependencies.getSandbox(sandboxName); if (!entry) return backupStateOnly(dependencies, sandboxName, options); + const stateFileOptions: Pick = + !entry.agent || entry.agent === "openclaw" + ? { + captureStateFile: (request) => + dependencies.captureOpenClawStateFile(sandboxName, request), + } + : {}; + const backupOptions = { ...options, ...stateFileOptions }; + let authority: SnapshotBackupAuthority | null; try { authority = captureSnapshotAuthority(entry, dependencies); @@ -208,6 +410,6 @@ export function backupSandboxStateWithManagedAuthority( return failure(error); } return authority - ? dependencies.backup(sandboxName, { ...options, ...authority }) - : backupStateOnly(dependencies, sandboxName, options); + ? dependencies.backup(sandboxName, { ...backupOptions, ...authority }) + : backupStateOnly(dependencies, sandboxName, backupOptions); } diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 6f2d38901b3..fb6b2814963 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -178,6 +178,12 @@ export interface BackupOptions { * visible to restore and rebuild flows. */ validateBeforePublish?: () => void; + /** + * Internal capture path for a declared state file that the sandbox-user SSH + * transport cannot read. The caller must independently enforce path, + * identity, and stable-read constraints before returning bytes. + */ + captureStateFile?: StateFileCapture; } export interface InstanceBackup { @@ -195,6 +201,19 @@ export interface StateFileSpec { strategy: StateFileStrategy; } +export interface StateFileCaptureRequest { + sandboxName: string; + dir: string; + spec: StateFileSpec; +} + +export type StateFileCaptureResult = + | { outcome: "backed_up"; data: Buffer } + | { outcome: "missing" } + | { outcome: "failed"; error?: string; unreachable?: boolean }; + +export type StateFileCapture = (request: StateFileCaptureRequest) => StateFileCaptureResult | null; + export interface BackupResult { success: boolean; // Only set once the backup has been written to disk — absent on @@ -1106,6 +1125,7 @@ function backupStateFile( dir: string, spec: StateFileSpec, backupPath: string, + captureFallback?: StateFileCapture, ): StateFileBackupResult { const command = buildStateFileBackupCommand(dir, spec); _log(`Backing up state file ${spec.path} (${spec.strategy})`); @@ -1117,8 +1137,25 @@ function backupStateFile( if (result.status === 2) return { outcome: "missing", unreachable: false }; const emptySqliteBackup = spec.strategy === "sqlite_backup" && result.stdout?.length === 0; - if (result.status !== 0 || result.error || result.signal || !result.stdout || emptySqliteBackup) { + let captured: StateFileCaptureResult | null = null; + if (result.status === 1 && !result.error && !result.signal && captureFallback !== undefined) { + try { + captured = captureFallback({ sandboxName, dir, spec }); + } catch (error) { + captured = { + outcome: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + } + if (captured?.outcome === "missing") return { outcome: "missing", unreachable: false }; + const capturedData = captured?.outcome === "backed_up" ? captured.data : null; + if ( + (result.status !== 0 || result.error || result.signal || !result.stdout || emptySqliteBackup) && + capturedData === null + ) { const detail = + (captured?.outcome === "failed" ? captured.error : undefined) || (result.stderr?.toString() || "").trim() || result.error?.message || (result.signal @@ -1127,7 +1164,12 @@ function backupStateFile( ? "empty output" : `exit ${String(result.status)}`); _log(`FAILED: state file backup ${spec.path}: ${detail.substring(0, 200)}`); - return { outcome: "failed", unreachable: isSshTransportFailure(result) }; + return { + outcome: "failed", + unreachable: + (captured?.outcome === "failed" && captured.unreachable === true) || + isSshTransportFailure(result), + }; } const localPath = path.join(backupPath, spec.path); @@ -1135,7 +1177,7 @@ function backupStateFile( rejectSymlinksOnPath(parent); mkdirSync(parent, { recursive: true, mode: 0o700 }); rejectSymlinksOnPath(localPath); - writeFileSync(localPath, result.stdout); + writeFileSync(localPath, capturedData ?? result.stdout); chmodSync(localPath, 0o600); return { outcome: "backed_up", unreachable: false }; } @@ -1748,7 +1790,14 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = } for (const spec of stateFiles) { - const result = backupStateFile(configFile, sandboxName, dir, spec, backupPath); + const result = backupStateFile( + configFile, + sandboxName, + dir, + spec, + backupPath, + options.captureStateFile, + ); if (result.outcome === "backed_up") { backedUpFiles.push(spec.path); } else if (result.outcome === "failed") { diff --git a/test/openclaw-config-snapshot.test.ts b/test/openclaw-config-snapshot.test.ts index c3ea863f892..61c0b708d45 100644 --- a/test/openclaw-config-snapshot.test.ts +++ b/test/openclaw-config-snapshot.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; // sandbox-state computes its backup root from HOME at module load time. const ORIGINAL_HOME = process.env.HOME; @@ -35,7 +35,12 @@ function writeExecutable(filePath: string, source: string): void { * SSH contract against a local sandbox-root directory, so backupSandboxState / * restoreSandboxState exercise the real code path without a live sandbox. */ -function writeFakeSandboxBins(binDir: string, fakeRoot: string): void { +function writeFakeSandboxBins( + binDir: string, + fakeRoot: string, + options: { denyConfigSshRead?: boolean } = {}, +): void { + const configReadDenial = options.denyConfigSshRead === true ? "process.exit(1);" : ""; writeExecutable( path.join(binDir, "openshell"), `#!/bin/sh @@ -71,6 +76,7 @@ function readStdin() { } if (cmd.includes("[ -d ")) { process.exit(0); } if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + ${configReadDenial} process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json"))); process.exit(0); } @@ -115,6 +121,50 @@ function writeOpenClawRegistry(sandboxName: string): void { } describe("OpenClaw durable config file (#5027)", () => { + it("uses a supplied state-file capture when SSH cannot read openclaw.json", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sealed-config-snapshot-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const fakeRoot = path.join(fixture, "sandbox-root"); + const openclawDir = path.join(fakeRoot, ".openclaw"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(openclawDir, { recursive: true }); + const original = Buffer.from( + JSON.stringify({ models: { default: "nvidia/test" }, apiKey: "secret" }), + ); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), original); + writeFakeSandboxBins(binDir, fakeRoot, { denyConfigSshRead: true }); + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = path.join(binDir, "openshell"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const captureStateFile = vi.fn(() => ({ outcome: "backed_up" as const, data: original })); + const backup = sandboxState.backupSandboxState("alpha", { captureStateFile }); + + expect(backup.success).toBe(true); + expect(backup.backedUpFiles).toEqual(["openclaw.json"]); + expect(backup.failedFiles).toEqual([]); + expect(captureStateFile).toHaveBeenCalledWith({ + sandboxName: "alpha", + dir: "/sandbox/.openclaw", + spec: { path: "openclaw.json", strategy: "copy" }, + }); + const stored = JSON.parse( + fs.readFileSync(path.join(backup.manifest!.backupPath, "openclaw.json"), "utf-8"), + ); + expect(stored.models.default).toBe("nvidia/test"); + expect(stored.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + } finally { + void (oldOpenshell === undefined + ? Reflect.deleteProperty(process.env, "NEMOCLAW_OPENSHELL_BIN") + : Reflect.set(process.env, "NEMOCLAW_OPENSHELL_BIN", oldOpenshell)); + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + it("backs up and restores openclaw.json settings while sanitizing secrets", async () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-")); const oldPath = process.env.PATH;