Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 51 additions & 15 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,23 @@ _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /s
# inherit group=sandbox regardless of which UID created them, so the
# agent keeps read access and shields-up locking still works the same.
#
# Keep the recovery baseline outside the mutable group-write contract. It is
# readable by the sandbox group for restore, but only root should rewrite it.
lock_openclaw_config_baseline_if_present() {
local config_dir="${1:-/sandbox/.openclaw}"
local baseline_file="$config_dir/openclaw.json.nemoclaw-baseline"

[ -f "$baseline_file" ] || return 0
[ "$(id -u)" -eq 0 ] || return 0

if [ -L "$config_dir" ] || [ -L "$baseline_file" ]; then
return 0
fi

chown root:sandbox "$baseline_file" 2>/dev/null || true
chmod 0440 "$baseline_file" 2>/dev/null || true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Idempotent. Skips when shields are UP (config dir owned by root) so
# the lock is not weakened.
normalize_mutable_config_perms() {
Expand All @@ -234,6 +251,7 @@ normalize_mutable_config_perms() {
find "$config_dir" -type d -exec chmod g+s {} + 2>/dev/null || true
chmod 2770 "$config_dir" 2>/dev/null || true
chmod 660 "$config_dir/openclaw.json" "$config_dir/.config-hash" 2>/dev/null || true
lock_openclaw_config_baseline_if_present "$config_dir"
}

openclaw_config_dir_owner() {
Expand Down Expand Up @@ -365,16 +383,20 @@ write_openclaw_config_baseline() {
return 0
fi

# Idempotent — only capture once per sandbox. Still re-lock an existing
# baseline because mutable permission normalization is intentionally broad.
if [ -f "$baseline_file" ]; then
lock_openclaw_config_baseline_if_present "$config_dir"
return 0
fi

# Skip in shields-up mode — config is supposed to be locked, baseline
# capture is unnecessary and the prepare/restore permission dance is
# already owned by the override paths.
if [ "$(openclaw_config_dir_owner "$config_dir")" = "root" ]; then
return 0
fi

# Idempotent — only capture once per sandbox.
[ -f "$baseline_file" ] && return 0

# Refuse to capture broken state. grep -q '[^[:space:]]' is false for both
# 0-byte and whitespace-only files.
if ! grep -q '[^[:space:]]' "$config_file" 2>/dev/null; then
Expand All @@ -385,16 +407,31 @@ write_openclaw_config_baseline() {
# baseline a known-good restore target. openclaw.json is JSON5 (comments,
# trailing commas) everywhere else in the stack — OpenClaw uses
# JSON5.parse / parseJsonWithJson5Fallback, and migration-state.ts uses
# JSON5.parse — so the validator here matches that contract instead of
# rejecting JSON5 features as the strict json.load would.
if ! python3 - "$config_file" 2>/dev/null <<'PY_VALIDATE'; then
import json, re, sys
src = open(sys.argv[1]).read()
src = re.sub(r'//[^\n]*', '', src) # line comments
src = re.sub(r'/\*[\s\S]*?\*/', '', src) # block comments
src = re.sub(r',(\s*[}\]])', r'\1', src) # trailing commas
json.loads(src)
PY_VALIDATE
# JSON5.parse — so use the real JSON5 parser instead of approximating the
# grammar with regexes.
if ! node - "$config_file" 2>/dev/null <<'NODE_VALIDATE'; then
const fs = require("fs");

let JSON5;
for (const candidate of [
"/opt/nemoclaw/node_modules/json5",
"./nemoclaw/node_modules/json5",
"json5",
]) {
try {
JSON5 = require(candidate);
break;
} catch {
// Try the next runtime location.
}
}

if (!JSON5) {
process.exit(1);
}

JSON5.parse(fs.readFileSync(process.argv[2], "utf8"));
NODE_VALIDATE
return 0
fi

Expand All @@ -404,8 +441,7 @@ PY_VALIDATE
# 0440 root:sandbox so the gateway/sandbox user can READ for recovery but
# cannot truncate or rewrite the baseline through the same path that
# corrupts the active config.
chown root:sandbox "$baseline_file" 2>/dev/null || true
chmod 0440 "$baseline_file" 2>/dev/null || true
lock_openclaw_config_baseline_if_present "$config_dir"
printf '[config] Baseline snapshot created: %s\n' "$baseline_file" >&2
}

Expand Down
72 changes: 66 additions & 6 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2304,6 +2304,49 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
});

// ── write_openclaw_config_baseline ────────────────────────────────────────
function runNormalizeMutableConfigPermsWithBaseline() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-lock-"));
const openclawDir = path.join(root, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const configPath = path.join(openclawDir, "openclaw.json");
const hashPath = path.join(openclawDir, ".config-hash");
const baselinePath = path.join(openclawDir, "openclaw.json.nemoclaw-baseline");

fs.writeFileSync(configPath, "{}");
fs.writeFileSync(hashPath, "oldhash\n");
fs.writeFileSync(baselinePath, JSON.stringify({ source: "baseline" }));
fs.chmodSync(openclawDir, 0o2770);
fs.chmodSync(configPath, 0o660);
fs.chmodSync(hashPath, 0o660);
fs.chmodSync(baselinePath, 0o460);

const wrapper = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"id() { echo 0; }",
"chown() { return 0; }",
`stat() { if [ "$1" = "-c" ] && [ "$2" = "%U" ] && [ "$3" = ${JSON.stringify(openclawDir)} ]; then echo sandbox; return 0; fi; command stat "$@"; }`,
extractShellFunction("lock_openclaw_config_baseline_if_present").replaceAll(
"/sandbox",
root,
),
extractShellFunction("normalize_mutable_config_perms").replaceAll("/sandbox", root),
"normalize_mutable_config_perms",
].join("\n");
const script = path.join(root, "run.sh");
fs.writeFileSync(script, wrapper, { mode: 0o700 });
const result = spawnSync("bash", [script], { encoding: "utf-8" });
const baselineMode = fs.statSync(baselinePath).mode & 0o777;
fs.rmSync(root, { recursive: true, force: true });
return { result, baselineMode };
}

it("keeps the baseline read-only after mutable permission normalization", () => {
const { result, baselineMode } = runNormalizeMutableConfigPermsWithBaseline();
expect(result.status).toBe(0);
expect(baselineMode).toBe(0o440);
});

type BaselineFixture = {
configContent: string;
baselineExists?: boolean;
Expand All @@ -2324,7 +2367,10 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
fs.writeFileSync(baselinePath, JSON.stringify({ stale: true }));
}

const helperFns = [extractShellFunction("openclaw_config_dir_owner")]
const helperFns = [
extractShellFunction("openclaw_config_dir_owner"),
extractShellFunction("lock_openclaw_config_baseline_if_present"),
]
.join("\n")
.replaceAll("/sandbox", root);
const fn = extractShellFunction("write_openclaw_config_baseline").replaceAll(
Expand All @@ -2349,8 +2395,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
const result = spawnSync("bash", [script], { encoding: "utf-8" });
const baselineExists = fs.existsSync(baselinePath);
const baselineContent = baselineExists ? fs.readFileSync(baselinePath, "utf-8") : "";
const baselineMode = baselineExists ? fs.statSync(baselinePath).mode & 0o777 : undefined;
fs.rmSync(root, { recursive: true, force: true });
return { result, baselineExists, baselineContent };
return { result, baselineExists, baselineContent, baselineMode };
}

it("captures baseline snapshot when openclaw.json is valid and no baseline exists", () => {
Expand All @@ -2363,14 +2410,15 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
expect(baselineContent).toBe(config);
});

it("is idempotent — does not overwrite an existing baseline", () => {
it("is idempotent and re-locks an existing baseline", () => {
const config = JSON.stringify({ source: "current" });
const { result, baselineContent } = runWriteBaseline({
const { result, baselineContent, baselineMode } = runWriteBaseline({
configContent: config,
baselineExists: true,
});
expect(result.status).toBe(0);
expect(baselineContent).toBe(JSON.stringify({ stale: true }));
expect(baselineMode).toBe(0o440);
});

it("refuses to capture an empty openclaw.json as baseline", () => {
Expand Down Expand Up @@ -2399,9 +2447,9 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
const config = [
"{",
' // primary model',
' "agents": { "defaults": { "model": { "primary": "x" } } },',
" agents: { defaults: { model: { primary: 'x' } } },",
" /* trailing comma below is JSON5-only */",
' "models": { "providers": { "inference": {} } },',
" models: { providers: { inference: {} } },",
"}",
].join("\n");
const { result, baselineExists, baselineContent } = runWriteBaseline({
Expand All @@ -2422,6 +2470,18 @@ describe("openclaw.json baseline + recovery (#3118)", () => {
expect(baselineExists).toBe(false);
});

it("re-locks an existing baseline even when shields are up", () => {
const config = JSON.stringify({ ok: true });
const { result, baselineContent, baselineMode } = runWriteBaseline({
configContent: config,
baselineExists: true,
dirOwner: "root",
});
expect(result.status).toBe(0);
expect(baselineContent).toBe(JSON.stringify({ stale: true }));
expect(baselineMode).toBe(0o440);
});

it("skips baseline write when not running as root", () => {
const config = JSON.stringify({ ok: true });
const { result, baselineExists } = runWriteBaseline({
Expand Down
Loading