diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 635b8dc159e..f61a907fe5e 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -133,6 +133,7 @@ COPY agents/hermes/runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-runtim COPY agents/hermes/finalize-tirith-marker.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py +COPY agents/hermes/restore-cron-guard.py /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ @@ -357,9 +358,10 @@ RUN chmod -R a+rX /opt/nemoclaw-blueprint/ # minimum supported Hermes sandbox base tag guarantees those artifacts and # test/sandbox-rlimit-hooks.test.ts covers that base. RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ - && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ + && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ + && chmod 555 /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py \ && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ @@ -557,12 +559,13 @@ RUN node --experimental-strip-types \ # accompanied by an updated hash below; otherwise the build fails. This blocks # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). -# Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. +# Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py,restore-cron-guard.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=a841a3673cd2543dd53559e513741dd66929361b8ca94b9196b72badf7827d3c ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 +ARG NEMOCLAW_HERMES_RESTORE_CRON_GUARD_SHA256=c469183e61a95dd558c115c14f084050a072dcc8ea0361f5e467937c61701caa # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ @@ -573,6 +576,11 @@ RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256" /usr/local/lib/nemoclaw/finalize-tirith-marker.py \ | sha256sum -c - \ || { echo "ERROR: finalize-tirith-marker.py hash mismatch (update NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256)" >&2; exit 1; } +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_RESTORE_CRON_GUARD_SHA256" /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py \ + | sha256sum -c - \ + || { echo "ERROR: hermes-restore-cron-guard.py hash mismatch (update NEMOCLAW_HERMES_RESTORE_CRON_GUARD_SHA256)" >&2; exit 1; } # Wrap the hermes CLI so the runtime env secret boundary is enforced for # `hermes gateway` no matter how it is invoked. The entrypoint guard alone left @@ -1102,6 +1110,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755' \ + && check_metadata /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py 'root:root 555' \ && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ && check_metadata /usr/local/bin/nemoclaw-managed-bootstrap 'root:root 755' \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 25b14380ebf..1d608c91c88 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -68,6 +68,10 @@ state_dirs: - sessions - skills - plugins + # Hermes confines the scripts that no_agent cron jobs run to + # ~/.hermes/scripts and rejects any path outside it, so cron job definitions + # are only restorable together with this directory. + - scripts - cron - logs - skins diff --git a/agents/hermes/restore-cron-guard.py b/agents/hermes/restore-cron-guard.py new file mode 100755 index 00000000000..b9277721967 --- /dev/null +++ b/agents/hermes/restore-cron-guard.py @@ -0,0 +1,279 @@ +#!/opt/hermes/.venv/bin/python -I +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Quiesce Hermes cron dispatch while NemoClaw restores scheduled work state.""" + +from __future__ import annotations + +import argparse +import grp +import json +import os +import pwd +import secrets +import stat +import sys +import time +from pathlib import Path +from typing import Any + +_OWNER_PREFIX = "nemoclaw-state-restore:" +_POLL_INTERVAL_SECONDS = 0.1 +_OWNERSHIP_FILE = ".nemoclaw-restore-drain" +_GATEWAY_USER = "gateway" + + +def _configure_home(raw_home: str) -> Path: + home = Path(raw_home) + if not home.is_absolute(): + raise ValueError("Hermes restore guard requires an absolute --home path") + os.environ["HERMES_HOME"] = str(home) + return home + + +def _gateway_modules() -> tuple[Any, Any]: + from gateway import drain_control, status + + return drain_control, status + + +def _runtime_is_safely_drained(status: Any, pid: int) -> bool: + runtime = status.read_runtime_status() + return bool( + isinstance(runtime, dict) + and runtime.get("pid") == pid + and runtime.get("gateway_state") == "draining" + and status.parse_active_agents(runtime.get("active_agents")) == 0 + ) + + +def _owned_marker_present(drain_control: Any, home: Path, token: str) -> bool: + marker = drain_control.read_drain_request(home=home) + return bool(isinstance(marker, dict) and marker.get("principal") == token) + + +def _release_owned_marker(drain_control: Any, home: Path, token: str) -> None: + if not _owned_marker_present(drain_control, home, token): + return + if not drain_control.clear_drain_request(home=home): + raise RuntimeError("Hermes restore guard could not clear its drain marker") + + +def _ownership_path(home: Path) -> Path: + return home / _OWNERSHIP_FILE + + +def _claim_ownership(home: Path, token: str) -> bool: + try: + descriptor = os.open( + _ownership_path(home), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600 + ) + except FileExistsError: + return False + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(token) + return True + + +def _release_ownership(home: Path, token: str) -> None: + path = _ownership_path(home) + try: + recorded = path.read_text(encoding="utf-8") + except FileNotFoundError: + return + if recorded == token: + path.unlink(missing_ok=True) + + +def begin_drain(home: Path, timeout_seconds: float) -> str: + drain_control, status = _gateway_modules() + token = f"{_OWNER_PREFIX}{secrets.token_hex(16)}" + if not _claim_ownership(home, token): + raise RuntimeError("Another NemoClaw restore already owns the Hermes drain") + + owned = False + try: + if drain_control.drain_requested(home=home): + result = "preserved" + else: + drain_control.write_drain_request(principal=token, home=home) + owned = True + result = token + + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + live_pid = status.get_running_pid() + if live_pid is None or _runtime_is_safely_drained(status, live_pid): + if not owned: + _release_ownership(home, token) + return result + time.sleep(_POLL_INTERVAL_SECONDS) + except BaseException: + if owned: + _release_owned_marker(drain_control, home, token) + _release_ownership(home, token) + raise + + if owned: + _release_owned_marker(drain_control, home, token) + _release_ownership(home, token) + raise TimeoutError( + f"Hermes gateway did not drain active messaging, API, and cron work within {timeout_seconds:g}s" + ) + + +def assert_safely_drained(home: Path) -> None: + drain_control, status = _gateway_modules() + pid = status.get_running_pid() + if pid is None: + return + if not drain_control.drain_requested(home=home) or not _runtime_is_safely_drained( + status, pid + ): + raise RuntimeError("Hermes gateway is not safely drained for scheduled-work restore") + + +def _gateway_identity() -> tuple[int, set[int]] | None: + try: + entry = pwd.getpwnam(_GATEWAY_USER) + except KeyError: + return None + memberships = { + group.gr_gid for group in grp.getgrall() if _GATEWAY_USER in group.gr_mem + } + memberships.add(entry.pw_gid) + return entry.pw_uid, memberships + + +def _accessible_by_gateway(path: Path, access_mode: int, bits: tuple[int, int, int]) -> bool: + identity = _gateway_identity() + if identity is None: + return os.access(path, access_mode) + uid, gids = identity + if uid == os.geteuid(): + return os.access(path, access_mode) + owner_bit, group_bit, other_bit = bits + info = path.stat() + if info.st_uid == uid: + return bool(info.st_mode & owner_bit) + if info.st_gid in gids: + return bool(info.st_mode & group_bit) + return bool(info.st_mode & other_bit) + + +def _readable_by_gateway(script_path: Path) -> bool: + return _accessible_by_gateway( + script_path, os.R_OK, (stat.S_IRUSR, stat.S_IRGRP, stat.S_IROTH) + ) + + +def _searchable_by_gateway(directory: Path) -> bool: + return _accessible_by_gateway( + directory, os.X_OK, (stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH) + ) + + +def _enclosing_directories(scripts_dir: Path, script_parent: Path) -> list[Path]: + directories = [script_parent] + while directories[-1] != scripts_dir: + directories.append(directories[-1].parent) + directories.reverse() + return directories + + +def _load_jobs(jobs_file: Path) -> list[Any]: + if not jobs_file.exists(): + return [] + data = json.loads(jobs_file.read_text(encoding="utf-8-sig")) + jobs = data.get("jobs", []) if isinstance(data, dict) else data + if not isinstance(jobs, list): + raise ValueError("Hermes cron database must contain a jobs list") + return jobs + + +def validate_enabled_scripts(home: Path) -> None: + scripts_dir = (home / "scripts").resolve() + for index, job in enumerate(_load_jobs(home / "cron" / "jobs.json")): + if not isinstance(job, dict): + raise ValueError(f"Hermes cron job at index {index} is not an object") + if not job.get("enabled", True) or job.get("state") == "paused": + continue + script = job.get("script") + if script in {None, ""}: + if job.get("no_agent"): + raise ValueError( + f"Enabled no-agent Hermes cron job at index {index} has no script" + ) + continue + if not isinstance(script, str): + raise ValueError(f"Enabled Hermes cron job at index {index} has a non-string script") + raw_path = Path(script).expanduser() + script_path = ( + raw_path.resolve() + if raw_path.is_absolute() + else (scripts_dir / raw_path).resolve() + ) + try: + script_path.relative_to(scripts_dir) + except ValueError as error: + raise ValueError( + f"Enabled Hermes cron job at index {index} resolves outside the scripts directory" + ) from error + if not script_path.is_file() or not _readable_by_gateway(script_path): + raise ValueError( + f"Enabled Hermes cron job at index {index} references a missing or unreadable script" + ) + for directory in _enclosing_directories(scripts_dir, script_path.parent): + if not _searchable_by_gateway(directory): + raise ValueError( + f"Enabled Hermes cron job at index {index} references a script the Hermes " + "gateway cannot reach through its directories" + ) + + +def validate_restore(home: Path) -> None: + assert_safely_drained(home) + validate_enabled_scripts(home) + + +def release_drain(home: Path, token: str) -> None: + if not token.startswith(_OWNER_PREFIX) or len(token) != len(_OWNER_PREFIX) + 32: + raise ValueError("Invalid Hermes restore drain ownership token") + drain_control, _status = _gateway_modules() + _release_owned_marker(drain_control, home, token) + _release_ownership(home, token) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("begin", "assert-safe", "validate", "release")) + parser.add_argument("--home", required=True) + parser.add_argument("--timeout", type=float, default=60.0) + parser.add_argument("--token") + return parser + + +def main() -> int: + args = _parser().parse_args() + try: + home = _configure_home(args.home) + if args.action == "begin": + if args.timeout <= 0: + raise ValueError("Hermes restore drain timeout must be positive") + print(begin_drain(home, args.timeout)) + elif args.action == "assert-safe": + assert_safely_drained(home) + elif args.action == "validate": + validate_restore(home) + else: + if not args.token: + raise ValueError("Hermes restore drain release requires --token") + release_drain(home, args.token) + return 0 + except Exception as error: + print(f"Hermes restore guard failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 109f13b7013..841d094a84c 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -57,7 +57,11 @@ If a copied file or parent directory changes identity during the operation, snap Hermes snapshots include `SOUL.md`, the Web Dashboard profile under `.hermes/dashboard-home/`, the SQLite database behind `.hermes/state.db`, and the default kanban board in `.hermes/kanban.db`. The default-profile snapshot also includes cron execution history in `.hermes/runtime/cron-executions.db` and Discord replay state in `.hermes/gateway/discord_message_recovery.db`. -NemoClaw captures cron job definitions from `.hermes/cron` as directory state. +NemoClaw captures cron job definitions from `.hermes/cron` as directory state, together with the scripts they run from `.hermes/scripts`. +A restore moves each state directory into place as a unit and applies `.hermes/cron` after `.hermes/scripts`. +Before replacing either directory, NemoClaw asks the running Hermes gateway to drain and waits until messaging, API, and cron work reaches zero. +It validates every enabled job's referenced script before releasing a drain that NemoClaw created; a drain already owned by an operator remains in place. +If the gateway cannot drain, a referenced script is missing or unreadable, or rollback cannot recover the prior state, the restore fails closed without resuming scheduled work. NemoClaw uses SQLite's online backup API and restores these databases through SQLite instead of copying live raw database files. After it replaces a database, NemoClaw opens a write transaction against the result and fails the restore when the database cannot be written. Named-profile cron and Discord databases under `.hermes/profiles//` use raw directory capture and can be inconsistent if a write overlaps the snapshot. @@ -136,6 +140,11 @@ $$nemoclaw my-assistant snapshot restore before-upgrade $$nemoclaw my-assistant snapshot restore 2026-04-14T09-40-09-760Z ``` +For restore paths that replace state directories as whole units, NemoClaw stages every replacement before it changes live state. +If publication fails before the transaction commits, NemoClaw restores the original directories. +If rollback or cleanup cannot finish, NemoClaw preserves `.nemoclaw-restore-rollback` under the agent state directory and refuses another restore so it does not overwrite the recovery copy. +Preserve the reported recovery path and verify its contents before you retry or remove it. + Post-restore policy reconciliation is best-effort. NemoClaw warns and continues the remaining restore steps in these cases: diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 614f37f56bf..e7d55b29c7f 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -657,7 +657,8 @@ For OpenClaw, the backed-up paths include agents, extensions, workspace, skills, -For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, cron, logs, plans, workspace, messaging platform state, `runtime/state.db`, and the default kanban board in `kanban.db`. +For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, scripts, cron, logs, plans, workspace, messaging platform state, `runtime/state.db`, and the default kanban board in `kanban.db`. +During restore, NemoClaw drains the Hermes gateway before replacing scripts or cron definitions, validates enabled-job script references, and then resumes only a drain that it created. Kanban backup does not include named boards, attachments, worker logs, scratch workspaces under `kanban/`, or external directory or worktree targets. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index c06ff606279..d7fb4fcbcb3 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -290,7 +290,7 @@ Writable agent state such as plugins, skills, hooks, and workspace metadata live By default, this directory starts writable so the agent can manage its own config, install skills, and write to standard home-directory paths natively. For sensitive workloads, use a reviewed host-side immutability workflow after initial setup so the sandbox user cannot change config or high-risk state entry points. -The immutability workflow locks high-risk state directories (`skills`, `agent`, `hooks`, `cron`, `agents`, `extensions`, `plugins`, `workspace`, `memory`, `devices`, `canvas`, `telegram`, `wechat`, `whatsapp`, `platforms`, `weixin`, `profiles`, `skins`) to `root:sandbox` and removes group and world write access. +The immutability workflow locks high-risk state directories (`skills`, `agent`, `hooks`, `cron`, `agents`, `extensions`, `plugins`, `scripts`, `workspace`, `memory`, `devices`, `canvas`, `telegram`, `wechat`, `whatsapp`, `platforms`, `weixin`, `profiles`, `skins`) to `root:sandbox` and removes group and world write access. The root-only helper traverses from opened directory descriptors with no-follow semantics instead of using recursive pathname `chown` or `chmod`. Read-only preflight and unlock operations reject unsafe external symlinks, hardlinks, special files, cross-device entries, and entries that race the traversal without modifying them. After the top-level config binding is frozen, lockdown makes containment monotonic. @@ -348,6 +348,8 @@ Direct edits to these files can be overwritten when NemoClaw regenerates the ima Hermes also stores runtime state such as `state.db`, logs, and platform sessions under the `.hermes` tree. Messaging sessions such as WhatsApp pairing can remain mutable by design so they survive rebuilds. +Hermes rebuild restore blocks new gateway dispatch and waits for active messaging, API, and cron work to drain before replacing cron scripts or job definitions. +It keeps that drain in place until enabled script references validate or the prior state is rolled back. When Shields locks the tree, the shared state-directory guard treats any present `credentials`, `identity`, or `pairing` directory as a confidentiality root. For Hermes, this normally applies to `pairing`. diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 1a889cea223..13865d92d47 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -39,6 +39,7 @@ "agent", "hooks", "cron", + "scripts", "agents", "extensions", "plugins", diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 3f5e47e1d6f..999d04fc2a5 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -748,6 +748,7 @@ export const MANAGED_STARTUP_PROFILE_EXCLUDED_DOCKER_INPUTS = { { input: "NEMOCLAW_HERMES_BACKUP_SOURCE_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256", reason: "integrity-pin" }, + { input: "NEMOCLAW_HERMES_RESTORE_CRON_GUARD_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_WRAPPER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_CLI_ADAPTER_SHA256", reason: "integrity-pin" }, { input: "NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256", reason: "integrity-pin" }, diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index fdf2de18da2..fb1671d9940 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -29,6 +29,7 @@ export const HIGH_RISK_STATE_DIRS = [ "agent", "hooks", "cron", + "scripts", "agents", "extensions", "plugins", diff --git a/src/lib/state/sandbox-staged-restore.test.ts b/src/lib/state/sandbox-staged-restore.test.ts new file mode 100644 index 00000000000..05e6d624fd0 --- /dev/null +++ b/src/lib/state/sandbox-staged-restore.test.ts @@ -0,0 +1,507 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { restoreEnvBulk } from "../../../test/helpers/env-test-helpers.js"; +import { loadAgent } from "../agent/defs.js"; +import { restoreRecreatedSandboxState } from "./sandbox.js"; + +const HERMES_DIR = "/sandbox/.hermes"; + +type RestoreEvent = + | { event: "guard"; action: string } + | { event: "move"; target: string; scriptsPresent: boolean; drainActive: boolean }; +type MoveRecord = Extract; + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function seedExistingStateFixture(hermesDir: string): void { + fs.mkdirSync(path.join(hermesDir, "cron"), { recursive: true }); + fs.mkdirSync(path.join(hermesDir, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(hermesDir, "workspace"), { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "cron", "jobs.json"), "old cron\n"); + fs.writeFileSync(path.join(hermesDir, "scripts", "digest.sh"), "old script\n"); + fs.writeFileSync(path.join(hermesDir, "workspace", "notes.md"), "old workspace\n"); +} + +function seedUnrecoveredRollbackFixture(hermesDir: string): void { + const rollbackScripts = path.join(hermesDir, ".nemoclaw-restore-rollback", "scripts"); + fs.mkdirSync(rollbackScripts, { recursive: true }); + fs.writeFileSync(path.join(rollbackScripts, "digest.sh"), "recoverable script\n"); +} + +function runHermesRestore(options: { + stateDirs: string[]; + movesFail?: boolean; + failPublishingDir?: string; + failRemovingRollback?: boolean; + failRollingBackDir?: string; + seedExistingState?: boolean; + seedUnrecoveredRollback?: boolean; + gatewayRunning?: boolean; + preexistingDrain?: boolean; + guardValidationFails?: boolean; + missingRestoredScript?: boolean; +}): { + moves: MoveRecord[]; + events: RestoreEvent[]; + guardEvents: string[]; + restore: ReturnType; + rollbackScript: string | null; + restoredCronJob: string | null; + restoredScript: string | null; + restoredWorkspace: string | null; + rollbackLeftBehind: boolean; + stagingLeftBehind: boolean; + drainLeftBehind: boolean; +} { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-staged-restore-")); + const previousOpenshellBin = process.env.NEMOCLAW_OPENSHELL_BIN; + const previousPath = process.env.PATH; + try { + const binDir = path.join(fixture, "bin"); + const shimDir = path.join(fixture, "shim"); + const hermesDir = path.join(fixture, "sandbox-root", ".hermes"); + const backupPath = path.join(fixture, "backup"); + const moveLog = path.join(fixture, "move-log.jsonl"); + const drainMarker = path.join(fixture, "drain-active"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(shimDir, { recursive: true }); + fs.mkdirSync(hermesDir, { recursive: true }); + const existingStateSeeder = + options.seedExistingState === true ? seedExistingStateFixture : () => undefined; + const rollbackSeeder = + options.seedUnrecoveredRollback === true ? seedUnrecoveredRollbackFixture : () => undefined; + const drainSeeder = + options.preexistingDrain === true + ? () => fs.writeFileSync(drainMarker, "external\n") + : () => undefined; + const restoredScriptSeeder = + options.missingRestoredScript === true + ? () => undefined + : () => + fs.writeFileSync( + path.join(backupPath, "scripts", "digest.sh"), + "#!/bin/bash\necho ok\n", + ); + existingStateSeeder(hermesDir); + rollbackSeeder(hermesDir); + drainSeeder(); + + for (const stateDir of options.stateDirs) { + fs.mkdirSync(path.join(backupPath, stateDir), { recursive: true }); + } + fs.writeFileSync( + path.join(backupPath, "cron", "jobs.json"), + '{"jobs":[{"enabled":true,"script":"digest.sh"}]}\n', + ); + restoredScriptSeeder(); + + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "alpha", + timestamp: "2026-07-29T12-00-00-000Z", + agentType: "hermes", + agentVersion: null, + expectedVersion: null, + stateDirs: options.stateDirs, + backedUpDirs: options.stateDirs, + stateFiles: [], + dir: HERMES_DIR, + backupPath, + blueprintDigest: null, + }), + ); + + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); +} +process.exit(0); +`, + ); + + writeExecutable( + path.join(shimDir, "mv"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +const source = args[args.length - 2] || ""; +const target = args[args.length - 1]; +const isPublish = source.includes("/.nemoclaw-restore-staging/"); +const isRollback = source.includes("/.nemoclaw-restore-rollback/"); +if (isPublish) { + fs.appendFileSync( + ${JSON.stringify(moveLog)}, + JSON.stringify({ + event: "move", + target, + scriptsPresent: fs.existsSync(${JSON.stringify(path.join(hermesDir, "scripts"))}), + drainActive: fs.existsSync(${JSON.stringify(drainMarker)}), + }) + "\\n", + ); +} +if ( + ${JSON.stringify(options.movesFail === true)} || + (isPublish && target.endsWith("/" + ${JSON.stringify(options.failPublishingDir ?? "")})) || + (isRollback && target.endsWith("/" + ${JSON.stringify(options.failRollingBackDir ?? "")})) +) process.exit(1); +const result = spawnSync("/bin/mv", args, { stdio: "inherit" }); +process.exit(result.status === null ? 1 : result.status); +`, + ); + + const restoreGuard = path.join(binDir, "hermes-restore-cron-guard"); + writeExecutable( + restoreGuard, + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +const action = args[0] || ""; +fs.appendFileSync( + ${JSON.stringify(moveLog)}, + JSON.stringify({ event: "guard", action }) + "\\n", +); +if (action === "begin") { + if (!${JSON.stringify(options.gatewayRunning !== false)}) { + process.stdout.write("inactive\\n"); + } else if (fs.existsSync(${JSON.stringify(drainMarker)})) { + process.stdout.write("preserved\\n"); + } else { + const token = "nemoclaw-state-restore:0123456789abcdef0123456789abcdef"; + fs.writeFileSync(${JSON.stringify(drainMarker)}, token + "\\n"); + process.stdout.write(token + "\\n"); + } +} else if (action === "assert-safe") { + if (${JSON.stringify(options.gatewayRunning !== false)} && !fs.existsSync(${JSON.stringify(drainMarker)})) { + process.exit(1); + } +} else if (action === "validate") { + if (${JSON.stringify(options.guardValidationFails === true)}) process.exit(1); + if (${JSON.stringify(options.gatewayRunning !== false)} && !fs.existsSync(${JSON.stringify(drainMarker)})) { + process.exit(1); + } + const jobs = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(hermesDir, "cron", "jobs.json"))}, "utf8")).jobs; + for (const job of jobs) { + if (!job.enabled || !job.script) continue; + try { + fs.accessSync(path.join(${JSON.stringify(path.join(hermesDir, "scripts"))}, job.script), fs.constants.R_OK); + } catch { + process.exit(1); + } + } +} else if (action === "release") { + const tokenIndex = args.indexOf("--token"); + const token = tokenIndex >= 0 ? args[tokenIndex + 1] : ""; + const owner = fs.existsSync(${JSON.stringify(drainMarker)}) + ? fs.readFileSync(${JSON.stringify(drainMarker)}, "utf8").trim() + : ""; + if (owner === token) fs.rmSync(${JSON.stringify(drainMarker)}); +} +process.exit(0); +`, + ); + + writeExecutable( + path.join(shimDir, "rm"), + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +if ( + ${JSON.stringify(options.failRemovingRollback === true)} && + args.some((arg) => arg.endsWith("/.nemoclaw-restore-rollback")) +) process.exit(1); +const result = spawnSync("/bin/rm", args, { stdio: "inherit" }); +process.exit(result.status === null ? 1 : result.status); +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const command = (process.argv[process.argv.length - 1] || "") + .split(${JSON.stringify(HERMES_DIR)}).join(${JSON.stringify(hermesDir)}) + .split("/usr/local/lib/nemoclaw/hermes-restore-cron-guard.py").join(${JSON.stringify(restoreGuard)}); +function readStdin() { + const chunks = []; + for (;;) { + const buffer = Buffer.alloc(65536); + let count = 0; + try { + count = fs.readSync(0, buffer, 0, buffer.length, null); + } catch { + break; + } + if (count === 0) break; + chunks.push(buffer.subarray(0, count)); + } + return Buffer.concat(chunks); +} +const result = spawnSync("sh", ["-c", command], { + input: readStdin(), + env: { ...process.env, PATH: ${JSON.stringify(shimDir)} + ":" + process.env.PATH }, + stdio: ["pipe", "pipe", "pipe"], +}); +process.exit(result.status === null ? 1 : result.status); +`, + ); + + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + const restore = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "hermes", + }); + + const events = fs.existsSync(moveLog) + ? fs + .readFileSync(moveLog, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as RestoreEvent) + : []; + const moves = events.filter((event): event is MoveRecord => event.event === "move"); + const cronJobPath = path.join(hermesDir, "cron", "jobs.json"); + const scriptPath = path.join(hermesDir, "scripts", "digest.sh"); + const workspacePath = path.join(hermesDir, "workspace", "notes.md"); + const rollbackScriptPath = path.join( + hermesDir, + ".nemoclaw-restore-rollback", + "scripts", + "digest.sh", + ); + return { + moves, + events, + guardEvents: events + .filter( + (event): event is Extract => event.event === "guard", + ) + .map((event) => event.action), + restore, + rollbackScript: fs.existsSync(rollbackScriptPath) + ? fs.readFileSync(rollbackScriptPath, "utf8") + : null, + restoredCronJob: fs.existsSync(cronJobPath) ? fs.readFileSync(cronJobPath, "utf8") : null, + restoredScript: fs.existsSync(scriptPath) ? fs.readFileSync(scriptPath, "utf8") : null, + restoredWorkspace: fs.existsSync(workspacePath) + ? fs.readFileSync(workspacePath, "utf8") + : null, + rollbackLeftBehind: fs.existsSync(path.join(hermesDir, ".nemoclaw-restore-rollback")), + stagingLeftBehind: fs.existsSync(path.join(hermesDir, ".nemoclaw-restore-staging")), + drainLeftBehind: fs.existsSync(drainMarker), + }; + } finally { + restoreEnvBulk({ NEMOCLAW_OPENSHELL_BIN: previousOpenshellBin, PATH: previousPath }); + fs.rmSync(fixture, { recursive: true, force: true }); + } +} + +describe("Hermes cron state restore", () => { + it("declares the cron script directory as Hermes state", () => { + expect(loadAgent("hermes").stateDirs).toContain("scripts"); + }); + + it("restores cron scripts alongside the job definitions that call them", () => { + const result = runHermesRestore({ stateDirs: ["scripts", "cron", "workspace"] }); + + expect(result.restore.success).toBe(true); + expect(result.restore.restoredDirs).toEqual( + expect.arrayContaining(["scripts", "cron", "workspace"]), + ); + expect(result.restoredScript).toBe("#!/bin/bash\necho ok\n"); + expect(result.restoredCronJob).toBe('{"jobs":[{"enabled":true,"script":"digest.sh"}]}\n'); + }); + + it("drains the gateway before moving live scheduled-work state and resumes after validation", () => { + const result = runHermesRestore({ stateDirs: ["scripts", "cron", "workspace"] }); + + expect(result.restore.success).toBe(true); + expect(result.guardEvents).toEqual(["begin", "assert-safe", "validate", "release"]); + expect(result.moves.every((move) => move.drainActive)).toBe(true); + expect( + result.events.findIndex((event) => event.event === "guard" && event.action === "begin"), + ).toBeLessThan(result.events.findIndex((event) => event.event === "move")); + expect( + result.events.findIndex((event) => event.event === "guard" && event.action === "validate"), + ).toBeGreaterThan(result.events.map((event) => event.event).lastIndexOf("move")); + expect(result.drainLeftBehind).toBe(false); + }); + + it("preserves a drain that another operator already owned", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "cron", "workspace"], + preexistingDrain: true, + }); + + expect(result.restore.success).toBe(true); + expect(result.guardEvents).toEqual(["begin", "assert-safe", "validate"]); + expect(result.moves.every((move) => move.drainActive)).toBe(true); + expect(result.drainLeftBehind).toBe(true); + }); + + it("does not create a drain marker when the gateway is inactive", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "cron", "workspace"], + gatewayRunning: false, + }); + + expect(result.restore.success).toBe(true); + expect(result.guardEvents).toEqual(["begin", "assert-safe", "validate"]); + expect(result.moves.every((move) => !move.drainActive)).toBe(true); + expect(result.drainLeftBehind).toBe(false); + }); + + it("publishes cron job definitions only after their scripts are in place", () => { + const result = runHermesRestore({ stateDirs: ["scripts", "cron", "workspace"] }); + + const cronMove = result.moves.find((move) => move.target.endsWith("/cron")); + expect(cronMove?.scriptsPresent).toBe(true); + expect(result.moves.at(-1)?.target).toMatch(/\/cron$/); + }); + + it("applies cron last for a backup whose manifest lists it first", () => { + const result = runHermesRestore({ stateDirs: ["cron", "scripts", "workspace"] }); + + expect(result.restore.success).toBe(true); + expect(result.moves.at(-1)?.target).toMatch(/\/cron$/); + expect(result.moves.find((move) => move.target.endsWith("/cron"))?.scriptsPresent).toBe(true); + }); + + it("publishes every state directory as a unit and leaves no staging directory", () => { + const result = runHermesRestore({ stateDirs: ["scripts", "cron", "workspace"] }); + + expect(result.restore.success).toBe(true); + expect(result.moves.map((move) => path.basename(move.target)).sort()).toEqual([ + "cron", + "scripts", + "workspace", + ]); + expect(result.stagingLeftBehind).toBe(false); + }); + + it("removes the archive copy when a state directory cannot be published", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "cron", "workspace"], + movesFail: true, + }); + + expect(result.moves.map((move) => path.basename(move.target))).toEqual(["scripts"]); + expect(result.restoredCronJob).toBeNull(); + expect(result.restore.success).toBe(false); + expect(result.stagingLeftBehind).toBe(false); + }); + + it("restores the original state when a staged directory cannot be published", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + failPublishingDir: "workspace", + seedExistingState: true, + }); + + expect(result.moves.map((move) => path.basename(move.target))).toEqual([ + "scripts", + "workspace", + ]); + expect(result.restore.success).toBe(false); + expect(result.restoredScript).toBe("old script\n"); + expect(result.restoredWorkspace).toBe("old workspace\n"); + expect(result.restoredCronJob).toBe("old cron\n"); + expect(result.stagingLeftBehind).toBe(false); + expect(result.rollbackLeftBehind).toBe(false); + expect(result.guardEvents.at(-1)).toBe("release"); + expect(result.drainLeftBehind).toBe(false); + }); + + it("rolls back instead of resuming when restored enabled-job scripts fail validation", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + guardValidationFails: true, + seedExistingState: true, + }); + + expect(result.restore.success).toBe(false); + expect(result.guardEvents).toEqual(["begin", "assert-safe", "validate", "release"]); + expect(result.restoredScript).toBe("old script\n"); + expect(result.restoredCronJob).toBe("old cron\n"); + expect(result.rollbackLeftBehind).toBe(false); + expect(result.drainLeftBehind).toBe(false); + }); + + it("rejects an enabled restored job whose script is absent", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + missingRestoredScript: true, + seedExistingState: true, + }); + + expect(result.restore.success).toBe(false); + expect(result.guardEvents).toEqual(["begin", "assert-safe", "validate", "release"]); + expect(result.restoredScript).toBe("old script\n"); + expect(result.restoredCronJob).toBe("old cron\n"); + expect(result.drainLeftBehind).toBe(false); + }); + + it("preserves the recovery tree when rolling the original state back fails", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + failPublishingDir: "workspace", + failRollingBackDir: "scripts", + seedExistingState: true, + }); + + expect(result.restore.success).toBe(false); + expect(result.restoredWorkspace).toBe("old workspace\n"); + expect(result.restoredCronJob).toBe("old cron\n"); + expect(result.rollbackScript).toBe("old script\n"); + expect(result.rollbackLeftBehind).toBe(true); + expect(result.stagingLeftBehind).toBe(false); + expect(result.guardEvents).not.toContain("release"); + expect(result.drainLeftBehind).toBe(true); + }); + + it("refuses a new restore while an unrecovered rollback tree exists", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + seedExistingState: true, + seedUnrecoveredRollback: true, + }); + + expect(result.restore.success).toBe(false); + expect(result.restoredScript).toBe("old script\n"); + expect(result.rollbackScript).toBe("recoverable script\n"); + expect(result.rollbackLeftBehind).toBe(true); + }); + + it("keeps the new state and recovery tree when post-commit cleanup fails", () => { + const result = runHermesRestore({ + stateDirs: ["scripts", "workspace", "cron"], + failRemovingRollback: true, + seedExistingState: true, + }); + + expect(result.restore.success).toBe(false); + expect(result.restoredScript).toBe("#!/bin/bash\necho ok\n"); + expect(result.restoredCronJob).toBe('{"jobs":[{"enabled":true,"script":"digest.sh"}]}\n'); + expect(result.rollbackScript).toBe("old script\n"); + expect(result.rollbackLeftBehind).toBe(true); + expect(result.stagingLeftBehind).toBe(false); + }); +}); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 0266b95dd86..8833ae8fbd8 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -1648,6 +1648,168 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = // ── Restore ──────────────────────────────────────────────────────── +const RESTORE_STAGING_DIR = ".nemoclaw-restore-staging"; +const RESTORE_ROLLBACK_DIR = ".nemoclaw-restore-rollback"; +const HERMES_RESTORE_CRON_GUARD = "/usr/local/lib/nemoclaw/hermes-restore-cron-guard.py"; +const HERMES_RESTORE_DRAIN_TIMEOUT_SECONDS = 60; +const HERMES_SCHEDULED_WORK_STATE_DIRS = new Set(["cron", "scripts"]); + +// Job definitions in these directories are picked up by a gateway that keeps +// running throughout a restore, so they must land after every directory whose +// content those jobs can reference. +const SCHEDULED_WORK_STATE_DIRS = new Set(["cron"]); + +function orderStateDirsForRestore(stateDirs: readonly string[]): string[] { + return [ + ...stateDirs.filter((stateDir) => !SCHEDULED_WORK_STATE_DIRS.has(stateDir)), + ...stateDirs.filter((stateDir) => SCHEDULED_WORK_STATE_DIRS.has(stateDir)), + ]; +} + +function buildHermesRestoreGuardCommand( + action: "begin" | "assert-safe" | "validate" | "release", + dir: string, +): string { + const command = [shellQuote(HERMES_RESTORE_CRON_GUARD), action, "--home", shellQuote(dir)]; + if (action === "begin") { + command.push("--timeout", String(HERMES_RESTORE_DRAIN_TIMEOUT_SECONDS)); + } else if (action === "release") { + command.push("--token", '"$drain_token"'); + } + return command.join(" "); +} + +function buildHermesDrainReleaseCommand(dir: string): string { + return ( + 'case "$drain_token" in nemoclaw-state-restore:*) ' + + `${buildHermesRestoreGuardCommand("release", dir)} && drain_token= ;; *) : ;; esac` + ); +} + +/** + * Extract a restore archive beside the state directories, then move each + * directory into place. + * + * Extracting straight into the state directory publishes files one at a time to + * the running gateway, which can read a directory before its content is + * complete. A rename within the same directory publishes each restored + * directory whole. + */ +function buildStagedRestoreCommand( + dir: string, + stateDirs: readonly string[], + staleContentDirs: readonly string[] = [], + options: { quiesceHermesScheduledWork?: boolean } = {}, +): string { + const staging = `${dir}/${RESTORE_STAGING_DIR}`; + const rollback = `${dir}/${RESTORE_ROLLBACK_DIR}`; + const quotedStaging = shellQuote(staging); + const quotedRollback = shellQuote(rollback); + const removeStaging = `rm -rf -- ${quotedStaging}`; + const removeRollback = `rm -rf -- ${quotedRollback}`; + const refuseUnrecoveredRollback = + `if [ -e ${quotedRollback} ] || [ -L ${quotedRollback} ]; then ` + + `echo ${shellQuote(`Refusing restore: unrecovered NemoClaw state remains at ${rollback}`)} >&2; ` + + "exit 1; fi"; + const restoredDirSet = new Set(stateDirs); + const transitionDirs = orderStateDirsForRestore([ + ...new Set([...stateDirs, ...staleContentDirs]), + ]); + const quiesceHermesScheduledWork = + options.quiesceHermesScheduledWork === true && + transitionDirs.some((stateDir) => HERMES_SCHEDULED_WORK_STATE_DIRS.has(stateDir)); + const releaseHermesDrain = buildHermesDrainReleaseCommand(dir); + const rollbackCommands = transitionDirs + .map((stateDir, index) => ({ stateDir, index })) + .reverse() + .map(({ stateDir, index }) => { + const target = shellQuote(`${dir}/${stateDir}`); + const rollbackTarget = shellQuote(`${rollback}/${stateDir}`); + const targetParent = shellQuote(path.posix.dirname(`${dir}/${stateDir}`)); + const existingMarker = shellQuote(`${rollback}/.existing-${index}`); + const absentMarker = shellQuote(`${rollback}/.absent-${index}`); + return ( + `if [ -e ${existingMarker} ]; then ` + + `if [ -e ${rollbackTarget} ] || [ -L ${rollbackTarget} ]; then ` + + `if rm -rf -- ${target} && mkdir -p -- ${targetParent} && ` + + `mv -- ${rollbackTarget} ${target}; then :; else rollback_status=1; fi; fi; ` + + `elif [ -e ${absentMarker} ]; then ` + + `rm -rf -- ${target} || rollback_status=1; fi` + ); + }); + const cleanup = [ + "status=$?", + "rollback_status=0", + `if [ "$status" -ne 0 ] && [ "$transaction_committed" -ne 1 ]; then ` + + `${rollbackCommands.join("; ")}; fi`, + quiesceHermesScheduledWork + ? `if [ "$rollback_status" -eq 0 ]; then ${releaseHermesDrain}; ` + + 'if [ "$?" -ne 0 ]; then status=1; fi; ' + + 'elif [ -n "$drain_token" ] && [ "$drain_token" != inactive ] && ' + + '[ "$drain_token" != preserved ]; then ' + + `echo ${shellQuote( + "Hermes restore rollback failed; preserving the scheduler drain", + )} >&2; fi` + : ":", + removeStaging, + `if [ "$status" -ne 0 ] && [ "$transaction_committed" -eq 1 ]; then ` + + `echo ${shellQuote( + `NemoClaw restore committed but cleanup failed; preserved recovery state at ${rollback}`, + )} >&2; ` + + `elif [ "$rollback_status" -eq 0 ]; then ${removeRollback}; ` + + `else echo ${shellQuote( + `NemoClaw restore rollback failed; preserved recovery state at ${rollback}`, + )} >&2; fi`, + 'exit "$status"', + ].join("; "); + const commands = [ + removeStaging, + `mkdir -p -- ${quotedStaging}`, + `mkdir -p -- ${quotedRollback}`, + `tar --no-same-owner -xf - -C ${quotedStaging}`, + ]; + if (quiesceHermesScheduledWork) { + commands.push( + `drain_token="$(${buildHermesRestoreGuardCommand("begin", dir)})"`, + 'case "$drain_token" in inactive|preserved|nemoclaw-state-restore:*) : ;; ' + + `*) echo ${shellQuote("Hermes restore guard returned an invalid drain token")} >&2; false ;; esac`, + buildHermesRestoreGuardCommand("assert-safe", dir), + ); + } + for (const [index, stateDir] of transitionDirs.entries()) { + const target = shellQuote(`${dir}/${stateDir}`); + const rollbackTarget = shellQuote(`${rollback}/${stateDir}`); + const rollbackParent = shellQuote(path.posix.dirname(`${rollback}/${stateDir}`)); + const existingMarker = shellQuote(`${rollback}/.existing-${index}`); + const absentMarker = shellQuote(`${rollback}/.absent-${index}`); + commands.push( + `if [ -e ${target} ] || [ -L ${target} ]; then ` + + `mkdir -p -- ${rollbackParent} && touch -- ${existingMarker} && ` + + `mv -- ${target} ${rollbackTarget}; else touch -- ${absentMarker}; fi`, + ); + if (restoredDirSet.has(stateDir)) { + const targetParent = shellQuote(path.posix.dirname(`${dir}/${stateDir}`)); + commands.push(`mkdir -p -- ${targetParent}`); + commands.push(`mv -- ${shellQuote(`${staging}/${stateDir}`)} ${target}`); + } + } + if (quiesceHermesScheduledWork) { + commands.push(buildHermesRestoreGuardCommand("validate", dir)); + } + commands.push("transaction_committed=1"); + if (quiesceHermesScheduledWork) { + commands.push(releaseHermesDrain); + } + commands.push(removeRollback); + // A failed extraction or move ends the chain. The EXIT trap restores every + // live directory already transitioned. If rollback itself fails, keep the + // recovery tree and refuse future restores instead of deleting its only copy. + return ( + `${refuseUnrecoveredRollback}; transaction_committed=0; drain_token=; ` + + `trap ${shellQuote(cleanup)} EXIT; ${commands.join(" && ")}` + ); +} + function snapshotManifestAuthority(manifest: RebuildManifest): RebuildManifest { const normalized = { ...manifest, @@ -2114,12 +2276,13 @@ function restoreSandboxStateInternal( return failRestoreContract(mutationAuthorityError); } - // Remove existing state dirs before extracting so stale files from later - // snapshots don't persist after restoring an earlier one. OpenClaw's - // image-managed extensions are preserved from the freshly built image and - // excluded from the restore tar; only user/non-managed extension entries - // are cleared and restored from the backup. - if (cleanupStateDirs.length > 0) { + // OpenClaw image-managed extensions must be merged into the live directory, + // so that path still cleans user-owned entries before extraction. The + // staged path below preserves live directories in its rollback tree and + // removes stale content only after every replacement is ready. + const usesStagedDirectoryRestore = + restoreTar !== undefined && pluginRestorePlan.preservedExtensionDirs.length === 0; + if (cleanupStateDirs.length > 0 && !usesStagedDirectoryRestore) { const rmCmd = buildRestoreCleanupCommand( dir, localDirs, @@ -2150,11 +2313,18 @@ function restoreSandboxStateInternal( } if (restoreTar !== undefined) { - const extractCmd = `tar --no-same-owner -xf - -C ${shellQuote(dir)}`; + // Image-managed extensions stay in place and are merged by extracting over + // the live directory, so those restores cannot use the staged swap. + const extractCmd = + pluginRestorePlan.preservedExtensionDirs.length > 0 + ? `tar --no-same-owner -xf - -C ${shellQuote(dir)}` + : buildStagedRestoreCommand(dir, localDirs, staleContentDirs, { + quiesceHermesScheduledWork: manifest.agentType === "hermes", + }); const sshResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), extractCmd], { input: restoreTar, stdio: ["pipe", "pipe", "pipe"], - timeout: 120000, + timeout: manifest.agentType === "hermes" ? 180000 : 120000, }); if (sshResult.status === 0) { diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index a1432dd1cdb..de919376199 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -84,6 +84,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)agents\/hermes\/(?:mcp-config-transaction|runtime-config-guard)\.py$/, testsToRun: runTests("src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts"), }, + { + pattern: /(?:^|\/)agents\/hermes\/restore-cron-guard\.py$/, + testsToRun: runTests("test/hermes-restore-cron-guard.test.ts"), + }, { pattern: /(?:^|\/)test\/e2e\/lib\/ci-compatible-inference\.sh$/, testsToRun: runTests("test/e2e/support/hosted-inference.test.ts"), diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index 7be8f2f420e..492e232b73d 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -66,6 +66,7 @@ describe("Hermes doctor and config hash boundary", () => { const libDir = path.join(tmp, "usr-local-lib-nemoclaw"); const preloadsDir = path.join(libDir, "preloads"); const buildMcpDigestPath = path.join(libDir, "build-hermes-mcp-digest.py"); + const restoreCronGuardPath = path.join(libDir, "hermes-restore-cron-guard.py"); const mcpConfigTransactionPath = path.join(libDir, "hermes-mcp-config-transaction.py"); const langfuseCredentialPatcherPath = path.join( libDir, @@ -107,6 +108,7 @@ describe("Hermes doctor and config hash boundary", () => { path.join(libDir, "hermes-runtime-config-guard.py"), path.join(libDir, "finalize-tirith-marker.py"), buildMcpDigestPath, + restoreCronGuardPath, mcpConfigTransactionPath, mcpCredentialBoundaryPath, path.join(libDir, "state-dir-guard.py"), @@ -145,7 +147,7 @@ describe("Hermes doctor and config hash boundary", () => { expect(result.stderr).toBe(""); expect(fs.readFileSync(chownLogPath, "utf-8")).toBe( [ - `root:root ${path.join(binDir, "nemoclaw-gateway-control")} ${path.join(libDir, "gateway-supervisor.sh")} ${path.join(libDir, "state-dir-guard.py")} ${path.join(libDir, "managed-gateway-control.py")} ${buildMcpDigestPath} ${mcpCredentialBoundaryPath}`, + `root:root ${path.join(binDir, "nemoclaw-gateway-control")} ${path.join(libDir, "gateway-supervisor.sh")} ${path.join(libDir, "state-dir-guard.py")} ${path.join(libDir, "managed-gateway-control.py")} ${buildMcpDigestPath} ${restoreCronGuardPath} ${mcpCredentialBoundaryPath}`, `-R 0:0 ${preloadsDir}`, "", ].join("\n"), @@ -158,6 +160,7 @@ describe("Hermes doctor and config hash boundary", () => { expect(mode(langfuseCredentialPatcherPath)).toBe("444"); expect(mode(mcpCredentialBoundaryPath)).toBe("444"); expect(mode(buildMcpDigestPath)).toBe("444"); + expect(mode(restoreCronGuardPath)).toBe("555"); expect(mode(path.join(libDir, "gateway-supervisor.sh"))).toBe("444"); expect(mode(path.join(libDir, "state-dir-guard.py"))).toBe("500"); expect(mode(path.join(libDir, "managed-gateway-control.py"))).toBe("500"); @@ -258,7 +261,7 @@ describe("Hermes doctor and config hash boundary", () => { expect([mode(configPath), mode(envPath)]).toEqual(["640", "640"]); const hash = runDockerShell(hashCommand, sandboxRoot); - expect(hash.result.status).toBe(0); + expect(hash.result.status, hash.result.stderr).toBe(0); expect(hash.result.stderr).toBe(""); expect(mode(path.join(etcDir, "hermes.config-hash"))).toBe("444"); const verifyHash = spawnSync("sha256sum", ["-c", path.join(etcDir, "hermes.config-hash")], { diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 0ed2c5aa3bf..6d48d83f030 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -44,6 +44,11 @@ const HERMES_INTEGRITY_FILES = [ source: "agents/hermes/finalize-tirith-marker.py", target: "/usr/local/lib/nemoclaw/finalize-tirith-marker.py", }, + { + arg: "NEMOCLAW_HERMES_RESTORE_CRON_GUARD_SHA256", + source: "agents/hermes/restore-cron-guard.py", + target: "/usr/local/lib/nemoclaw/hermes-restore-cron-guard.py", + }, { arg: "NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256", source: "agents/hermes/patch-langfuse-credentials.mts", @@ -273,6 +278,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/finalize-tirith-marker.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py", "COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", "COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py", + "COPY agents/hermes/restore-cron-guard.py /usr/local/lib/nemoclaw/hermes-restore-cron-guard.py", "COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json", "COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py", "COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/", @@ -400,6 +406,7 @@ describe("Hermes final image layout", () => { "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755'", "/usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755'", "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755'", + "/usr/local/lib/nemoclaw/hermes-restore-cron-guard.py 'root:root 555'", "/usr/local/bin/nemoclaw-managed-bootstrap 'root:root 755'", "/usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root 444'", "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", diff --git a/test/hermes-restore-cron-guard.test.ts b/test/hermes-restore-cron-guard.test.ts new file mode 100644 index 00000000000..769c0f22fe8 --- /dev/null +++ b/test/hermes-restore-cron-guard.test.ts @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const GUARD = path.resolve(import.meta.dirname, "..", "agents/hermes/restore-cron-guard.py"); + +function runGuardModule(source: string, args: string[] = []) { + return spawnSync("python3", ["-c", source, GUARD, ...args], { + encoding: "utf8", + timeout: 10_000, + }); +} + +const LOAD_GUARD = ` +import importlib.util +import pathlib +import sys +spec = importlib.util.spec_from_file_location("nemoclaw_restore_cron_guard", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +`; + +describe("Hermes restore cron guard (#7806)", () => { + it("waits for the gateway drain acknowledgement that includes all active work", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + const result = runGuardModule( + `${LOAD_GUARD} +class Drain: + marker = None + def drain_requested(self, *, home): return self.marker is not None + def write_drain_request(self, *, principal, home): self.marker = {"principal": principal} + def read_drain_request(self, *, home): return self.marker + def clear_drain_request(self, *, home): self.marker = None; return True +class Status: + states = [("running", 2), ("draining", 1), ("draining", 0)] + def get_running_pid(self): return 42 + def read_runtime_status(self): + state, active = self.states.pop(0) if len(self.states) > 1 else self.states[0] + return {"pid": 42, "gateway_state": state, "active_agents": active} + def parse_active_agents(self, value): return max(0, int(value)) +drain = Drain() +status = Status() +module._gateway_modules = lambda: (drain, status) +module.secrets.token_hex = lambda _size: "a" * 32 +module.time.sleep = lambda _seconds: None +token = module.begin_drain(pathlib.Path(sys.argv[2]), 1) +print(token) +print(drain.marker["principal"]) +`, + [home], + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "nemoclaw-state-restore:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "nemoclaw-state-restore:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("holds a drain marker while the gateway is not running", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + const result = runGuardModule( + `${LOAD_GUARD} +class Drain: + marker = None + def drain_requested(self, *, home): return self.marker is not None + def write_drain_request(self, *, principal, home): self.marker = {"principal": principal} + def read_drain_request(self, *, home): return self.marker + def clear_drain_request(self, *, home): self.marker = None; return True +class Status: + def get_running_pid(self): return None + def read_runtime_status(self): return None + def parse_active_agents(self, value): return 0 +drain = Drain() +status = Status() +module._gateway_modules = lambda: (drain, status) +module.secrets.token_hex = lambda _size: "c" * 32 +home = pathlib.Path(sys.argv[2]) +token = module.begin_drain(home, 1) +print(token) +print(drain.marker["principal"]) +module.release_drain(home, token) +print(drain.marker, module._ownership_path(home).exists()) +`, + [home], + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "nemoclaw-state-restore:cccccccccccccccccccccccccccccccc", + "nemoclaw-state-restore:cccccccccccccccccccccccccccccccc", + "None False", + ]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("refuses a second restore while another restore owns the drain", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + const result = runGuardModule( + `${LOAD_GUARD} +class Drain: + marker = None + def drain_requested(self, *, home): return self.marker is not None + def write_drain_request(self, *, principal, home): self.marker = {"principal": principal} + def read_drain_request(self, *, home): return self.marker + def clear_drain_request(self, *, home): self.marker = None; return True +class Status: + def get_running_pid(self): return None + def read_runtime_status(self): return None + def parse_active_agents(self, value): return 0 +drain = Drain() +status = Status() +module._gateway_modules = lambda: (drain, status) +home = pathlib.Path(sys.argv[2]) +first = module.begin_drain(home, 1) +try: + module.begin_drain(home, 1) +except RuntimeError as error: + print(error) +print(drain.marker["principal"] == first) +`, + [home], + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "Another NemoClaw restore already owns the Hermes drain", + "True", + ]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("preserves an operator-owned drain and releases only its own marker", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + const result = runGuardModule( + `${LOAD_GUARD} +class Drain: + marker = {"principal": "operator"} + cleared = 0 + def drain_requested(self, *, home): return True + def write_drain_request(self, *, principal, home): raise AssertionError("must not overwrite") + def read_drain_request(self, *, home): return self.marker + def clear_drain_request(self, *, home): self.cleared += 1; self.marker = None; return True +class Status: + def get_running_pid(self): return 42 + def read_runtime_status(self): return {"pid": 42, "gateway_state": "draining", "active_agents": 0} + def parse_active_agents(self, value): return int(value) +drain = Drain() +status = Status() +module._gateway_modules = lambda: (drain, status) +home = pathlib.Path(sys.argv[2]) +print(module.begin_drain(home, 1)) +print(module._ownership_path(home).exists()) +module.release_drain(home, "nemoclaw-state-restore:" + "b" * 32) +print(drain.marker["principal"], drain.cleared) +drain.marker = {"principal": "nemoclaw-state-restore:" + "b" * 32} +module.release_drain(home, "nemoclaw-state-restore:" + "b" * 32) +print(drain.marker, drain.cleared) +`, + [home], + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "preserved", + "False", + "operator 0", + "None 1", + ]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("accepts only enabled jobs whose referenced scripts resolve to readable files", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + const scripts = path.join(fixture, "scripts"); + const cron = path.join(fixture, "cron"); + fs.mkdirSync(scripts); + fs.mkdirSync(cron); + fs.writeFileSync(path.join(scripts, "digest.sh"), "echo ok\n", { mode: 0o600 }); + fs.writeFileSync( + path.join(cron, "jobs.json"), + JSON.stringify({ + jobs: [ + { enabled: true, script: "digest.sh" }, + { enabled: false, script: "missing-disabled.sh" }, + ], + }), + ); + + const result = runGuardModule( + `${LOAD_GUARD} +module._gateway_identity = lambda: None +module.validate_enabled_scripts(pathlib.Path(sys.argv[2])) +`, + [fixture], + ); + expect(result.status).toBe(0); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it.each([ + ["missing", "missing.sh"], + ["path escape", "../outside.sh"], + ])("rejects an enabled job with a %s script", (_case, script) => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + fs.mkdirSync(path.join(fixture, "scripts")); + fs.mkdirSync(path.join(fixture, "cron")); + fs.writeFileSync(path.join(fixture, "outside.sh"), "echo outside\n"); + fs.writeFileSync( + path.join(fixture, "cron", "jobs.json"), + JSON.stringify({ jobs: [{ enabled: true, script }] }), + ); + + const result = runGuardModule( + `${LOAD_GUARD}\nmodule.validate_enabled_scripts(pathlib.Path(sys.argv[2]))`, + [fixture], + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/missing or unreadable|outside the scripts directory/u); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("rejects an enabled job whose script the gateway account cannot read", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + fs.mkdirSync(path.join(fixture, "scripts")); + fs.mkdirSync(path.join(fixture, "cron")); + fs.writeFileSync(path.join(fixture, "scripts", "digest.sh"), "echo ok\n", { mode: 0o600 }); + fs.writeFileSync( + path.join(fixture, "cron", "jobs.json"), + JSON.stringify({ jobs: [{ enabled: true, script: "digest.sh" }] }), + ); + + const result = runGuardModule( + `${LOAD_GUARD} +import os +module._gateway_identity = lambda: (os.geteuid() + 1, set()) +module.validate_enabled_scripts(pathlib.Path(sys.argv[2])) +`, + [fixture], + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing or unreadable"); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("rejects an enabled job whose script sits behind a directory the gateway cannot search", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + const nested = path.join(fixture, "scripts", "private"); + try { + fs.mkdirSync(path.join(fixture, "scripts")); + fs.mkdirSync(path.join(fixture, "cron")); + fs.mkdirSync(nested, { mode: 0o700 }); + fs.writeFileSync(path.join(nested, "digest.sh"), "echo ok\n", { mode: 0o644 }); + fs.writeFileSync( + path.join(fixture, "cron", "jobs.json"), + JSON.stringify({ jobs: [{ enabled: true, script: "private/digest.sh" }] }), + ); + + const result = runGuardModule( + `${LOAD_GUARD} +import os +module._gateway_identity = lambda: (os.geteuid() + 1, set()) +module.validate_enabled_scripts(pathlib.Path(sys.argv[2])) +`, + [fixture], + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("cannot reach through its directories"); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("accepts an enabled job whose script directories the gateway can search", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + const nested = path.join(fixture, "scripts", "shared"); + try { + fs.mkdirSync(path.join(fixture, "scripts"), { mode: 0o755 }); + fs.mkdirSync(path.join(fixture, "cron")); + fs.mkdirSync(nested, { mode: 0o755 }); + fs.writeFileSync(path.join(nested, "digest.sh"), "echo ok\n", { mode: 0o644 }); + fs.writeFileSync( + path.join(fixture, "cron", "jobs.json"), + JSON.stringify({ jobs: [{ enabled: true, script: "shared/digest.sh" }] }), + ); + + const result = runGuardModule( + `${LOAD_GUARD} +import os +module._gateway_identity = lambda: (os.geteuid() + 1, set()) +module.validate_enabled_scripts(pathlib.Path(sys.argv[2])) +`, + [fixture], + ); + expect(result.status).toBe(0); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("rejects an enabled no-agent job without a script", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-guard-")); + try { + fs.mkdirSync(path.join(fixture, "scripts")); + fs.mkdirSync(path.join(fixture, "cron")); + fs.writeFileSync( + path.join(fixture, "cron", "jobs.json"), + JSON.stringify({ jobs: [{ enabled: true, no_agent: true }] }), + ); + + const result = runGuardModule( + `${LOAD_GUARD}\nmodule.validate_enabled_scripts(pathlib.Path(sys.argv[2]))`, + [fixture], + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("has no script"); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 2b79002f099..01c84463440 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1107,6 +1107,7 @@ describe("Hermes sandbox provisioning", () => { const gatewayControlPath = path.join(localBin, "nemoclaw-gateway-control"); const gatewaySupervisorPath = path.join(localLib, "gateway-supervisor.sh"); const buildMcpDigestPath = path.join(localLib, "build-hermes-mcp-digest.py"); + const restoreCronGuardPath = path.join(localLib, "hermes-restore-cron-guard.py"); const mcpConfigTransactionPath = path.join(localLib, "hermes-mcp-config-transaction.py"); const langfuseCredentialPatcherPath = path.join( localLib, @@ -1131,6 +1132,7 @@ describe("Hermes sandbox provisioning", () => { path.join(localLib, "hermes-runtime-config-guard.py"), path.join(localLib, "finalize-tirith-marker.py"), buildMcpDigestPath, + restoreCronGuardPath, mcpConfigTransactionPath, mcpManifest, gatewaySupervisorPath, @@ -1159,13 +1161,14 @@ describe("Hermes sandbox provisioning", () => { expect(result.status, result.stderr).toBe(0); expect(calls).toContain( - `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${mcpManifest}`, + `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${restoreCronGuardPath} ${mcpManifest}`, ); expect((fs.statSync(gatewayControlPath).mode & 0o777).toString(8)).toBe("700"); expect((fs.statSync(mcpConfigTransactionPath).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(langfuseCredentialPatcherPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(mcpManifest).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(buildMcpDigestPath).mode & 0o777).toString(8)).toBe("444"); + expect((fs.statSync(restoreCronGuardPath).mode & 0o777).toString(8)).toBe("555"); expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); expect((fs.statSync(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index c4c5ae690ad..f461b4a9cd1 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -564,6 +564,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { const runtimeGuard = path.join(localLib, "hermes-runtime-config-guard.py"); const tirithMarkerFinalizer = path.join(localLib, "finalize-tirith-marker.py"); const buildMcpDigest = path.join(localLib, "build-hermes-mcp-digest.py"); + const restoreCronGuard = path.join(localLib, "hermes-restore-cron-guard.py"); const mcpTransaction = path.join(localLib, "hermes-mcp-config-transaction.py"); const mcpCredentialBoundary = path.join( localLib, @@ -597,6 +598,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { fs.writeFileSync(runtimeGuard, "# runtime guard fixture\n"); fs.writeFileSync(tirithMarkerFinalizer, "# Tirith marker finalizer fixture\n"); fs.writeFileSync(buildMcpDigest, "# build MCP digest fixture\n"); + fs.writeFileSync(restoreCronGuard, "# restore cron guard fixture\n"); fs.writeFileSync(mcpTransaction, "# MCP transaction fixture\n"); fs.writeFileSync(mcpCredentialBoundary, "{}\n"); fs.mkdirSync(preloadDir, { mode: 0o777 }); @@ -648,6 +650,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { .replaceAll("/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py", runtimeGuard) .replaceAll("/usr/local/lib/nemoclaw/finalize-tirith-marker.py", tirithMarkerFinalizer) .replaceAll("/usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", buildMcpDigest) + .replaceAll("/usr/local/lib/nemoclaw/hermes-restore-cron-guard.py", restoreCronGuard) .replaceAll("/usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py", mcpTransaction) .replaceAll( "/usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json", @@ -684,6 +687,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(fs.statSync(langfuseCredentialPatcher).mode & 0o777).toBe(0o444); expect(fs.statSync(mcpCredentialBoundary).mode & 0o777).toBe(0o444); expect(fs.statSync(buildMcpDigest).mode & 0o777).toBe(0o444); + expect(fs.statSync(restoreCronGuard).mode & 0o777).toBe(0o555); expect(hardenedDir.uid).toBe(fixtureOwner.uid); expect(hardenedDir.gid).toBe(fixtureOwner.gid); expect(hardenedSafetyNet.uid).toBe(fixtureOwner.uid); diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index cca7ac5f0ac..86e416a5920 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -201,7 +201,15 @@ describe("shields-up state-dir lock preserves sandbox-group access + runtime ses it("keeps the complete protected inventory and writable sessions carve-out", () => { expect(HIGH_RISK_STATE_DIRS).toEqual( - expect.arrayContaining(["skills", "agent", "hooks", "agents", "extensions", "workspace"]), + expect.arrayContaining([ + "skills", + "agent", + "hooks", + "scripts", + "agents", + "extensions", + "workspace", + ]), ); expect(CONFIDENTIALITY_STATE_DIRS).toEqual(["credentials", "identity", "pairing"]); expect(WRITABLE_RUNTIME_SUBPATHS).toEqual(["agents/*/sessions"]); diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 80a1e6a31f0..cac63146b32 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -392,17 +392,22 @@ describe("state-dir-guard", () => { expect(mode(path.join(versionDir, "plugin.js"))).toBe(0o644); }); - it("keeps the runtime ledger writable while sealing cron job definitions", () => { + it("keeps the runtime ledger writable while sealing cron jobs and scripts", () => { const { configDir } = fixture(".hermes"); const cronDir = path.join(configDir, "cron"); const cronLedger = path.join(cronDir, "executions.db"); + const scriptsDir = path.join(configDir, "scripts"); + const cronScript = path.join(scriptsDir, "digest.sh"); const runtimeDir = path.join(configDir, "runtime"); const runtimeLedger = path.join(runtimeDir, "cron-executions.db"); fs.mkdirSync(cronDir); + fs.mkdirSync(scriptsDir); fs.mkdirSync(runtimeDir); fs.chmodSync(cronDir, 0o2770); + fs.chmodSync(scriptsDir, 0o2770); fs.chmodSync(runtimeDir, 0o2770); fs.writeFileSync(cronLedger, "legacy ledger\n", { mode: 0o660 }); + fs.writeFileSync(cronScript, "#!/bin/sh\nexit 0\n", { mode: 0o770 }); fs.writeFileSync(runtimeLedger, "active ledger\n", { mode: 0o660 }); fs.chmodSync(runtimeLedger, 0o660); @@ -411,6 +416,8 @@ describe("state-dir-guard", () => { expect(locked.status, locked.stderr).toBe(0); expect(mode(cronDir)).toBe(0o755); expect(mode(cronLedger)).toBe(0o640); + expect(mode(scriptsDir)).toBe(0o755); + expect(mode(cronScript)).toBe(0o750); expect(mode(runtimeDir)).toBe(0o2770); expect(mode(runtimeLedger)).toBe(0o660); fs.appendFileSync(runtimeLedger, "still writable\n"); diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index 39a2907d341..226662cec40 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -59,6 +59,7 @@ const OPAQUE_INPUTS = [ "nemoclaw-blueprint/policies/presets/claude-code.yaml", "agents/hermes/runtime-config-guard.py", "agents/hermes/mcp-config-transaction.py", + "agents/hermes/restore-cron-guard.py", "test/e2e/lib/ci-compatible-inference.sh", "scripts/setup-jetson.sh", ".github/workflows/base-image.yaml", @@ -123,6 +124,9 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy("agents/hermes/mcp-config-transaction.py")).toEqual([ "src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts", ]); + expect(triggeredBy("agents/hermes/restore-cron-guard.py")).toEqual([ + "test/hermes-restore-cron-guard.test.ts", + ]); expect(triggeredBy("test/e2e/lib/ci-compatible-inference.sh")).toEqual([ "test/e2e/support/hosted-inference.test.ts", ]);