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
28 changes: 28 additions & 0 deletions nemoclaw/src/commands/migration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ describe("commands/migration-state", () => {
expect(result.errors.some((e) => e.includes("Failed to parse"))).toBe(true);
});

// Empty / whitespace-only openclaw.json. Without the read-path guard,
// JSON5.parse("") throws "JSON5: invalid end of input at 1:1" (issue
// #3118). The guard surfaces a recovery hint instead of leaking the
// opaque parser error.
it("reports a structured error when config file is empty (0 bytes)", () => {
const env = { HOME: "/home/user" };
addDir("/home/user/.openclaw");
addFile("/home/user/.openclaw/openclaw.json", "");
const result = detectHostOpenClaw(env);
expect(
result.errors.some(
(e) => e.toLowerCase().includes("empty") && !e.includes("invalid end of input"),
),
).toBe(true);
});

it("reports a structured error when config file is whitespace-only", () => {
const env = { HOME: "/home/user" };
addDir("/home/user/.openclaw");
addFile("/home/user/.openclaw/openclaw.json", " \n\t ");
const result = detectHostOpenClaw(env);
expect(
result.errors.some(
(e) => e.toLowerCase().includes("empty") && !e.includes("invalid end of input"),
),
).toBe(true);
});

it("reports error when config is an array", () => {
const env = { HOME: "/home/user" };
addDir("/home/user/.openclaw");
Expand Down
12 changes: 12 additions & 0 deletions nemoclaw/src/commands/migration-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ function loadConfigDocument(configPath: string): OpenClawConfigDocument | null {
return null;
}
const raw = readFileSync(configPath, "utf-8");
// Empty / whitespace-only openclaw.json — the upstream openshell-inference-set
// truncate-then-write window can leave the file at 0 bytes (#3118). JSON5.parse
// would throw "JSON5: invalid end of input at 1:1"; surface a recovery hint
// instead so callers can route the user to the restart-recovery path rather
// than chasing the parser error.
if (raw.trim() === "") {
throw new Error(
`Config at ${configPath} is empty (0 bytes or whitespace-only). ` +
"Restart the sandbox to trigger baseline recovery, or restore the " +
"file from a known-good copy (see #3118).",
);
}
return parseConfigDocument(JSON5.parse(raw), `Config at ${configPath}`);
}

Expand Down
169 changes: 169 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,159 @@ restore_openclaw_config_after_write() {
lock_config_after_write "$config_file" "$hash_file"
}

# ── Empty-config recovery and baseline (#3118) ──────────────────
# Upstream OpenShell's `openshell inference set` (run inside the sandbox to
# change the runtime model) can truncate /sandbox/.openclaw/openclaw.json to
# 0 bytes when its write fails partway through. The corrupted file then
# breaks `openclaw doctor --fix` (its own JSON5.parse crashes on empty
# input) and any other consumer of the config.
#
# These two functions are NemoClaw's defensive recovery — they don't fix the
# upstream bugs (which still need to be filed against OpenShell and OpenClaw)
# but they let a sandbox restart restore working state instead of leaving the
# sandbox unusable. Both are scoped to mutable-default mode: in shields-up
# mode openclaw.json is root-owned and immutable, so an empty file there
# implies tampering (which integrity check should catch) rather than the
# #3118 trigger (which requires a writable config).

# Capture a known-good copy of openclaw.json for later restore. Idempotent:
# only writes the baseline once. Runs at root after apply_model_override and
# apply_cors_override so the baseline reflects the post-override config that
# the user actually started with. Refuses to capture broken state (empty,
# whitespace-only, or unparseable input).
write_openclaw_config_baseline() {
local config_dir="/sandbox/.openclaw"
local config_file="$config_dir/openclaw.json"
local baseline_file="$config_dir/openclaw.json.nemoclaw-baseline"

[ -d "$config_dir" ] || return 0
[ -f "$config_file" ] || return 0
[ "$(id -u)" -eq 0 ] || return 0

# Refuse to act through symlinks (mirrors apply_model_override's stance).
if [ -L "$config_dir" ] || [ -L "$config_file" ] || [ -L "$baseline_file" ]; then
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
return 0
fi

# Refuse to capture content that doesn't parse as JSON5 — keeps the
# 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
return 0
fi

if ! cp "$config_file" "$baseline_file" 2>/dev/null; then
return 0
fi
# 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
printf '[config] Baseline snapshot created: %s\n' "$baseline_file" >&2
}

# Restore openclaw.json from a baseline when the active file has been
# truncated to 0 bytes / whitespace-only. Runs at startup before
# verify_config_integrity_if_locked. Prefers OpenClaw's own
# openclaw.json.last-good (if it exists and is non-empty) over our
# nemoclaw-baseline so we ride OpenClaw's recovery convention when both
# are available. Recomputes .config-hash on success so subsequent
# integrity checks pass.
recover_openclaw_config_if_empty() {
local config_dir="/sandbox/.openclaw"
local config_file="$config_dir/openclaw.json"
local hash_file="$config_dir/.config-hash"
local baseline_file="$config_dir/openclaw.json.nemoclaw-baseline"
local last_good_file="$config_dir/openclaw.json.last-good"

[ -d "$config_dir" ] || return 0
[ -f "$config_file" ] || return 0

# Refuse to act through symlinks.
if [ -L "$config_dir" ] || [ -L "$config_file" ] || [ -L "$hash_file" ]; then
return 0
fi

# Skip in shields-up mode — see header comment.
if [ "$(openclaw_config_dir_owner "$config_dir")" = "root" ]; then
return 0
fi

# Active file is non-empty → no-op.
if grep -q '[^[:space:]]' "$config_file" 2>/dev/null; then
return 0
fi

local source=""
if [ -f "$last_good_file" ] && [ ! -L "$last_good_file" ] \
&& grep -q '[^[:space:]]' "$last_good_file" 2>/dev/null; then
source="$last_good_file"
elif [ -f "$baseline_file" ] && [ ! -L "$baseline_file" ] \
&& grep -q '[^[:space:]]' "$baseline_file" 2>/dev/null; then
source="$baseline_file"
fi

# Recovery failures must be loud, not silent. In mutable-default mode the
# downstream verify_config_integrity_if_locked is intentionally a no-op,
# so a soft-fail here would let startup continue with an empty (or
# restored-but-unhashed) config and crash much later in a less obvious
# place. Return non-zero so `set -e` aborts startup with the diagnostic
# already on stderr.
if [ -z "$source" ]; then
printf '[config] ERROR: openclaw.json is empty (%s). No baseline available; restart cannot recover. See issue #3118.\n' "$config_file" >&2
return 1
fi

if ! cp "$source" "$config_file" 2>/dev/null; then
printf '[config] ERROR: Failed to restore openclaw.json from %s (see #3118)\n' "$source" >&2
return 1
fi
chown sandbox:sandbox "$config_file" 2>/dev/null || true
chmod 660 "$config_file" 2>/dev/null || true

if (cd "$config_dir" && sha256sum openclaw.json >".config-hash") 2>/dev/null; then
chown sandbox:sandbox "$hash_file" 2>/dev/null || true
chmod 660 "$hash_file" 2>/dev/null || true
else
printf '[config] ERROR: Restored openclaw.json from %s but failed to recompute %s (see #3118)\n' "$source" "$hash_file" >&2
return 1
fi

printf '[config] openclaw.json restored from %s (was empty — see #3118)\n' "$source" >&2
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

# Refresh the mutable-default .config-hash so it matches the current
# openclaw.json. Independent of the #3118 recovery above — this runs on
# every start after the override pipeline to keep the hash in sync with
# any in-flight config edits (model override, CORS override, provider
# placeholder refresh).
ensure_mutable_openclaw_config_hash() {
local config_dir="/sandbox/.openclaw"
local config_file="${config_dir}/openclaw.json"
Expand Down Expand Up @@ -1767,6 +1920,10 @@ fi
if [ "$(id -u)" -ne 0 ]; then
echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2
export HOME=/sandbox
# Empty-config recovery runs before integrity check so a #3118 truncation
# (openshell inference set inside the sandbox) is restored from baseline
# rather than failing the integrity hash for the empty file.
recover_openclaw_config_if_empty
if ! verify_config_integrity_if_locked /sandbox/.openclaw; then
echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2
exit 1
Expand All @@ -1777,6 +1934,10 @@ if [ "$(id -u)" -ne 0 ]; then
apply_cors_override
refresh_openclaw_provider_placeholders
ensure_mutable_openclaw_config_hash
# Capture baseline for next start's recovery — only after overrides and
# placeholder refresh have produced the post-startup config the user
# actually runs with.
write_openclaw_config_baseline
export_gateway_token
write_runtime_shell_env
ensure_runtime_shell_env_shim
Expand Down Expand Up @@ -1863,6 +2024,10 @@ fi

# ── Root path (full privilege separation via setpriv) ──────────

# Empty-config recovery runs before integrity check so a #3118 truncation
# (openshell inference set inside the sandbox) is restored from baseline
# rather than failing the integrity hash for the empty file.
recover_openclaw_config_if_empty
# Verify locked config integrity before starting anything. Mutable-default
# config is intentionally writable and is not a trust anchor until shields-up.
verify_config_integrity_if_locked /sandbox/.openclaw
Expand All @@ -1872,6 +2037,10 @@ reconcile_agent_model_with_provider
apply_cors_override
refresh_openclaw_provider_placeholders
ensure_mutable_openclaw_config_hash
# Capture baseline for next start's recovery — only after overrides and
# placeholder refresh have produced the post-startup config the user
# actually runs with.
write_openclaw_config_baseline
export_gateway_token
write_runtime_shell_env
ensure_runtime_shell_env_shim
Expand Down
1 change: 1 addition & 0 deletions test/Dockerfile.sandbox
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ RUN mkdir -p /sandbox/.openclaw/workspace /sandbox/.openclaw/extensions /sandbox
&& rm -f /sandbox/.openclaw/openclaw.json.bak* \
/sandbox/.openclaw/openclaw.json.last-good \
/sandbox/.openclaw/openclaw.json.clobbered.* \
/sandbox/.openclaw/openclaw.json.nemoclaw-baseline \
/sandbox/.openclaw-data/logs/config-health.json \
/sandbox/.openclaw-data/logs/config-audit.jsonl \
&& echo "test-skill" > /sandbox/.openclaw/skills/test.md \
Expand Down
Loading
Loading