Files
hermes-agent/tools/environments/file_sync.py

407 lines
18 KiB
Python

"""Shared file sync manager for remote execution backends.
Tracks local file changes via mtime+size, detects deletions, and syncs to
remote environments transactionally. Used by SSH, Modal, and Daytona.
Docker and Singularity use bind mounts (live host FS view) and don't need this.
"""
import hashlib
import logging
import os
import posixpath
import shlex
import shutil
import signal
import tarfile
import tempfile
import threading
import time
try:
import fcntl
except ImportError:
fcntl = None # Windows — file locking skipped
from pathlib import Path
from typing import Callable
from hermes_constants import get_hermes_home
from tools.environments.base import _file_mtime_key
logger = logging.getLogger(__name__)
# Tests patch these module-level aliases instead of ``time.sleep`` /
# ``time.monotonic``: patching attributes on the shared ``time`` module object
# leaks into unrelated threads under xdist and inflates retry call counts.
_sleep = time.sleep
_monotonic = time.monotonic
_SYNC_INTERVAL_SECONDS = 5.0
_FORCE_SYNC_ENV = "HERMES_FORCE_FILE_SYNC"
# Transport callbacks provided by each backend
UploadFn = Callable[[str, str], None] # (host_path, remote_path) -> raises on failure
BulkUploadFn = Callable[[list[tuple[str, str]]], None] # [(host_path, remote_path), ...] -> raises on failure
BulkDownloadFn = Callable[[Path], None] # (dest_tar_path) -> writes tar archive, raises on failure
DeleteFn = Callable[[list[str]], None] # (remote_paths) -> raises on failure
GetFilesFn = Callable[[], list[tuple[str, str]]] # () -> [(host_path, remote_path), ...]
_SYNC_BACK_MAX_RETRIES = 3
_SYNC_BACK_BACKOFF = (2, 4, 8) # seconds between retries
_SYNC_BACK_MAX_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB — refuse to extract larger tars
def iter_sync_files(container_base: str = "/root/.hermes") -> list[tuple[str, str]]:
"""Enumerate all (host_path, remote_path) pairs to sync to a remote. Credential paths are
remapped from the hardcoded /root/.hermes to *container_base* (remote home may differ)."""
# Late import: credential_files pulls in agent modules (circular at module level).
from tools.credential_files import get_credential_file_mounts, iter_cache_files, iter_skills_files
files = [
(entry["host_path"], entry["container_path"].replace("/root/.hermes", container_base, 1))
for entry in get_credential_file_mounts()]
files += [
(entry["host_path"], entry["container_path"])
for entry in (*iter_skills_files(container_base=container_base),
*iter_cache_files(container_base=container_base))]
return files
def _resolve_host_path_str(host_path: str) -> str:
"""Canonical string form of a host path (``resolve()`` falling back to ``expanduser()``)."""
try:
return str(Path(host_path).expanduser().resolve())
except OSError:
return str(Path(host_path).expanduser())
def _credential_host_paths() -> set[str]:
"""Return credential files that are upload-only for remote sandboxes."""
try:
from tools.credential_files import get_credential_file_mounts
mounts = get_credential_file_mounts()
except Exception:
return set()
return {
_resolve_host_path_str(entry["host_path"])
for entry in mounts if isinstance(entry, dict) and entry.get("host_path")}
def quoted_rm_command(remote_paths: list[str]) -> str:
"""Build a shell ``rm -f`` command for a batch of remote paths."""
return "rm -f " + " ".join(shlex.quote(p) for p in remote_paths)
def quoted_mkdir_command(dirs: list[str]) -> str:
"""Build a shell ``mkdir -p`` command for a batch of directories."""
return "mkdir -p " + " ".join(shlex.quote(d) for d in dirs)
def unique_parent_dirs(files: list[tuple[str, str]]) -> list[str]:
"""Extract sorted unique parent directories from (host, remote) pairs."""
return sorted({posixpath.dirname(remote) for _, remote in files})
def _sha256_file(path: str) -> str:
"""Return hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
class FileSyncManager:
"""Tracks local file changes and syncs to a remote environment. Backends supply transport
callbacks (upload, delete) and a file-source callable; the manager handles mtime-based
change detection, deletion tracking, rate limiting, and transactional state."""
def __init__(
self,
get_files_fn: GetFilesFn,
upload_fn: UploadFn,
delete_fn: DeleteFn,
sync_interval: float = _SYNC_INTERVAL_SECONDS,
bulk_upload_fn: BulkUploadFn | None = None,
bulk_download_fn: BulkDownloadFn | None = None):
self._get_files_fn = get_files_fn
self._upload_fn = upload_fn
self._bulk_upload_fn = bulk_upload_fn
self._bulk_download_fn = bulk_download_fn
self._delete_fn = delete_fn
self._transaction_lock = threading.Lock()
self._synced_files: dict[str, tuple[float, int]] = {} # remote_path -> (mtime, size)
self._pushed_hashes: dict[str, str] = {} # remote_path -> sha256 hex digest
self._upload_only_host_paths: set[str] = set()
self._last_sync_time: float = 0.0 # monotonic; 0 ensures first sync runs
self._sync_interval = sync_interval
def sync(self, *, force: bool = False) -> None:
"""Run a sync cycle: upload changed files, delete removed files. Rate-limited to once
per ``sync_interval`` unless *force* or ``HERMES_FORCE_FILE_SYNC=1``. Transactional:
state is committed only if ALL operations succeed; on failure it rolls back so the
next cycle retries everything."""
with self._transaction_lock:
self._sync_transaction(force=force)
def _sync_transaction(self, *, force: bool = False) -> None:
"""Execute one sync cycle while holding the per-manager lock."""
if (
not force
and not os.environ.get(_FORCE_SYNC_ENV)
and _monotonic() - self._last_sync_time < self._sync_interval):
return
current_files = self._get_files_fn()
self._upload_only_host_paths.update(_credential_host_paths())
to_upload, new_files, to_delete = self._plan_sync(current_files)
if not to_upload and not to_delete:
self._last_sync_time = _monotonic()
return
prev_files = dict(self._synced_files)
prev_hashes = dict(self._pushed_hashes)
try:
# Hash and upload the same bytes: the original may be saved while
# the transport is reading it or waiting for remote acknowledgement.
with tempfile.TemporaryDirectory(prefix="hermes-sync-push-") as staging:
staged_files = []
pushed_hashes = {}
for index, (host_path, remote_path) in enumerate(to_upload):
staged_path = os.path.join(staging, str(index))
shutil.copy2(host_path, staged_path)
pushed_hashes[remote_path] = _sha256_file(staged_path)
staged_files.append((staged_path, remote_path))
self._push(staged_files, to_delete)
# Commit (all succeeded).
self._pushed_hashes.update(pushed_hashes)
for p in to_delete:
new_files.pop(p, None)
self._pushed_hashes.pop(p, None)
self._synced_files = new_files
self._last_sync_time = _monotonic()
except Exception as exc:
self._synced_files = prev_files
self._pushed_hashes = prev_hashes
# Do NOT advance _last_sync_time: bumping the rate-limit clock on failure would
# suppress the retry for up to _sync_interval, contradicting the retry contract.
logger.warning("file_sync: sync failed, rolled back state: %s", exc)
def _plan_sync(
self, current_files: list[tuple[str, str]]
) -> tuple[list[tuple[str, str]], dict[str, tuple[float, int]], list[str]]:
"""Diff *current_files* against synced state -> ``(to_upload, new_synced_state, to_delete)``."""
to_upload: list[tuple[str, str]] = []
new_files = dict(self._synced_files)
for host_path, remote_path in current_files:
file_key = _file_mtime_key(host_path)
if file_key is None or self._synced_files.get(remote_path) == file_key:
continue
to_upload.append((host_path, remote_path))
new_files[remote_path] = file_key
current_remote_paths = {remote for _, remote in current_files}
to_delete = [p for p in self._synced_files if p not in current_remote_paths]
return to_upload, new_files, to_delete
def _push(self, to_upload: list[tuple[str, str]], to_delete: list[str]) -> None:
"""Run the transport calls for one cycle (bulk upload when available)."""
if to_upload:
logger.debug("file_sync: uploading %d file(s)", len(to_upload))
if to_delete:
logger.debug("file_sync: deleting %d stale remote file(s)", len(to_delete))
if to_upload and self._bulk_upload_fn is not None:
self._bulk_upload_fn(to_upload)
logger.debug("file_sync: bulk-uploaded %d file(s)", len(to_upload))
else:
for host_path, remote_path in to_upload:
self._upload_fn(host_path, remote_path)
logger.debug("file_sync: uploaded %s -> %s", host_path, remote_path)
if to_delete:
self._delete_fn(to_delete)
logger.debug("file_sync: deleted %s", to_delete)
# --- Sync-back: pull remote changes to host on teardown ---
def sync_back(self, hermes_home: Path | None = None) -> None:
"""Pull remote changes back to the host: download the remote ``.hermes/`` as a tar and
apply only files whose SHA-256 differs from what was pushed. SIGINT is deferred until
complete; concurrent gateway sandboxes are serialized via a file lock."""
with self._transaction_lock:
self._sync_back_transaction(hermes_home=hermes_home)
def _sync_back_transaction(self, hermes_home: Path | None = None) -> None:
"""Execute sync-back (with retries) against a stable snapshot of manager state."""
if self._bulk_download_fn is None:
return
# Nothing was ever committed (initial push failed or never ran): skip
# to avoid retry storms against an uninitialized remote .hermes/.
if not self._pushed_hashes and not self._synced_files:
logger.debug("sync_back: no prior push state — skipping")
return
lock_path = (hermes_home or get_hermes_home()) / ".sync.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
last_exc: Exception | None = None
for attempt in range(_SYNC_BACK_MAX_RETRIES):
try:
self._sync_back_once(lock_path)
return
except Exception as exc:
last_exc = exc
if attempt < _SYNC_BACK_MAX_RETRIES - 1:
delay = _SYNC_BACK_BACKOFF[attempt]
logger.warning("sync_back: attempt %d failed (%s), retrying in %ds", attempt + 1, exc, delay)
_sleep(delay)
logger.warning("sync_back: all %d attempts failed: %s", _SYNC_BACK_MAX_RETRIES, last_exc)
def _sync_back_once(self, lock_path: Path) -> None:
"""Single sync-back attempt with SIGINT protection and file lock."""
# signal.signal() only works from the main thread; gateway cleanup()
# may run from a worker thread — skip SIGINT deferral there.
on_main_thread = threading.current_thread() is threading.main_thread()
deferred_sigint: list[object] = []
original_handler = None
if on_main_thread:
original_handler = signal.getsignal(signal.SIGINT)
def _defer_sigint(signum, frame):
deferred_sigint.append((signum, frame))
logger.debug("sync_back: SIGINT deferred until sync completes")
signal.signal(signal.SIGINT, _defer_sigint)
try:
self._sync_back_locked(lock_path)
finally:
if on_main_thread and original_handler is not None:
signal.signal(signal.SIGINT, original_handler)
if deferred_sigint:
# Re-deliver the deferred Ctrl+C to the restored handler. ``os.kill(os.getpid(),
# SIGINT)`` is NOT graceful on Windows (routes to TerminateProcess, hard-killing
# the CLI); ``raise_signal`` invokes the handler everywhere.
signal.raise_signal(signal.SIGINT)
def _sync_back_locked(self, lock_path: Path) -> None:
"""Sync-back under file lock (serializes concurrent gateways)."""
if fcntl is None:
# Windows: no flock — run without serialization
self._sync_back_impl()
return
lock_fd = open(lock_path, "w", encoding="utf-8")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
self._sync_back_impl()
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except (OSError, IOError):
pass
lock_fd.close()
def _sync_back_impl(self) -> None:
"""Download, diff, and apply remote changes to host."""
if self._bulk_download_fn is None:
raise RuntimeError("_sync_back_impl called without bulk_download_fn")
# Cache file mapping once to avoid O(n*m) from repeated iteration
try:
file_mapping = list(self._get_files_fn())
except Exception:
file_mapping = []
# mkstemp + close: NamedTemporaryFile keeps an exclusive handle on Windows, so the
# backend's open(dest, "wb") / write_bytes on the same path raised PermissionError.
fd, tar_path = tempfile.mkstemp(suffix=".tar")
os.close(fd)
try:
self._bulk_download_fn(Path(tar_path))
# A misbehaving sandbox could produce an arbitrarily large tar.
try:
tar_size = os.path.getsize(tar_path)
except OSError:
tar_size = 0
if tar_size > _SYNC_BACK_MAX_BYTES:
logger.warning(
"sync_back: remote tar is %d bytes (cap %d) — skipping extraction",
tar_size, _SYNC_BACK_MAX_BYTES)
return
with tempfile.TemporaryDirectory(prefix="hermes-sync-back-") as staging:
with tarfile.open(tar_path) as tar:
tar.extractall(staging, filter="data")
upload_only = self._upload_only_host_paths | _credential_host_paths()
applied = 0
for dirpath, _dirnames, filenames in os.walk(staging):
for fname in filenames:
staged_file = os.path.join(dirpath, fname)
# Remote keys are POSIX; relpath uses host separators (backslashes on Windows).
remote_path = "/" + Path(os.path.relpath(staged_file, staging)).as_posix()
applied += self._apply_staged_file(staged_file, remote_path, file_mapping, upload_only)
if applied:
logger.info("sync_back: applied %d changed file(s)", applied)
else:
logger.debug("sync_back: no remote changes detected")
finally:
try:
os.unlink(tar_path)
except OSError:
pass
def _apply_staged_file(
self, staged_file: str, remote_path: str, file_mapping: list[tuple[str, str]], upload_only_host_paths: set[str],
) -> int:
"""Copy one extracted remote file onto the host if it changed since push. Returns 1 if
applied, 0 if skipped (unchanged, unmapped, or an upload-only credential). A host file
modified since push is overwritten with the remote version (last-write-wins) with a warning."""
pushed_hash = self._pushed_hashes.get(remote_path)
if pushed_hash is not None and _sha256_file(staged_file) == pushed_hash:
return 0 # unchanged from push
host_path = self._resolve_host_path(remote_path, file_mapping)
if host_path is None:
host_path = self._infer_host_path(remote_path, file_mapping, upload_only_host_paths=upload_only_host_paths)
if host_path is None:
logger.debug("sync_back: skipping %s (no host mapping)", remote_path)
return 0
if self._is_upload_only_host_path(host_path, upload_only_host_paths):
logger.debug("sync_back: skipping upload-only credential file %s", remote_path)
return 0
if pushed_hash is not None and os.path.exists(host_path) and _sha256_file(host_path) != pushed_hash:
logger.warning(
"sync_back: conflict on %s — host modified "
"since push, remote also changed. Applying remote version (last-write-wins).",
remote_path)
os.makedirs(os.path.dirname(host_path), exist_ok=True)
shutil.copy2(staged_file, host_path)
return 1
def _resolve_host_path(self, remote_path: str, file_mapping: list[tuple[str, str]] | None = None) -> str | None:
"""Find the host path for a known remote path from the file mapping."""
return next((host for host, remote in file_mapping or [] if remote == remote_path), None)
def _infer_host_path(self, remote_path: str, file_mapping: list[tuple[str, str]] | None = None, *,
upload_only_host_paths: set[str] | None = None) -> str | None:
"""Infer a host path for a new remote file by matching path prefixes: an existing
remote->host pair whose parent directory prefixes *remote_path* gets the same
substitution (``/root/.hermes/skills/b.md`` -> ``~/.hermes/skills/b.md``)."""
upload_only_host_paths = upload_only_host_paths or set()
for host, remote in file_mapping or []:
if self._is_upload_only_host_path(host, upload_only_host_paths):
continue
remote_dir = posixpath.dirname(remote) # remote paths are POSIX even on a Windows host
if remote_path.startswith(remote_dir + "/"):
return str(Path(host).parent / remote_path[len(remote_dir) + 1:])
return None
@staticmethod
def _is_upload_only_host_path(host_path: str, upload_only_host_paths: set[str]) -> bool:
return _resolve_host_path_str(host_path) in upload_only_host_paths