diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 2ad417f574..2035cb0678 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2256,6 +2256,12 @@ _nemoclaw_restore_mutable_config_perms() { chmod -R g+rwX,o-rwx "$_nemoclaw_oc_dir" 2>/dev/null || true find "$_nemoclaw_oc_dir" -type d -exec chmod g+s {} + 2>/dev/null || true chmod 2770 "$_nemoclaw_oc_dir" 2>/dev/null || true + if [ ! -L "$_nemoclaw_oc_dir" ] && + [ ! -L "$_nemoclaw_oc_dir/openclaw.json" ] && + [ ! -L "$_nemoclaw_oc_dir/.config-hash" ] && + [ -f "$_nemoclaw_oc_dir/openclaw.json" ]; then + (cd "$_nemoclaw_oc_dir" && sha256sum openclaw.json >.config-hash) 2>/dev/null || true + fi chmod 660 "$_nemoclaw_oc_dir/openclaw.json" "$_nemoclaw_oc_dir/.config-hash" 2>/dev/null || true # Keep the recovery baseline out of the group-writable contract — it is a # read-only trust anchor (root:sandbox 0440 when root re-locks it). The diff --git a/src/lib/actions/sandbox/rebuild-config-hash.test.ts b/src/lib/actions/sandbox/rebuild-config-hash.test.ts new file mode 100644 index 0000000000..24e7011ef5 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-config-hash.test.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +type RebuildModule = typeof import("../../../../dist/lib/actions/sandbox/rebuild"); + +const requireDist = createRequire(import.meta.url); +const { buildRefreshMutableOpenClawConfigHashCommand } = requireDist( + "../../../../dist/lib/actions/sandbox/rebuild.js", +) as RebuildModule; + +function sha256Hex(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function runRefresh(configDir: string): ReturnType { + return spawnSync("bash", ["-c", buildRefreshMutableOpenClawConfigHashCommand(configDir)], { + encoding: "utf-8", + timeout: 5000, + }); +} + +describe.skipIf(process.platform !== "linux")("OpenClaw rebuild config hash refresh", () => { + it("refreshes .config-hash for the current openclaw.json", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-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 = runRefresh(configDir); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(`${sha256Hex(configPath)} openclaw.json\n`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("refuses to refresh through a symlinked config file", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-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 = runRefresh(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 }); + } + }); +}); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index e2c1daaeff..f96b80e0a8 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -61,6 +61,7 @@ import { printSandboxListFailureWithRecoveryContext, } from "../../openshell-sandbox-list"; import * as policies from "../../policy"; +import { shellQuote } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import * as sandboxVersion from "../../sandbox/version"; import { redact } from "../../security/redact"; @@ -88,6 +89,43 @@ import { relockRebuildShieldsWindow, } from "./rebuild-shields"; +export function buildRefreshMutableOpenClawConfigHashCommand( + configDir = "/sandbox/.openclaw", +): 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; }', + 'owner="$(stat -c "%U" "$config_dir" 2>/dev/null || echo unknown)"', + '[ "$owner" != "root" ] || exit 0', + '[ -f "$config_file" ] || exit 0', + 'cd "$config_dir" || exit 13', + "sha256sum openclaw.json > .config-hash", + "chmod 660 .config-hash 2>/dev/null || true", + ].join("; "); +} + +function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( + sandboxName: string, + log: (msg: string) => void, +): boolean { + const result = executeSandboxCommand(sandboxName, buildRefreshMutableOpenClawConfigHashCommand()); + if (result && result.status === 0) { + log("Mutable OpenClaw config hash refreshed after post-restore config writes"); + 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} Mutable OpenClaw config hash was not refreshed: ${redact(detail)}`); + return false; +} + /** * Emit timestamped rebuild diagnostics when verbose rebuild logging is enabled. */ @@ -1136,6 +1174,7 @@ export async function rebuildSandbox( // could not verify the contract — the rebuilt sandbox may still EACCES on // gateway-side config writes, so the final result is downgraded below. let mutablePermsRepairUnverified = false; + let mutableConfigHashRefreshUnverified = false; if (agentDef.name === "openclaw") { // openclaw doctor --fix validates and repairs directory structure. // Idempotent and safe — catches structural changes between OpenClaw versions @@ -1159,6 +1198,16 @@ export async function rebuildSandbox( // remain paired with the restored OpenClaw extension state. await reapplyMessagingManifestAfterOpenClawDoctor(sandboxName, rebuildMessagingPlan, log); + // The post-restore structure repair and seed helper can rewrite + // openclaw.json after restoreStateFile has already refreshed + // .config-hash. Refresh the mutable hash here so the gateway token and + // channel seed changes are integrity-valid before the sandbox is handed + // back to the user. + log("Refreshing mutable OpenClaw config hash after post-restore config writes"); + if (!refreshMutableOpenClawConfigHashAfterPostRestoreWrites(sandboxName, log)) { + mutableConfigHashRefreshUnverified = true; + } + // #4538: `openclaw doctor --fix` enforces a single-user 700/600 state // layout, which silently tightens NemoClaw's mutable config contract // (setgid + group-writable /sandbox/.openclaw and group-writable @@ -1220,7 +1269,7 @@ export async function rebuildSandbox( if (!relockShieldsIfNeeded(true)) return bail("Failed to re-apply shields lockdown."); console.log(""); - if (restoreSucceeded && !mutablePermsRepairUnverified) { + if (restoreSucceeded && !mutablePermsRepairUnverified && !mutableConfigHashRefreshUnverified) { console.log(` ${G}\u2713${R} Sandbox '${sandboxName}' rebuilt successfully`); if (staleRecovery) { console.log( @@ -1248,6 +1297,11 @@ export async function rebuildSandbox( ` Mutable config permissions were not verified \u2014 run \`${CLI_NAME} ${sandboxName} doctor --fix\` to restore the OpenClaw config permission contract`, ); } + if (mutableConfigHashRefreshUnverified) { + console.log( + ` Mutable OpenClaw config hash was not refreshed \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, + ); + } } // Stale recovery reset the shields state to mutable (the gone sandbox's lock // seal could not carry over to the fresh image). If lockdown had been enabled, diff --git a/test/openclaw-config-restore.test.ts b/test/openclaw-config-restore.test.ts new file mode 100644 index 0000000000..af079a4dc2 --- /dev/null +++ b/test/openclaw-config-restore.test.ts @@ -0,0 +1,259 @@ +// 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 { pathToFileURL } from "node:url"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +type SandboxStateModule = typeof import("../dist/lib/state/sandbox.js"); +type CurrentOpenClawReadMode = "file" | "missing" | "invalid-json"; + +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "state", "sandbox.js")).href +)) as SandboxStateModule; + +beforeEach(() => { + fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); +}); + +afterAll(() => { + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeOpenClawRegistry(sandboxName: string): void { + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: null, + }, + }, + }), + ); +} + +function writeFakeOpenshell(binDir: string): string { + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + return openshell; +} + +function writeBackup(sandboxName: string, dirName: string): { backupPath: string } { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify( + { + version: 1, + sandboxName, + timestamp: dirName, + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + stateFiles: [{ path: "openclaw.json", strategy: "copy" }], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + }, + null, + 2, + ), + ); + return { backupPath }; +} + +function freshRuntimeConfig(): string { + return JSON.stringify( + { + gateway: { auth: { token: "fresh-runtime-token" } }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "nvidia/nemotron" }] } }, + }, + }, + null, + 2, + ); +} + +function staleBackupConfig(): string { + return JSON.stringify( + { + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + slack: { accounts: { default: { botToken: "[STRIPPED_BY_MIGRATION]" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "stale-model" }] } }, + }, + mcpServers: { filesystem: { command: "npx" } }, + }, + null, + 2, + ); +} + +function restoreOpenClawStateFileWithFakeSsh(options: { + backupContents: string; + currentContents: string; + currentReadMode?: CurrentOpenClawReadMode; +}): { + restore: ReturnType; + currentContents: string; +} { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-fixture-")); + 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 }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), options.currentContents); + + process.env.NEMOCLAW_OPENSHELL_BIN = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const dir = path.join(${JSON.stringify(fakeRoot)}, ".openclaw"); +const cmd = process.argv[process.argv.length - 1] || ""; +const currentReadMode = ${JSON.stringify(options.currentReadMode ?? "file")}; +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + let n = 0; + try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { break; } + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + if (currentReadMode === "missing") process.exit(2); + if (currentReadMode === "invalid-json") { + process.stdout.write("{ invalid current json"); + process.exit(0); + } + process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json"))); + process.exit(0); +} +if (cmd.includes(".nemoclaw-restore") && cmd.includes("openclaw.json")) { + fs.writeFileSync(path.join(dir, "openclaw.json"), readStdin()); + process.exit(0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const { backupPath } = writeBackup("alpha", "2026-06-10T20-00-00-000Z"); + fs.writeFileSync(path.join(backupPath, "openclaw.json"), options.backupContents); + + const restore = sandboxState.restoreSandboxState("alpha", backupPath); + return { + restore, + currentContents: fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8"), + }; + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } +} + +describe("OpenClaw config restore failure modes", () => { + it("fails closed when the current rebuilt openclaw.json cannot be read", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "missing", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.slack).toBeUndefined(); + }); + + it("fails closed when the current rebuilt openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "invalid-json", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).models.providers.nvidia.models[0].id).toBe( + "nvidia/nemotron", + ); + }); + + it("fails closed when the backed-up openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: "{ invalid backup json", + currentContents: freshRuntimeConfig(), + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.discord.accounts.default.token).toBe( + "openshell:resolve:env:v222_TOKEN", + ); + }); +}); diff --git a/test/repro-4538-raw-doctor-perms.test.ts b/test/repro-4538-raw-doctor-perms.test.ts index 03beb27a7f..f08c6b4529 100644 --- a/test/repro-4538-raw-doctor-perms.test.ts +++ b/test/repro-4538-raw-doctor-perms.test.ts @@ -31,6 +31,7 @@ */ import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -65,6 +66,17 @@ function modeBits(filePath: string): number { return fs.statSync(filePath).mode & 0o7777; } +function sha256Hex(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function openClawConfigHashMatches(configDir: string): boolean { + const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); + const [digest, fileName] = fs.readFileSync(hashFile, "utf-8").trim().split(/\s+/); + return digest === sha256Hex(configFile) && fileName === "openclaw.json"; +} + // WSL CI can run these snippets as root; force restore-path cases to model a // mutable sandbox-owned config tree instead of the shields-up root-owned branch. function mutableSandboxOwnerStatShim(): string { @@ -141,6 +153,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // Config + hash: group-writable so the gateway UID can persist edits. expect(modeBits(configFile)).toBe(0o660); expect(modeBits(hashFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); // Recursive: nested dirs regain setgid + group access too. expect(modeBits(nestedDir) & 0o2070).toBe(0o2070); } finally { @@ -199,6 +212,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { "command() {", ' if [ "${1:-}" = "openclaw" ]; then', ' chmod 700 "$OPENCLAW_STATE_DIR";', + ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', " return 7;", " fi", @@ -222,6 +236,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // ...and still restores the mutable contract afterwards. expect(modeBits(configDir)).toBe(0o2770); expect(modeBits(configFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -246,6 +261,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { "command() {", ' if [ "${1:-}" = "openclaw" ]; then', ' chmod 700 "$OPENCLAW_STATE_DIR";', + ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', " return 7;", " fi", @@ -268,6 +284,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // ...but the in-function restore ran before the function returned. expect(modeBits(configDir)).toBe(0o2770); expect(modeBits(configFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }