diff --git a/src/lib/actions/sandbox/rebuild-config-hash-command.ts b/src/lib/actions/sandbox/rebuild-config-hash-command.ts index f1d87ad6826..8d7fe890b1b 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash-command.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash-command.ts @@ -3,22 +3,45 @@ import { shellQuote } from "../../core/shell-quote"; -export function buildRefreshMutableOpenClawConfigHashCommand( - configDir = "/sandbox/.openclaw", -): string { +function buildConfigHashVerification(errorMessage: string): string { + return `expected_hash="$(sha256sum openclaw.json 2>/dev/null)" && actual_hash="$(cat .config-hash 2>/dev/null)" && [ "$actual_hash" = "$expected_hash" ] || { echo ${shellQuote(errorMessage)} >&2; exit 15; }`; +} + +function buildOpenClawConfigHashCommandPrefix(configDir: string): string[] { return [ `config_dir=${shellQuote(configDir)}`, 'config_file="${config_dir}/openclaw.json"', 'hash_file="${config_dir}/.config-hash"', - '[ -d "$config_dir" ] || exit 0', '[ ! -L "$config_dir" ] || { echo "refusing symlinked OpenClaw config dir: $config_dir" >&2; exit 10; }', '[ ! -L "$config_file" ] || { echo "refusing symlinked OpenClaw config file: $config_file" >&2; exit 11; }', '[ ! -L "$hash_file" ] || { echo "refusing symlinked OpenClaw config hash: $hash_file" >&2; exit 12; }', + ]; +} + +export function buildRefreshMutableOpenClawConfigHashCommand( + configDir = "/sandbox/.openclaw", +): string { + return [ + ...buildOpenClawConfigHashCommandPrefix(configDir), + '[ -d "$config_dir" ] || exit 0', 'owner="$(stat -c "%U" "$config_dir" 2>/dev/null || echo unknown)"', '[ -f "$config_file" ] || exit 0', 'cd "$config_dir" || exit 13', - '[ "$owner" != "root" ] || { expected_hash="$(sha256sum openclaw.json 2>/dev/null)" && actual_hash="$(cat .config-hash 2>/dev/null)" && [ "$actual_hash" = "$expected_hash" ] || { echo "root-owned OpenClaw config hash does not match openclaw.json" >&2; exit 15; }; exit 0; }', + `[ "$owner" != "root" ] || { ${buildConfigHashVerification("root-owned OpenClaw config hash does not match openclaw.json")}; exit 0; }`, "sha256sum openclaw.json > .config-hash || exit 14", "chmod 660 .config-hash 2>/dev/null || true", ].join("; "); } + +export function buildVerifyMutableOpenClawConfigHashCommand( + configDir = "/sandbox/.openclaw", +): string { + return [ + ...buildOpenClawConfigHashCommandPrefix(configDir), + '[ -d "$config_dir" ] || { echo "OpenClaw config directory is not a directory: $config_dir" >&2; exit 16; }', + '[ -f "$config_file" ] || { echo "OpenClaw config is not a regular file: $config_file" >&2; exit 17; }', + '[ -f "$hash_file" ] || { echo "OpenClaw config hash is not a regular file: $hash_file" >&2; exit 18; }', + 'cd "$config_dir" || exit 13', + buildConfigHashVerification("OpenClaw config hash does not match openclaw.json"), + ].join("; "); +} diff --git a/src/lib/actions/sandbox/rebuild-config-hash.test.ts b/src/lib/actions/sandbox/rebuild-config-hash.test.ts index d3b3421e55d..1b24b4ab524 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.test.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.test.ts @@ -9,7 +9,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildRefreshMutableOpenClawConfigHashCommand } from "./rebuild-config-hash-command"; +import { + buildRefreshMutableOpenClawConfigHashCommand, + buildVerifyMutableOpenClawConfigHashCommand, +} from "./rebuild-config-hash-command"; function sha256Hex(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); @@ -26,6 +29,13 @@ function runRefresh( }); } +function runVerify(configDir: string): ReturnType { + return spawnSync("bash", ["-c", buildVerifyMutableOpenClawConfigHashCommand(configDir)], { + encoding: "utf-8", + timeout: 5000, + }); +} + function installRootOwnerStat(binDir: string): void { const statCommand = path.join(binDir, "stat"); fs.mkdirSync(binDir, { recursive: true }); @@ -54,6 +64,130 @@ describe.skipIf(process.platform !== "linux")("OpenClaw rebuild config hash refr } }); + it("verifies the final pair without changing a stale config hash (#9530)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-")); + const configDir = path.join(tmpDir, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{"gateway":{"auth":{"token":"fresh"}}}\n'); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + + const result = runVerify(configDir); + + expect(result.status).toBe(15); + expect(result.stderr).toBe("OpenClaw config hash does not match openclaw.json\n"); + expect(fs.readFileSync(hashPath, "utf-8")).toBe("stale openclaw.json\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("accepts a matching final pair without changing the config hash (#9530)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-")); + const configDir = path.join(tmpDir, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{"gateway":{"auth":{"token":"fresh"}}}\n'); + const expectedHash = `${sha256Hex(configPath)} openclaw.json\n`; + fs.writeFileSync(hashPath, expectedHash); + + const result = runVerify(configDir); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(expectedHash); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it.each([ + { + title: "rejects a missing final config without changing the config hash (#9530)", + arrange: (_configDir: string, hashPath: string) => { + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + }, + expectedStatus: 17, + expectedStderr: "OpenClaw config is not a regular file", + expectedHash: "stale openclaw.json\n", + }, + { + title: "rejects a dangling final config symlink without changing the config hash (#9530)", + arrange: (configDir: string, hashPath: string) => { + fs.symlinkSync( + path.join(configDir, "missing-openclaw.json"), + path.join(configDir, "openclaw.json"), + ); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + }, + expectedStatus: 11, + expectedStderr: "refusing symlinked OpenClaw config file", + expectedHash: "stale openclaw.json\n", + }, + { + title: "rejects a final config directory without changing the config hash (#9530)", + arrange: (configDir: string, hashPath: string) => { + fs.mkdirSync(path.join(configDir, "openclaw.json")); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + }, + expectedStatus: 17, + expectedStderr: "OpenClaw config is not a regular file", + expectedHash: "stale openclaw.json\n", + }, + ])("$title", ({ arrange, expectedStatus, expectedStderr, expectedHash }) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-input-")); + const configDir = path.join(tmpDir, ".openclaw"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + arrange(configDir, hashPath); + + const result = runVerify(configDir); + + expect(result.status).toBe(expectedStatus); + expect(result.stderr).toContain(expectedStderr); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(expectedHash); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("rejects a missing final config hash (#9530)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-input-")); + const configDir = path.join(tmpDir, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{"gateway":{}}\n'); + + const result = runVerify(configDir); + + expect(result.status).toBe(18); + expect(result.stderr).toContain("OpenClaw config hash is not a regular file"); + expect(fs.existsSync(hashPath)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("rejects a missing final config directory (#9530)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-input-")); + const configDir = path.join(tmpDir, ".openclaw"); + try { + const result = runVerify(configDir); + + expect(result.status).toBe(16); + expect(result.stderr).toContain("OpenClaw config directory is not a directory"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it.each([ { title: "rejects a stale hash when the config directory is root-owned (#9530)", @@ -125,6 +259,28 @@ describe.skipIf(process.platform !== "linux")("OpenClaw rebuild config hash refr } }); + it("refuses to verify through a symlinked config file without changing the config hash (#9530)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-final-hash-symlink-")); + const configDir = path.join(tmpDir, ".openclaw"); + const targetPath = path.join(tmpDir, "target-openclaw.json"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(targetPath, '{"gateway":{"auth":{"token":"target"}}}\n'); + fs.symlinkSync(targetPath, configPath); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + + const result = runVerify(configDir); + + expect(result.status).toBe(11); + expect(result.stderr).toContain("refusing symlinked OpenClaw config file"); + expect(fs.readFileSync(hashPath, "utf-8")).toBe("stale openclaw.json\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it.skipIf(process.getuid?.() === 0)( "reports hash command failures instead of masking them (#6245)", () => { diff --git a/src/lib/actions/sandbox/rebuild-config-hash.ts b/src/lib/actions/sandbox/rebuild-config-hash.ts index 1e60e12b97b..fd2c2ce288b 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.ts @@ -4,7 +4,10 @@ import { R, YW } from "../../cli/terminal-style"; import { redact } from "../../security/redact"; import { executeSandboxCommand } from "./process-recovery"; -import { buildRefreshMutableOpenClawConfigHashCommand } from "./rebuild-config-hash-command"; +import { + buildRefreshMutableOpenClawConfigHashCommand, + buildVerifyMutableOpenClawConfigHashCommand, +} from "./rebuild-config-hash-command"; export { buildRefreshMutableOpenClawConfigHashCommand }; @@ -24,3 +27,22 @@ export function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( console.error(` ${YW}⚠${R} Mutable OpenClaw config hash was not refreshed: ${redact(detail)}`); return false; } + +export function verifyFinalMutableOpenClawConfigHash( + sandboxName: string, + log: (msg: string) => void, +): boolean { + const result = executeSandboxCommand(sandboxName, buildVerifyMutableOpenClawConfigHashCommand()); + if (result && result.status === 0) { + log("Final mutable OpenClaw config hash verified after post-restore finalization"); + return true; + } + + const detail = result + ? [result.stderr, result.stdout].filter(Boolean).join("; ") || `exit ${result.status}` + : "could not obtain sandbox SSH config"; + console.error( + ` ${YW}⚠${R} Final mutable OpenClaw config hash was not verified: ${redact(detail)}`, + ); + return false; +} diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index 10c89cf87ca..3ec97bfdb61 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -52,6 +52,10 @@ describe("rebuild post-restore phase", () => { order.push("config-hash"); return true; }); + vi.spyOn(rebuildConfigHash, "verifyFinalMutableOpenClawConfigHash").mockImplementation(() => { + order.push("config-hash-final"); + return true; + }); vi.spyOn(shields, "repairMutableConfigPerms").mockReturnValue({ applied: false, reason: "not needed", @@ -121,7 +125,7 @@ describe("rebuild post-restore phase", () => { it("reconciles OpenClaw sessions after doctor and before later config writes (#7102)", async () => { await runRebuildPostRestorePhase(input()); - expect(order).toEqual(["doctor", "reconcile", "messaging", "config-hash"]); + expect(order).toEqual(["doctor", "reconcile", "messaging", "config-hash", "config-hash-final"]); }); it("fails when doctor returns 255 and the final OpenClaw config hash is unverified (#9530)", async () => { @@ -146,6 +150,40 @@ describe("rebuild post-restore phase", () => { ); }); + it("fails when finalization invalidates the OpenClaw config hash after the early refresh (#9530)", async () => { + let configHashValid = true; + vi.mocked( + rebuildConfigHash.refreshMutableOpenClawConfigHashAfterPostRestoreWrites, + ).mockImplementation(() => configHashValid); + vi.mocked(messagingHostForward.ensureMessagingHostForwardAfterRebuild).mockImplementation( + () => { + configHashValid = false; + return true; + }, + ); + vi.mocked(rebuildConfigHash.verifyFinalMutableOpenClawConfigHash).mockImplementation( + () => configHashValid, + ); + const args = input(); + + await runRebuildPostRestorePhase(args); + + expect( + rebuildConfigHash.refreshMutableOpenClawConfigHashAfterPostRestoreWrites, + ).toHaveBeenCalledOnce(); + expect(rebuildConfigHash.verifyFinalMutableOpenClawConfigHash).toHaveBeenCalledOnce(); + expect(args.relockShieldsIfNeeded).toHaveBeenCalledWith(true); + expect(args.bail).toHaveBeenCalledWith( + "OpenClaw config integrity verification failed after rebuild.", + ); + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).toContain( + "Final OpenClaw configuration hash verification failed after post-restore finalization", + ); + expect(output).not.toContain("Mutable OpenClaw config hash was not refreshed"); + expect(output).not.toContain("rebuilt successfully"); + }); + it("does not run OpenClaw session reconciliation for another agent (#7102)", async () => { agentName = "hermes"; const args = input(); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 82d196f3437..d5ce1898766 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -14,7 +14,10 @@ import * as registry from "../../state/registry"; import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; import { executeSandboxCommand } from "./process-recovery"; import type { RebuildBackupManifest } from "./rebuild-backup-phase"; -import { refreshMutableOpenClawConfigHashAfterPostRestoreWrites } from "./rebuild-config-hash"; +import { + refreshMutableOpenClawConfigHashAfterPostRestoreWrites, + verifyFinalMutableOpenClawConfigHash, +} from "./rebuild-config-hash"; import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import { @@ -223,6 +226,7 @@ export async function runRebuildPostRestorePhase( const rebuiltAgentName = agentDef.displayName; let mutablePermsRepairUnverified = false; let mutableConfigHashRefreshUnverified = false; + let finalMutableConfigHashUnverified = false; let messagingHostForwardUnverified = false; const policyPresetRestoreIncomplete = failedPresets.length > 0 || @@ -396,13 +400,17 @@ export async function runRebuildPostRestorePhase( if (!ensureMessagingHostForwardAfterRebuild(sandboxName, messagingPlan)) { messagingHostForwardUnverified = true; } + if (targetAgentName === "openclaw" && !verifyFinalMutableOpenClawConfigHash(sandboxName, log)) { + finalMutableConfigHashUnverified = true; + } console.log(""); const postRestoreComplete = postRestoreCompleted({ hermesGatewayRestoreUnverified, messagingHostForwardUnverified, mcpBridgeRestoreUnverified, - mutableConfigHashRefreshUnverified, + mutableConfigHashRefreshUnverified: + mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified, mutablePermsRepairUnverified, policyPresetRestoreIncomplete, restoreSucceeded, @@ -435,6 +443,11 @@ export async function runRebuildPostRestorePhase( ` Mutable OpenClaw config hash was not refreshed \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, ); } + if (finalMutableConfigHashUnverified && !mutableConfigHashRefreshUnverified) { + console.log( + ` Final OpenClaw configuration hash verification failed after post-restore finalization \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, + ); + } if (messagingHostForwardUnverified) { console.log( ` Messaging webhook forward was not verified \u2014 run \`${CLI_NAME} ${sandboxName} connect\` after resolving the port conflict`, @@ -465,7 +478,10 @@ export async function runRebuildPostRestorePhase( bail(`Rebuild completed with unverified live policy reconciliation for '${sandboxName}'.`); return; } - if (targetAgentName === "openclaw" && mutableConfigHashRefreshUnverified) { + if ( + targetAgentName === "openclaw" && + (mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified) + ) { bail("OpenClaw config integrity verification failed after rebuild."); return; }