diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index e05ca34af19..46484a8f706 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -59,6 +59,8 @@ SHIELDS_TRANSITION_LEASE_SECONDS = 300 STATE_WORKER_LEASE_SECONDS = 15 * 60 HERMES_STARTUP_READY_FILE = "/run/nemoclaw/hermes-startup-ready" +HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle" +ROOT_LIFECYCLE_FINISH_CAPABILITY = "root-lifecycle-finish-v1" NEMOCLAW_RUNTIME_DIR = "/run/nemoclaw" NEMOCLAW_RUNTIME_DIR_MODE = 0o711 HERMES_RESTART_STATE_FILE = "/run/nemoclaw/hermes-restart-seal.json" @@ -3542,8 +3544,7 @@ def begin_shields_transition( finally: os.close(hermes_fd) - state_data["phase"] = "shields-transition-pending" - state_data["shields_transition"] = { + transition: dict[str, object] = { "mode": mode, "original_locked": original_locked, "rollback_mode": rollback_mode @@ -3551,6 +3552,12 @@ def begin_shields_transition( "lease_expires_ns": time.time_ns() + SHIELDS_TRANSITION_LEASE_SECONDS * 1_000_000_000, } + if mode == "mutable": + transition["root_lifecycle_topology"] = ( + _inspect_hermes_root_lifecycle_topology() + ) + state_data["phase"] = "shields-transition-pending" + state_data["shields_transition"] = transition _write_restart_state(state_file, state_data, create=False) return lock_token, original_locked except Exception: @@ -3986,6 +3993,33 @@ def _freeze_shields_directories(state_data: dict[str, object], hermes_dir: str) os.close(parent_fd) +def _inspect_hermes_root_lifecycle_topology() -> str: + try: + os.lstat(HERMES_ROOT_LIFECYCLE_MARKER) + except FileNotFoundError: + return "managed-nonroot" + except OSError as exc: + raise UnsafePathError( + "refusing Hermes root lifecycle topology probe failure" + ) from exc + return "root-separated" + + +def _private_mutable_hermes_root_allowed( + transition: dict[str, object], + mode: str, + recorded_mode: object, + observed_mode: int, +) -> bool: + return ( + mode == "mutable" + and recorded_mode == 0o3770 + and observed_mode == 0o700 + and transition.get("root_lifecycle_topology") == "managed-nonroot" + and _inspect_hermes_root_lifecycle_topology() == "managed-nonroot" + ) + + def finish_shields_transition( hermes_dir: str, hash_file: str, state_file: str, lock_token: str ) -> tuple[str, bool]: @@ -4023,11 +4057,20 @@ def finish_shields_transition( hermes_st = os.fstat(hermes_fd) if not _same_inode(hermes_st, hermes_meta): raise UnsafePathError("refusing shields finish because .hermes changed") - if ( - hermes_st.st_uid != hermes_meta.get("uid") - or hermes_st.st_gid != hermes_meta.get("gid") - or stat.S_IMODE(hermes_st.st_mode) != hermes_meta.get("mode") - ): + observed_mode = stat.S_IMODE(hermes_st.st_mode) + owner_matches = ( + hermes_st.st_uid == hermes_meta.get("uid") + and hermes_st.st_gid == hermes_meta.get("gid") + ) + mode_matches = observed_mode == hermes_meta.get("mode") + if owner_matches and not mode_matches: + mode_matches = _private_mutable_hermes_root_allowed( + transition, + mode, + hermes_meta.get("mode"), + observed_mode, + ) + if not owner_matches or not mode_matches: raise UnsafePathError( "refusing shields finish because .hermes metadata drifted" ) @@ -4723,7 +4766,9 @@ def provider_placeholders( def main() -> int: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + epilog=f"capabilities: {ROOT_LIFECYCLE_FINISH_CAPABILITY}" + ) parser.add_argument( "action", choices=( diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 31ded8ba9d8..e7a55e52b99 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -123,7 +123,7 @@ When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down win 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. -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. +For an older Hermes image that predates sealed shields transitions or lacks the current sealed-shields finish capability, 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/src/lib/shields/index.ts b/src/lib/shields/index.ts index 8556619ad56..7e8cd18aa1e 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -106,6 +106,7 @@ const AUTO_RESTORE_COMPLETION_GRACE_MS = 30_000; const HERMES_RUNTIME_CONFIG_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; const HERMES_RESTART_SEAL_STATE = "/run/nemoclaw/hermes-restart-seal.json"; +const HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle"; const HERMES_CONFIG_HASH = "/etc/nemoclaw/hermes.config-hash"; const STATE_DIR_GUARD_TIMEOUT_MS = 15 * 60 * 1000; const OPENCLAW_CONFIG_GUARD_TIMEOUT_MS = 6 * 60 * 1000; @@ -465,6 +466,22 @@ function privilegedSandboxExecCapture(sandboxName: string, cmd: string[], timeou }).trim(); } +function inspectHermesRootLifecycleTopology( + sandboxName: string, +): "managed-nonroot" | "root-separated" { + const topology = privilegedSandboxExecCapture(sandboxName, [ + "sh", + "-c", + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then printf managed-nonroot; else printf root-separated; fi', + "sh", + HERMES_ROOT_LIFECYCLE_MARKER, + ]); + if (topology !== "managed-nonroot" && topology !== "root-separated") { + throw new Error(`Unexpected Hermes workload topology response: ${topology}`); + } + return topology; +} + function hermesShieldsGuardArgs( action: string, target: AgentConfigTarget, @@ -498,6 +515,7 @@ const HERMES_SEALED_SHIELDS_CONTRACT = [ "prepare-shields-abort", "abort-shields-transition", "--rollback-shields-mode", + "root-lifecycle-finish-v1", ] as const; const HERMES_LEGACY_GUARD_CONTRACT = [ "ensure-api-key", @@ -1728,7 +1746,22 @@ function unlockAgentConfigUnderMutationLock( target.configDir, ]); const [mode, owner] = dirPerms.split(" "); - if (mode !== dirMode) issues.push(`config dir mode=${mode} (expected ${dirMode})`); + // A sealed transaction has already attested a live Hermes topology. The + // managed same-UID topology can tolerate the dashboard tightening its + // mutable home to 0700; the root-separated gateway still needs 03770. + const privateHermesRootAllowed = + target.agentName === "hermes" && + mode === "700" && + transaction !== null && + inspectHermesRootLifecycleTopology(sandboxName) === "managed-nonroot"; + const validDirMode = mode === dirMode || privateHermesRootAllowed; + if (!validDirMode) { + const expectedDirModes = + target.agentName === "hermes" && transaction !== null + ? `${dirMode}, or 700 in the managed non-root topology` + : dirMode; + issues.push(`config dir mode=${mode} (expected ${expectedDirModes})`); + } if (owner !== "sandbox:sandbox") { issues.push(`config dir owner=${owner} (expected sandbox:sandbox)`); } diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 3181d920a6f..363dc1ffe37 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; @@ -12,10 +13,11 @@ const requireSource = createRequire(import.meta.url); const INDEX_MODULE = "./index.js"; const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; +const HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle"; const LOCK_TOKEN = "a".repeat(64); const OLD_GUARD_HELP = "usage: guard {ensure-api-key,refresh-hashes,provider-placeholders}"; const PARTIAL_GUARD_HELP = "begin-shields-transition --rollback-shields-mode"; -const CURRENT_GUARD_HELP = [ +const PRE_PRIVATE_ROOT_GUARD_HELP = [ "begin-shields-transition", "run-state-dir-transition", "apply-shields-transition", @@ -23,9 +25,12 @@ const CURRENT_GUARD_HELP = [ "prepare-shields-abort", "abort-shields-transition", "--rollback-shields-mode", + OLD_GUARD_HELP, ].join(" "); +const CURRENT_GUARD_HELP = `${PRE_PRIVATE_ROOT_GUARD_HELP} root-lifecycle-finish-v1`; type ShieldsModule = typeof import("./index"); +type HermesRootLifecycleMarkerState = "missing" | "regular" | "dangling-symlink"; function hermesTarget() { return { @@ -128,7 +133,24 @@ describe("legacy Hermes shields compatibility", () => { fs.rmSync(homeDir, { recursive: true, force: true }); }); - function installExecResponses(help: string): void { + function installExecResponses( + help: string, + hermesDirMode = "3770", + markerState: HermesRootLifecycleMarkerState = "missing", + topologyResponseOverride?: string, + topologyProbeError?: Error, + ): void { + const localMarker = path.join(homeDir, "hermes-root-lifecycle"); + switch (markerState) { + case "missing": + break; + case "regular": + fs.writeFileSync(localMarker, "root-separated\n"); + break; + case "dangling-symlink": + fs.symlinkSync(`${localMarker}.missing`, localMarker); + break; + } dockerExecSpy.mockImplementation((cmd: string[]) => { switch (true) { case cmd.includes(HERMES_GUARD) && cmd.includes("--help"): @@ -138,9 +160,26 @@ describe("legacy Hermes shields compatibility", () => { case isGuardAction(cmd, "apply-shields-transition"): return "shields_mode=mutable chattr_applied=0"; case cmd[0] === "stat": - return cmd.at(-1) === "/sandbox/.hermes" ? "3770 sandbox:sandbox" : "640 sandbox:sandbox"; + return cmd.at(-1) === "/sandbox/.hermes" + ? `${hermesDirMode} sandbox:sandbox` + : "640 sandbox:sandbox"; case cmd[0] === "lsattr": return `---------------- ${cmd.at(-1)}`; + case cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER): { + switch (topologyProbeError) { + case undefined: + break; + default: + throw topologyProbeError; + } + const localProbe = cmd.map((arg) => + arg === HERMES_ROOT_LIFECYCLE_MARKER ? localMarker : arg, + ); + return ( + topologyResponseOverride ?? + execFileSync(localProbe[0], localProbe.slice(1), { encoding: "utf8" }) + ); + } default: return ""; } @@ -210,6 +249,21 @@ describe("legacy Hermes shields compatibility", () => { expect(commands.some((cmd) => isGuardAction(cmd, "begin-shields-transition"))).toBe(false); }); + it("rejects a pre-capability sealed guard before policy or state mutation", () => { + installExecResponses(PRE_PRIVATE_ROOT_GUARD_HELP); + + expect(() => + shields.shieldsDown("pre-private-root-hermes", { + skipTimer: true, + throwOnError: true, + }), + ).toThrow(/predates sealed shields transitions|incomplete|rebuild/i); + + expect(runSpy).not.toHaveBeenCalled(); + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => isGuardAction(cmd, "begin-shields-transition"))).toBe(false); + }); + it("permits only an explicitly authorized legacy path to use the descriptor-safe top-level unlock", () => { installExecResponses(OLD_GUARD_HELP); @@ -264,6 +318,94 @@ describe("legacy Hermes shields compatibility", () => { expect(commands.some(isInlinePython)).toBe(false); }); + it("accepts the dashboard-tightened private Hermes root after a sealed unlock", () => { + installExecResponses(CURRENT_GUARD_HELP, "700"); + + expect(() => + shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true), + ).not.toThrow(); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(true); + }); + + it("rejects a private Hermes root in the root-separated topology", () => { + installExecResponses(CURRENT_GUARD_HELP, "700", "regular"); + + expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow( + /managed non-root topology/, + ); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false); + }); + + it("rejects a dangling lifecycle marker as root-separated", () => { + installExecResponses(CURRENT_GUARD_HELP, "700", "dangling-symlink"); + + expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow( + /managed non-root topology/, + ); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false); + }); + + it("rejects an unexpected Hermes topology response before finishing", () => { + installExecResponses(CURRENT_GUARD_HELP, "700", "missing", "unexpected-topology"); + + expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow( + /Unexpected Hermes workload topology response/, + ); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false); + }); + + it("aborts the sealed transaction when the Hermes topology probe fails", () => { + installExecResponses( + CURRENT_GUARD_HELP, + "700", + "missing", + undefined, + new Error("topology probe unavailable"), + ); + + expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow( + /topology probe unavailable/, + ); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + const guardActions = commands.flatMap((cmd) => { + const guardIndex = cmd.indexOf(HERMES_GUARD); + return guardIndex >= 0 && cmd[guardIndex + 1] !== "--help" ? [cmd[guardIndex + 1]] : []; + }); + expect(guardActions).toEqual([ + "begin-shields-transition", + "run-state-dir-transition", + "apply-shields-transition", + "prepare-shields-abort", + "run-state-dir-transition", + "abort-shields-transition", + ]); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false); + }); + + it("rejects other sandbox-owned Hermes root modes before finishing a sealed unlock", () => { + installExecResponses(CURRENT_GUARD_HELP, "750"); + + expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow( + /config dir mode/, + ); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false); + }); + it("isolates Hermes guard Python and scrubs every privileged shields exec", () => { installExecResponses(CURRENT_GUARD_HELP); diff --git a/test/e2e/live/hermes-shields-config.test.ts b/test/e2e/live/hermes-shields-config.test.ts index 3fec7053517..57fbd8c547d 100644 --- a/test/e2e/live/hermes-shields-config.test.ts +++ b/test/e2e/live/hermes-shields-config.test.ts @@ -110,7 +110,9 @@ async function expectMutablePosture(sandbox: SandboxClient, cycle: number): Prom ); assertExitZero(result, `inspect Hermes mutable posture after cycle ${cycle}`); expect(result.stdout).toContain("755 sandbox:sandbox /sandbox"); - expect(result.stdout).toContain(`3770 sandbox:sandbox ${HERMES_DIR}`); + expect(result.stdout).toMatch( + new RegExp(`^(?:700|3770) sandbox:sandbox ${HERMES_DIR.replace(".", "\\.")}$`, "m"), + ); expect(result.stdout).toContain(`640 sandbox:sandbox ${CONFIG_PATH}`); expect(result.stdout).toContain(`640 sandbox:sandbox ${HERMES_DIR}/.env`); expect(result.stdout).toContain(`640 sandbox:sandbox ${HERMES_DIR}/.config-hash`); diff --git a/test/hermes-restart-config-seal-transition.test.ts b/test/hermes-restart-config-seal-transition.test.ts index d8922d6e92a..43f40787f80 100644 --- a/test/hermes-restart-config-seal-transition.test.ts +++ b/test/hermes-restart-config-seal-transition.test.ts @@ -1,6 +1,7 @@ // 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 path from "node:path"; @@ -10,6 +11,8 @@ import { createRestartFixture, mode, overwriteThroughOldFd, + type RestartFixture, + RUNTIME_CONFIG_GUARD, readFileSnapshot, readTextFileSnapshot, runGuard, @@ -20,6 +23,88 @@ import { strictHashIsValid, } from "./helpers/hermes-restart-config-seal-fixture"; +const LIFECYCLE_GUARD_ACTION = String.raw` +import errno +import importlib.util +import os +import sys + +spec = importlib.util.spec_from_file_location("runtime_config_guard", sys.argv[1]) +guard = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = guard +spec.loader.exec_module(guard) + +marker = sys.argv[2] +guard.HERMES_ROOT_LIFECYCLE_MARKER = marker +if marker == "lookup-error": + guard.HERMES_ROOT_LIFECYCLE_MARKER = "/synthetic/hermes-root-lifecycle" + original_lstat = guard.os.lstat + def fail_marker_lookup(path): + if path == guard.HERMES_ROOT_LIFECYCLE_MARKER: + raise OSError(errno.EIO, "synthetic lifecycle lookup failure") + return original_lstat(path) + guard.os.lstat = fail_marker_lookup + +action = sys.argv[3] +hermes_dir, hash_file, state_file = sys.argv[4:7] +if action == "begin": + token, original_locked = guard.begin_shields_transition( + hermes_dir, hash_file, state_file, "mutable" + ) + print(f"lock_token={token} original_locked={int(original_locked)}") +elif action == "finish": + mode, chattr_applied = guard.finish_shields_transition( + hermes_dir, hash_file, state_file, sys.argv[7] + ) + print(f"shields_mode={mode} chattr_applied={int(chattr_applied)}") +else: + raise ValueError(f"unsupported lifecycle guard action: {action}") +`; + +function runLifecycleGuardAction( + fixture: RestartFixture, + marker: string, + action: "begin" | "finish", + token = "", +) { + return spawnSync( + "python3", + [ + "-I", + "-c", + LIFECYCLE_GUARD_ACTION, + RUNTIME_CONFIG_GUARD, + marker, + action, + fixture.hermesDir, + fixture.hashPath, + fixture.statePath, + token, + ], + { encoding: "utf-8", timeout: 5000 }, + ); +} + +function applyPrivateMutableTransition(fixture: RestartFixture, marker: string): string { + const begun = runLifecycleGuardAction(fixture, marker, "begin"); + expect(begun.status, begun.stderr).toBe(0); + const token = shieldsTransactionToken(begun.stdout); + expect(token).toMatch(/^[0-9a-f]{64}$/); + const applied = runShieldsTransactionAction(fixture, "apply-shields-transition", { token }); + expect(applied.status, applied.stderr).toBe(0); + fs.chmodSync(fixture.hermesDir, 0o700); + return token!; +} + +function abortPrivateMutableTransition(fixture: RestartFixture, token: string): void { + const prepared = runShieldsTransactionAction(fixture, "prepare-shields-abort", { token }); + expect(prepared.status, prepared.stderr).toBe(0); + const aborted = runShieldsTransactionAction(fixture, "abort-shields-transition", { token }); + expect(aborted.status, aborted.stderr).toBe(0); + expect(mode(fixture.hermesDir)).toBe(0o3770); + expect(fs.existsSync(fixture.statePath)).toBe(false); +} + describe.skipIf(process.platform === "win32")("Hermes mutable restart input seal", () => { it("revokes pre-open writable fds while preserving trusted path bytes and strict hashes", () => { const fixture = createRestartFixture(); @@ -126,6 +211,118 @@ describe.skipIf(process.platform === "win32")("Hermes mutable restart input seal } }); + it("commits a descriptor-pinned private mutable root in the managed non-root topology", () => { + const fixture = createRestartFixture(); + const missingMarker = path.join(fixture.root, "hermes-root-lifecycle"); + + try { + const token = applyPrivateMutableTransition(fixture, missingMarker); + const finished = runLifecycleGuardAction(fixture, missingMarker, "finish", token); + + expect(finished.status, finished.stderr).toBe(0); + expect(finished.stdout).toContain("shields_mode=mutable"); + expect(mode(fixture.hermesDir)).toBe(0o700); + expect(fs.existsSync(fixture.statePath)).toBe(false); + expect(fs.existsSync(path.join(fixture.root, "hermes-config-mutation.lock"))).toBe(false); + expect(fs.existsSync(path.join(fixture.hermesDir, ".nemoclaw-hermes-restart-seal"))).toBe( + false, + ); + expect(strictHashIsValid(fixture)).toBe(true); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("restores the original posture when the begin topology probe fails", () => { + const fixture = createRestartFixture(); + + try { + const begun = runLifecycleGuardAction(fixture, "lookup-error", "begin"); + + expect(begun.status).not.toBe(0); + expect(begun.stderr).toContain("topology probe failure"); + expect(mode(fixture.sandboxDir)).toBe(0o770); + expect(mode(fixture.hermesDir)).toBe(0o3770); + expect(fs.existsSync(fixture.statePath)).toBe(false); + expect(fs.existsSync(path.join(fixture.root, "hermes-config-mutation.lock"))).toBe(false); + expect(fs.existsSync(path.join(fixture.hermesDir, ".nemoclaw-hermes-restart-seal"))).toBe( + false, + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.each([ + "regular", + "dangling-symlink", + ] as const)("rejects a private mutable root pinned from a %s marker", (markerState) => { + const fixture = createRestartFixture(); + const marker = path.join(fixture.root, "hermes-root-lifecycle"); + switch (markerState) { + case "regular": + fs.writeFileSync(marker, "root-separated\n", { mode: 0o444 }); + break; + case "dangling-symlink": + fs.symlinkSync(`${marker}.missing`, marker); + break; + } + + try { + const token = applyPrivateMutableTransition(fixture, marker); + switch (markerState) { + case "regular": + fs.unlinkSync(marker); + break; + case "dangling-symlink": + break; + } + const finished = runLifecycleGuardAction(fixture, marker, "finish", token); + + expect(finished.status).not.toBe(0); + expect(finished.stderr).toContain(".hermes metadata drifted"); + expect(fs.existsSync(fixture.statePath)).toBe(true); + abortPrivateMutableTransition(fixture, token); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("rejects a root-lifecycle marker that appears after begin", () => { + const fixture = createRestartFixture(); + const marker = path.join(fixture.root, "hermes-root-lifecycle"); + + try { + const token = applyPrivateMutableTransition(fixture, marker); + fs.writeFileSync(marker, "root-separated\n", { mode: 0o444 }); + const finished = runLifecycleGuardAction(fixture, marker, "finish", token); + + expect(finished.status).not.toBe(0); + expect(finished.stderr).toContain(".hermes metadata drifted"); + expect(fs.existsSync(fixture.statePath)).toBe(true); + abortPrivateMutableTransition(fixture, token); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("retains the transaction when the root-lifecycle lookup fails", () => { + const fixture = createRestartFixture(); + const missingMarker = path.join(fixture.root, "hermes-root-lifecycle"); + + try { + const token = applyPrivateMutableTransition(fixture, missingMarker); + const finished = runLifecycleGuardAction(fixture, "lookup-error", "finish", token); + + expect(finished.status).not.toBe(0); + expect(finished.stderr).toContain("topology probe failure"); + expect(fs.existsSync(fixture.statePath)).toBe(true); + abortPrivateMutableTransition(fixture, token); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + it("holds the mutation lock through begin, apply, verification, and finish", () => { const fixture = createRestartFixture(); const expectedDigest = createHash("sha256").update(fixture.trustedConfig).digest("hex");