Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -56,6 +56,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 @@ -83,6 +84,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 @@ -1103,6 +1141,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 Down Expand Up @@ -1147,6 +1186,16 @@ export async function rebuildSandbox(
);
}

// 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 @@ -1208,7 +1257,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 @@ -1236,6 +1285,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