Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6462e22
fix(state): restore Hermes cron scripts before enabling restored jobs
laitingsheng Jul 30, 2026
7da56a0
test(state): keep the staged restore test body linear
laitingsheng Jul 30, 2026
617ea5d
fix(state): remove the restore archive copy when publishing fails
laitingsheng Jul 30, 2026
66e47a6
test(state): assert the failed publication attempt in staged restore
laitingsheng Jul 30, 2026
dfde003
Merge remote-tracking branch 'origin/main' into fix/hermes-cron-scrip…
laitingsheng Jul 30, 2026
a5a1de4
merge: sync Hermes cron restore with main
apurvvkumaria Jul 30, 2026
507a354
merge: sync Hermes cron restore with main
apurvvkumaria Jul 30, 2026
bef478a
fix(shields): lock restored Hermes cron scripts
apurvvkumaria Jul 30, 2026
191d7d1
Merge remote-tracking branch 'origin/main' into codex/pr7880-hermes-l…
apurvvkumaria Jul 30, 2026
cef29e3
fix(state): roll back failed staged restores
apurvvkumaria Jul 30, 2026
447a759
test(state): keep rollback fixtures linear
apurvvkumaria Jul 30, 2026
446f157
merge(main): sync PR #7880 with current main
apurvvkumaria Jul 31, 2026
ae5963b
Merge remote-tracking branch 'origin/main' into codex/pr7880-hermes-l…
apurvvkumaria Jul 31, 2026
25e00ce
fix(state): drain Hermes scheduled work during restore
apurvvkumaria Jul 31, 2026
9ec10b3
test(state): linearize Hermes restore fixtures
apurvvkumaria Jul 31, 2026
e198654
test(hermes): cover restore guard image contracts
apurvvkumaria Jul 31, 2026
88f6b0c
merge(main): sync Hermes state restore coverage
apurvvkumaria Jul 31, 2026
b52ec4f
Merge branch 'main' into fix/hermes-cron-script-state-restore
senthilr-nv Jul 31, 2026
7824b3f
merge: resolve conflicts with main
github-actions[bot] Aug 2, 2026
a5a698f
merge: resolve conflicts with main
github-actions[bot] Aug 3, 2026
6949d3e
fix(state): close the staged restore command builder
laitingsheng Aug 4, 2026
664b91f
merge(main): refresh #7880 branch
laitingsheng Aug 4, 2026
9affeda
fix(state): hold Hermes restore drain ownership atomically
laitingsheng Aug 4, 2026
2056cc6
merge(main): refresh #7880 branch
laitingsheng Aug 4, 2026
2cd6d4e
merge: resolve conflicts with main
github-actions[bot] Aug 4, 2026
bc0391d
merge(main): refresh #7880 branch
laitingsheng Aug 5, 2026
6a74ed6
fix(hermes): pin the current restore cron guard digest
laitingsheng Aug 5, 2026
af6abcc
merge(main): refresh #7880 branch
laitingsheng Aug 5, 2026
8f029ed
merge(main): refresh #7880 branch
laitingsheng Aug 5, 2026
f7cba25
Merge branch 'main' into fix/hermes-cron-script-state-restore
cv Aug 5, 2026
a7544ee
fix(hermes): reject cron scripts the gateway cannot reach
laitingsheng Aug 5, 2026
554aa38
merge(main): refresh #7880 branch
laitingsheng Aug 5, 2026
61b91a3
merge(main): refresh #7880 branch
laitingsheng Aug 5, 2026
1e263e6
merge: resolve conflicts with main
github-actions[bot] Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 4 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
279 changes: 279 additions & 0 deletions agents/hermes/restore-cron-guard.py
Original file line number Diff line number Diff line change
@@ -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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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())
11 changes: 10 additions & 1 deletion docs/manage-sandboxes/backup-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ If a copied file or parent directory changes identity during the operation, snap
<AgentOnly variant="hermes">
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/<name>/` use raw directory capture and can be inconsistent if a write overlaps the snapshot.
Expand Down Expand Up @@ -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.

<Warning>
Post-restore policy reconciliation is best-effort.
NemoClaw warns and continues the remaining restore steps in these cases:
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,8 @@ For OpenClaw, the backed-up paths include agents, extensions, workspace, skills,
</AgentOnly>
<AgentOnly variant="hermes">

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.

</AgentOnly>
Expand Down
Loading