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
33 changes: 28 additions & 5 deletions src/lib/actions/sandbox/rebuild-config-hash-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("; ");
}
158 changes: 157 additions & 1 deletion src/lib/actions/sandbox/rebuild-config-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -26,6 +29,13 @@ function runRefresh(
});
}

function runVerify(configDir: string): ReturnType<typeof spawnSync> {
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 });
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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)",
() => {
Expand Down
24 changes: 23 additions & 1 deletion src/lib/actions/sandbox/rebuild-config-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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;
}
40 changes: 39 additions & 1 deletion src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
Expand Down
22 changes: 19 additions & 3 deletions src/lib/actions/sandbox/rebuild-post-restore-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading