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
6 changes: 6 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/lib/actions/sandbox/rebuild-config-hash.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spawnSync> {
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 });
}
});
});
56 changes: 55 additions & 1 deletion src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading