diff --git a/docs/inference/set-up-sub-agent.mdx b/docs/inference/set-up-sub-agent.mdx index cfca0f7ef04..06e2093f022 100644 --- a/docs/inference/set-up-sub-agent.mdx +++ b/docs/inference/set-up-sub-agent.mdx @@ -26,7 +26,7 @@ Use these paths inside the sandbox when you adapt an OpenClaw sub-agent setup: | Path | Purpose | |---|---| | `/sandbox/.openclaw/openclaw.json` | OpenClaw config, including `models.providers`, `agents.defaults`, and `agents.list`. | -| `/sandbox/.openclaw/.config-hash` | Hash for `openclaw.json`. Keep it in sync after manual config edits so OpenClaw can detect the updated config. | +| `/sandbox/.openclaw/.config-hash` | Hash for `openclaw.json`. Keep it in sync after manual config edits so OpenClaw can detect the updated config. From the default mutable posture, the next `shields up` synthesizes a missing hash from `openclaw.json`. | | `/sandbox/.openclaw/agents//agent/auth-profiles.json` | Per-agent provider credentials. Use this when a sub-agent calls an auxiliary provider directly. | | `/sandbox/.openclaw/workspace/` | Writable shared workspace path for files the primary agent passes to the sub-agent. | | `/tmp/gateway.log` | OpenClaw gateway log. Use it to confirm config reloads and diagnose sub-agent failures. | @@ -121,6 +121,8 @@ Do not commit `/tmp/openclaw.updated.json` or any other file that contains a rea Upload the patched config and refresh the hash. In the default mutable state, this keeps the local hash consistent but does not make it tamper-proof. Use NemoClaw runtime controls when the sandbox needs a hardened config posture after the manual edit. +From the default mutable posture, `shields up` regenerates a stale hash and synthesizes a missing hash. +Keep the refresh step so OpenClaw detects the update immediately. ```bash docker exec --user root "$SANDBOX_CTR" chmod 644 /sandbox/.openclaw/openclaw.json diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 8266cbc9c11..1ec0dac28b3 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -171,6 +171,15 @@ Use this recovery path only when losing the state that could not be backed up is When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation. A detached auto-lock timer remains the recovery authority until NemoClaw commits a successful shields-up state, including when the host rebuild process exits unexpectedly. + +If a failed shields transition on a sandbox from an older NemoClaw release quarantined the OpenClaw config, the bytes are preserved as `/sandbox/.openclaw/.nemoclaw-rejected-openclaw.json-` rather than deleted. +Upgrade the NemoClaw CLI before rebuilding because an older CLI restages the older in-container guard. +To preserve settings, copy the quarantine file out of the container before `rebuild --yes`. +After the rebuild, inspect that copy and reapply required settings with the host-side `config set` command. +To discard the quarantined settings, upgrade the CLI and run `rebuild --yes` to create a known-good baseline. +Sandboxes with the updated guard report quarantine filenames and synthesize a missing `.config-hash` only during `shields up` from the default mutable posture. + + For an older Hermes image that predates sealed shields transitions, only the rebuild workflow may use the descriptor-safe compatibility transition needed to archive and replace the sandbox. That transition verifies the strict and compatibility hashes and publishes fresh config inodes before changing their lock posture, while ordinary `shields up` and `shields down` commands continue to refuse the older protocol. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 9413d576540..2232e6a504d 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1631,6 +1631,31 @@ If the sandbox still cannot start or reports that no baseline is available, rebu $$nemoclaw rebuild ``` +### `shields up` or `shields down` fails after `.config-hash` was removed + +`/sandbox/.openclaw/.config-hash` is the integrity sidecar for `openclaw.json`; deleting it during a manual config edit removes the file the shields guard captures alongside the config. +From the default mutable posture, `$$nemoclaw shields up` regenerates a stale hash from the current `openclaw.json` bytes. +On sandboxes with the updated guard, the same command synthesizes a truly absent `.config-hash` under the frozen tree. +Only a truly absent file is repaired; an unexpected file type at that name still fails closed. +`$$nemoclaw shields down` does not synthesize the hash: with the file missing it fails closed without modifying the config. +If shields are already up, another `shields up` also fails closed when the hash is missing. +Do not use mutable-posture synthesis to recover a locked sandbox. + +Sandboxes created by an older NemoClaw release keep the older guard baked into the container image, where a missing `.config-hash` makes the transition fail closed and quarantine `openclaw.json` by renaming it to `.nemoclaw-rejected-openclaw.json-` in the same directory. +The config bytes are preserved, not deleted. +Upgrade the NemoClaw CLI before either recovery path because an older CLI restages the older guard. +To preserve the quarantined settings, copy the file to the host before rebuilding: + +```bash +docker exec ls -a /sandbox/.openclaw +docker cp :/sandbox/.openclaw/ ./openclaw.json.recovered +$$nemoclaw rebuild --yes +``` + +After the rebuild, inspect `./openclaw.json.recovered` and reapply required settings with the host-side `config set` command. +Do not overwrite the regenerated `openclaw.json` with an unreviewed quarantine copy. +To discard the quarantined settings, upgrade the CLI and run `$$nemoclaw rebuild --yes` without copying the file. + diff --git a/scripts/openclaw-config-guard.py b/scripts/openclaw-config-guard.py index fb5dedfd54e..dd6c16b263a 100755 --- a/scripts/openclaw-config-guard.py +++ b/scripts/openclaw-config-guard.py @@ -3264,6 +3264,48 @@ def _preflight_restart(opened: OpenConfig, identity: Identity) -> None: _assert_config_binding(opened) +_hash_synthesized = False + + +def _write_hash_record(opened: OpenConfig, config_data: bytes, identity: Identity) -> None: + digest = hashlib.sha256(config_data).hexdigest() + _force_replace_bytes( + opened, + ".config-hash", + f"{digest} openclaw.json\n".encode("ascii"), + identity, + ) + + +def _repair_absent_hash_for_lock(opened: OpenConfig, identity: Identity) -> None: + """Synthesize a truly absent .config-hash before lock-from-mutable capture. + + On lock-from-mutable the canonical pair is regenerated from openclaw.json + bytes regardless of the stored hash content, so an absent sidecar carries + less signal than the tolerated stale-content case. The repair fires only on + true ENOENT under the frozen tree: a planted symlink, directory, fifo, or + hardlink at the name is seen as existing and falls through to the existing + fail-closed rejections. + """ + + global _hash_synthesized + try: + os.stat(".config-hash", dir_fd=opened.config_fd, follow_symlinks=False) + return + except FileNotFoundError: + pass + _verify_dir_posture( + opened.config_fd, + opened.config_path, + identity.root_uid, + identity.root_gid, + 0o700, + ) + config = _snapshot_file(opened, "openclaw.json") + _write_hash_record(opened, config.data, identity) + _hash_synthesized = True + + def _force_fail_closed_lock(opened: OpenConfig, identity: Identity) -> list[str]: errors: list[str] = [] targets: tuple[FileSnapshot, FileSnapshot] | None = None @@ -3291,22 +3333,37 @@ def _force_fail_closed_lock(opened: OpenConfig, identity: Identity) -> list[str] except Exception as force_exc: errors.append(f"forced fresh pair: {force_exc}") else: - # No bounded pair could be captured. Sever each canonical path - # rather than retaining an attacker-held writable inode. - for name in CONFIG_FILES: - try: - os.rename( - name, - f".nemoclaw-rejected-{name.lstrip('.')}-{secrets.token_hex(16)}", - src_dir_fd=opened.config_fd, - dst_dir_fd=opened.config_fd, + published = False + try: + config = _snapshot_file(opened, "openclaw.json") + _force_replace_bytes(opened, "openclaw.json", config.data, identity) + _write_hash_record(opened, config.data, identity) + _snapshot_pair(opened) + published = True + except Exception as publish_exc: + errors.append(f"config-only publish: {publish_exc}") + if not published: + # No bounded config could be republished. Sever each canonical + # path rather than retaining an attacker-held writable inode. + for name in CONFIG_FILES: + rejected = ( + f".nemoclaw-rejected-{name.lstrip('.')}-{secrets.token_hex(16)}" ) - except FileNotFoundError: - # A concurrently absent canonical name is already severed. - pass - except Exception as file_exc: - errors.append(f"{name}: {file_exc}") - os.fsync(opened.config_fd) + try: + os.rename( + name, + rejected, + src_dir_fd=opened.config_fd, + dst_dir_fd=opened.config_fd, + ) + except FileNotFoundError: + # A concurrently absent canonical name is already severed. + pass + except Exception as file_exc: + errors.append(f"{name}: {file_exc}") + else: + errors.append(f"{name}: quarantined as {rejected}") + os.fsync(opened.config_fd) try: _commit_locked_dirs(opened, identity) except Exception as exc: @@ -3385,6 +3442,7 @@ def _transition( freeze_started = True _freeze(opened, identity) _settle_pending_transaction_for_lock(opened, identity) + _repair_absent_hash_for_lock(opened, identity) source = _snapshot_raw_pair(opened) targets, _digest = _canonical_targets(source, identity, locked=True) _install_stored_pair(opened, targets) @@ -4200,6 +4258,7 @@ def main(argv: list[str] | None = None) -> int: "files": list(CONFIG_FILES), "chattrApplied": False, **({"configSha256": new_digest} if new_digest is not None else {}), + **({"hashSynthesized": True} if _hash_synthesized else {}), **({"recovery": recovery} if recovery is not None else {}), **( {"originalLocked": original_locked} diff --git a/src/lib/shields/openclaw-config-lock.test.ts b/src/lib/shields/openclaw-config-lock.test.ts index e594ed99d80..4a584aadd74 100644 --- a/src/lib/shields/openclaw-config-lock.test.ts +++ b/src/lib/shields/openclaw-config-lock.test.ts @@ -314,4 +314,57 @@ describe("OpenClaw top-config guard host wiring", () => { expect.stringContaining("capability probe failed"), ]); }); + + it("sanitizes non-printable bytes and caps oversized guard issue text", () => { + const result: PrivilegedExecResult = { + status: 1, + signal: null, + stdout: [ + JSON.stringify({ + type: "issue", + code: "transition-failed\u001b[31m", + path: `${OPENCLAW_CONFIG_DIR}/openclaw.json\u0007`, + detail: `quarantined as .nemoclaw-rejected-openclaw.json-abc\u0000\u001b]0;title\u0007${"x".repeat(4096)}`, + }), + JSON.stringify({ type: "result", action: "lock", status: "failed" }), + ].join("\n"), + stderr: "", + }; + + const issues = parseOpenClawConfigGuardOutput("lock", result).issues; + + expect(issues[0]).toContain("[transition-failed"); + expect(issues[0]).toContain("quarantined as .nemoclaw-rejected-openclaw.json-abc"); + expect(issues[0]).not.toMatch(/[^\x20-\x7e]/); + expect(issues[0]?.length).toBeLessThan(2500); + }); + + it("propagates the guard's synthesized-hash marker on a successful lock", () => { + const synthesized: PrivilegedExecResult = { + status: 0, + signal: null, + stdout: `${JSON.stringify({ + type: "result", + action: "lock", + status: "ok", + configDir: OPENCLAW_CONFIG_DIR, + files: ["openclaw.json", ".config-hash"], + chattrApplied: false, + hashSynthesized: true, + })}\n`, + stderr: "", + }; + const plain: PrivilegedExecResult = { + status: 0, + signal: null, + stdout: `${success("lock")}\n`, + stderr: "", + }; + + const parsed = parseOpenClawConfigGuardOutput("lock", synthesized); + + expect(parsed.issues).toEqual([]); + expect(parsed.hashSynthesized).toBe(true); + expect(parseOpenClawConfigGuardOutput("lock", plain).hashSynthesized).toBeUndefined(); + }); }); diff --git a/src/lib/shields/openclaw-config-lock.ts b/src/lib/shields/openclaw-config-lock.ts index 21ef19263be..94e15f4ea3b 100644 --- a/src/lib/shields/openclaw-config-lock.ts +++ b/src/lib/shields/openclaw-config-lock.ts @@ -64,6 +64,7 @@ type GuardSummary = { files?: string[]; chattrApplied?: boolean; configSha256?: string; + hashSynthesized?: boolean; recovery?: string; originalLocked?: boolean; }; @@ -72,6 +73,7 @@ export type OpenClawConfigGuardResult = { issues: string[]; chattrApplied: boolean; configSha256?: string; + hashSynthesized?: boolean; recovery?: string; originalLocked?: boolean; }; @@ -102,6 +104,13 @@ function stringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } +function printableExcerpt(value: string, maxLength: number): string { + return [...value.slice(0, maxLength)] + .map((character) => (/^[\x20-\x7e]$/.test(character) ? character : " ")) + .join("") + .trim(); +} + function schemaIssuePaths(payload: unknown): string[] { if (!payload || typeof payload !== "object") return []; const issues = (payload as { issues?: unknown }).issues; @@ -111,10 +120,7 @@ function schemaIssuePaths(payload: unknown): string[] { if (!issue || typeof issue !== "object") continue; const path = (issue as { path?: unknown }).path; if (typeof path !== "string") continue; - const sanitized = [...path.slice(0, 256)] - .map((character) => (/^[\x20-\x7e]$/.test(character) ? character : " ")) - .join("") - .trim(); + const sanitized = printableExcerpt(path, 256); if (sanitized && !paths.includes(sanitized)) paths.push(sanitized); } return paths; @@ -223,6 +229,7 @@ export function parseOpenClawConfigGuardOutput( (record.files === undefined || stringArray(record.files)) && (record.chattrApplied === undefined || typeof record.chattrApplied === "boolean") && (record.configSha256 === undefined || typeof record.configSha256 === "string") && + (record.hashSynthesized === undefined || typeof record.hashSynthesized === "boolean") && (record.recovery === undefined || typeof record.recovery === "string") && (record.originalLocked === undefined || typeof record.originalLocked === "boolean") ) { @@ -283,7 +290,8 @@ export function parseOpenClawConfigGuardOutput( return { issues: [ ...issues.map( - (issue) => `OpenClaw config guard ${action} [${issue.code}] ${issue.path}: ${issue.detail}`, + (issue) => + `OpenClaw config guard ${action} [${printableExcerpt(issue.code, 64)}] ${printableExcerpt(issue.path, 256)}: ${printableExcerpt(issue.detail, 2048)}`, ), ...contractIssues, ], @@ -291,6 +299,9 @@ export function parseOpenClawConfigGuardOutput( ...(summary?.status === "ok" && summary.configSha256 ? { configSha256: summary.configSha256 } : {}), + ...(summary?.status === "ok" && summary.hashSynthesized === true + ? { hashSynthesized: true } + : {}), ...(summary?.status === "ok" && summary.recovery ? { recovery: summary.recovery } : {}), ...(summary?.status === "ok" && typeof summary.originalLocked === "boolean" ? { originalLocked: summary.originalLocked } diff --git a/test/openclaw-config-guard-absent-hash.test.ts b/test/openclaw-config-guard-absent-hash.test.ts new file mode 100644 index 00000000000..5142ca7b6ed --- /dev/null +++ b/test/openclaw-config-guard-absent-hash.test.ts @@ -0,0 +1,319 @@ +// 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 os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const GUARD_PATH = path.resolve("scripts/openclaw-config-guard.py"); +const fixtures: string[] = []; + +const RUN_AS_CURRENT_USER = String.raw` +import importlib.util +import os +import sys + +guard_path, action, config_dir, failure = sys.argv[1:5] +spec = importlib.util.spec_from_file_location("nemoclaw_openclaw_config_guard", guard_path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +identity = module.Identity( + root_uid=os.getuid(), + root_gid=os.getgid(), + sandbox_uid=os.getuid(), + sandbox_gid=os.getgid(), +) +module.os.geteuid = lambda: 0 +module._production_identity = lambda: identity +module.PRODUCTION_CONFIG_DIR = config_dir +module.JOURNAL_PATH = os.path.join(os.path.dirname(config_dir), ".nemoclaw-test", "transaction.json") +module.MUTEX_PATH = os.path.join(os.path.dirname(config_dir), ".nemoclaw-test", "mutation.lock") +module.STARTUP_READY_PATH = os.path.join(os.path.dirname(config_dir), ".nemoclaw-test", "ready.json") +module.STARTUP_CAPABILITY_PATH = os.path.join(os.path.dirname(config_dir), ".nemoclaw-test", "ready-capability.json") +module.NODE_BINARY_PATH = os.environ.get("NEMOCLAW_TEST_NODE_PATH", module.NODE_BINARY_PATH) +module.JSON5_MODULE_PATH = os.environ.get("NEMOCLAW_TEST_JSON5_PATH", module.JSON5_MODULE_PATH) +if failure in {"install-fails-hash-vanishes", "install-fails-hash-vanishes-publish-fails"}: + def vanish_then_fail(opened, targets): + os.unlink(os.path.join(config_dir, ".config-hash")) + raise OSError("injected install failure") + module._install_stored_pair = vanish_then_fail +if failure == "install-fails-hash-vanishes-publish-fails": + def refuse_publish(opened, name, data, identity): + raise OSError("injected publish failure") + module._force_replace_bytes = refuse_publish +raise SystemExit(module.main([action, "--config-dir", config_dir])) +`; + +type GuardLine = { + type: "issue" | "result"; + action?: string; + status?: string; + code?: string; + path?: string; + detail?: string; + chattrApplied?: boolean; + configSha256?: string; + hashSynthesized?: boolean; + recovery?: string; + originalLocked?: boolean; +}; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function trustedNodePath(configDir: string): string { + return path.join(path.dirname(configDir), ".nemoclaw-test-node"); +} + +const CONFIG_BYTES = Buffer.from('{"gateway":{"port":18789}}\n'); +const CONFIG_HASH_RECORD = `${createHash("sha256").update(CONFIG_BYTES).digest("hex")} openclaw.json\n`; + +function fixture() { + const created = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-guard-absent-hash-")); + const root = fs.realpathSync(created); + fixtures.push(root); + const configDir = path.join(root, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + const nodePath = trustedNodePath(configDir); + fs.mkdirSync(configDir); + fs.writeFileSync(nodePath, `#!/bin/sh\nexec ${shellQuote(process.execPath)} "$@"\n`, { + mode: 0o500, + }); + fs.writeFileSync(configPath, CONFIG_BYTES, { mode: 0o660 }); + fs.writeFileSync(hashPath, CONFIG_HASH_RECORD, { mode: 0o660 }); + fs.chmodSync(configPath, 0o660); + fs.chmodSync(hashPath, 0o660); + fs.chmodSync(configDir, 0o2770); + fs.chmodSync(root, 0o755); + return { root, configDir, configPath, hashPath }; +} + +function runGuard(action: "lock" | "unlock", configDir: string, failure = "none") { + const result = spawnSync( + "python3", + ["-c", RUN_AS_CURRENT_USER, GUARD_PATH, action, configDir, failure], + { + encoding: "utf-8", + timeout: 15_000, + env: { + ...process.env, + NEMOCLAW_TEST_NODE_PATH: trustedNodePath(configDir), + NEMOCLAW_TEST_JSON5_PATH: path.resolve("nemoclaw/node_modules/json5"), + }, + maxBuffer: 32 * 1024 * 1024, + }, + ); + const lines = result.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as GuardLine); + return { ...result, lines }; +} + +function mode(filePath: string): number { + return fs.lstatSync(filePath).mode & 0o7777; +} + +function rejectedNames(configDir: string): string[] { + return fs.readdirSync(configDir).filter((name) => name.startsWith(".nemoclaw-rejected-")); +} + +afterEach(() => { + for (const root of fixtures.splice(0)) { + try { + fs.chmodSync(root, 0o700); + const configDir = path.join(root, ".openclaw"); + for (const existingConfigDir of fs.existsSync(configDir) && + !fs.lstatSync(configDir).isSymbolicLink() + ? [configDir] + : []) { + fs.chmodSync(existingConfigDir, 0o700); + for (const name of fs.readdirSync(existingConfigDir)) { + const filePath = path.join(existingConfigDir, name); + for (const existingFilePath of fs.lstatSync(filePath).isFile() ? [filePath] : []) { + fs.chmodSync(existingFilePath, 0o600); + } + } + } + } catch { + // Best effort before recursive fixture cleanup. + } + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("openclaw-config-guard lock with an absent .config-hash", () => { + it("locks from mutable by synthesizing the hash from openclaw.json", () => { + const { root, configDir, configPath, hashPath } = fixture(); + fs.rmSync(hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status, JSON.stringify(result.lines)).toBe(0); + expect(result.lines.at(-1)).toMatchObject({ + type: "result", + action: "lock", + status: "ok", + hashSynthesized: true, + }); + expect(mode(root)).toBe(0o1775); + expect(mode(configDir)).toBe(0o755); + expect(mode(configPath)).toBe(0o444); + expect(mode(hashPath)).toBe(0o444); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(CONFIG_HASH_RECORD); + expect(rejectedNames(configDir)).toEqual([]); + }); + + it("relocks idempotently after a synthesized-hash lock without rewriting inodes", () => { + const { configDir, configPath, hashPath } = fixture(); + fs.rmSync(hashPath); + expect(runGuard("lock", configDir).status).toBe(0); + const configInode = fs.lstatSync(configPath).ino; + const hashInode = fs.lstatSync(hashPath).ino; + + const second = runGuard("lock", configDir); + + expect(second.status, JSON.stringify(second.lines)).toBe(0); + expect(second.lines.at(-1)?.hashSynthesized).toBeUndefined(); + expect(fs.lstatSync(configPath).ino).toBe(configInode); + expect(fs.lstatSync(hashPath).ino).toBe(hashInode); + expect(mode(configPath)).toBe(0o444); + expect(mode(hashPath)).toBe(0o444); + }); + + it("stays fail-closed when openclaw.json and the hash are both absent", () => { + const { configDir, configPath, hashPath } = fixture(); + fs.rmSync(configPath); + fs.rmSync(hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "issue", code: "stat-failed" })]), + ); + expect(fs.existsSync(configPath)).toBe(false); + expect(fs.existsSync(hashPath)).toBe(false); + expect(rejectedNames(configDir)).toEqual([]); + }); + + it("preserves openclaw.json through a fail-closed lock when the hash vanishes mid-transition", () => { + const { configDir, configPath, hashPath } = fixture(); + + const result = runGuard("lock", configDir, "install-fails-hash-vanishes"); + + expect(result.status).toBe(1); + expect(mode(configPath)).toBe(0o444); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(mode(hashPath)).toBe(0o444); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(CONFIG_HASH_RECORD); + expect(rejectedNames(configDir)).toEqual([]); + }); + + it("locks a pristine pair without recording a synthesized hash", () => { + const { configDir } = fixture(); + + const result = runGuard("lock", configDir); + + expect(result.status, JSON.stringify(result.lines)).toBe(0); + expect(result.lines.at(-1)).toMatchObject({ type: "result", action: "lock", status: "ok" }); + expect(result.lines.at(-1)?.hashSynthesized).toBeUndefined(); + }); + + it("refuses to repair a planted symlink at .config-hash and fails closed", () => { + const { configDir, configPath, hashPath } = fixture(); + fs.rmSync(hashPath); + fs.symlinkSync("openclaw.json", hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "issue", code: "unsafe-config-file" }), + ]), + ); + expect(fs.lstatSync(hashPath).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(hashPath).isFile()).toBe(true); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(rejectedNames(configDir).filter((name) => name.includes("openclaw"))).toEqual([]); + }); + + it("refuses to repair a dangling symlink at .config-hash and fails closed", () => { + const { configDir, configPath, hashPath } = fixture(); + fs.rmSync(hashPath); + fs.symlinkSync("does-not-exist", hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status, JSON.stringify(result.lines)).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "issue", code: "unsafe-config-file" }), + ]), + ); + expect(fs.lstatSync(hashPath).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(rejectedNames(configDir).filter((name) => name.includes("openclaw"))).toEqual([]); + }); + + it("keeps unlock fail-closed when the hash is absent", () => { + const { configDir, configPath, hashPath } = fixture(); + fs.rmSync(hashPath); + + const result = runGuard("unlock", configDir); + + expect(result.status).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "issue", code: "stat-failed" })]), + ); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(fs.existsSync(hashPath)).toBe(false); + expect(rejectedNames(configDir)).toEqual([]); + }); + + it("keeps a locked-posture relock fail-closed when the hash was removed", () => { + const { configDir, configPath, hashPath } = fixture(); + expect(runGuard("lock", configDir).status).toBe(0); + fs.rmSync(hashPath); + + const result = runGuard("lock", configDir); + + expect(result.status).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "issue", code: "stat-failed" })]), + ); + expect(mode(configPath)).toBe(0o444); + expect(fs.readFileSync(configPath)).toEqual(CONFIG_BYTES); + expect(fs.existsSync(hashPath)).toBe(false); + expect(rejectedNames(configDir)).toEqual([]); + }); + + it("reports the quarantine name when the last-resort sever renames openclaw.json", () => { + const { configDir, configPath } = fixture(); + + const result = runGuard("lock", configDir, "install-fails-hash-vanishes-publish-fails"); + + expect(result.status).toBe(1); + expect(result.lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "issue", + detail: expect.stringContaining("openclaw.json: quarantined as .nemoclaw-rejected-"), + }), + ]), + ); + expect(fs.existsSync(configPath)).toBe(false); + const [rejected] = rejectedNames(configDir); + expect(rejected).toMatch(/^\.nemoclaw-rejected-openclaw\.json-[0-9a-f]{32}$/); + expect(fs.readFileSync(path.join(configDir, rejected))).toEqual(CONFIG_BYTES); + }); +});