diff --git a/tests/tools/test_sync_back_backends.py b/tests/tools/test_sync_back_backends.py index 82983914a9..5562e6d784 100644 --- a/tests/tools/test_sync_back_backends.py +++ b/tests/tools/test_sync_back_backends.py @@ -115,7 +115,8 @@ class TestSSHBulkDownload: cmd = mock_run.call_args[0][0] cmd_str = " ".join(cmd) assert "tar cf -" in cmd_str - assert "/home/testuser/.hermes" in cmd_str + assert "-C /" in cmd_str + assert "home/testuser/.hermes" in cmd_str assert "ssh" in cmd_str assert "testuser@example.com" in cmd_str @@ -250,7 +251,7 @@ class TestModalBulkDownload: assert args[0] == "bash" assert args[1] == "-c" assert "tar cf -" in args[2] - assert "-C /root/.hermes" in args[2] + assert "-C / root/.hermes" in args[2] def test_modal_bulk_download_writes_to_dest(self, tmp_path): """Downloaded tar bytes should be written to the dest path.""" @@ -368,7 +369,7 @@ class TestDaytonaBulkDownload: env._daytona_bulk_download(dest) exec_cmd = env._sandbox.process.exec.call_args[0][0] - assert "/home/daytona/.hermes" in exec_cmd + assert "home/daytona/.hermes" in exec_cmd class TestDaytonaCleanup: diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index d916452a4f..93beebf79f 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -170,9 +170,9 @@ class DaytonaEnvironment(BaseEnvironment): def _daytona_bulk_download(self, dest: Path) -> None: """Download remote .hermes/ as a tar archive.""" - base = shlex.quote(self._remote_home) + rel_base = f"{self._remote_home}/.hermes".lstrip("/") self._sandbox.process.exec( - f"tar cf /tmp/.hermes_sync.tar -C {base}/.hermes ." + f"tar cf /tmp/.hermes_sync.tar -C / {shlex.quote(rel_base)}" ) self._sandbox.fs.download_file("/tmp/.hermes_sync.tar", str(dest)) diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py index 8e29297812..aa8e610bfc 100644 --- a/tools/environments/file_sync.py +++ b/tools/environments/file_sync.py @@ -15,10 +15,12 @@ import shutil import signal import tarfile import tempfile +import threading import time 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__) @@ -211,7 +213,7 @@ class FileSyncManager: if self._bulk_download_fn is None: return - lock_path = (hermes_home or Path.home() / ".hermes") / ".sync.lock" + lock_path = (hermes_home or get_hermes_home()) / ".sync.lock" lock_path.parent.mkdir(parents=True, exist_ok=True) last_exc: Exception | None = None @@ -233,22 +235,28 @@ class FileSyncManager: def _sync_back_once(self, lock_path: Path) -> None: """Single sync-back attempt with SIGINT protection and file lock.""" - # Defer SIGINT so we don't leave host files in a partial state. + # signal.signal() only works from the main thread. In gateway + # contexts cleanup() may run from a worker thread — skip SIGINT + # deferral there rather than crashing. + on_main_thread = threading.current_thread() is threading.main_thread() + deferred_sigint: list[object] = [] - original_handler = signal.getsignal(signal.SIGINT) + 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") + 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) + signal.signal(signal.SIGINT, _defer_sigint) try: self._sync_back_locked(lock_path) finally: - signal.signal(signal.SIGINT, original_handler) - # Re-raise deferred SIGINT so the caller can handle it. - if deferred_sigint: - os.kill(os.getpid(), signal.SIGINT) + if on_main_thread and original_handler is not None: + signal.signal(signal.SIGINT, original_handler) + if deferred_sigint: + os.kill(os.getpid(), signal.SIGINT) def _sync_back_locked(self, lock_path: Path) -> None: """Sync-back under file lock (serializes concurrent gateways).""" @@ -329,19 +337,18 @@ class FileSyncManager: def _infer_host_path(self, remote_path: str) -> str | None: """Infer a host path for a new remote file by matching path prefixes. - Uses the existing file mapping to find a remote->host prefix pair, - then applies the same prefix substitution to the new file. + Uses the existing file mapping to find a remote->host directory + pair, then applies the same prefix substitution to the new file. + For example, if the mapping has ``/root/.hermes/skills/a.md`` → + ``~/.hermes/skills/a.md``, a new remote file at + ``/root/.hermes/skills/b.md`` maps to ``~/.hermes/skills/b.md``. """ try: for host, remote in self._get_files_fn(): - # Find a common prefix (e.g. /root/.hermes/skills -> ~/.hermes/skills) - remote_dir = str(Path(remote).parent) + "/" - if remote_path.startswith(remote_dir) or remote_path.startswith( - str(Path(remote).parent.parent) + "/" - ): + remote_dir = str(Path(remote).parent) + if remote_path.startswith(remote_dir + "/"): host_dir = str(Path(host).parent) - remote_base_dir = str(Path(remote).parent) - suffix = remote_path[len(remote_base_dir):] + suffix = remote_path[len(remote_dir):] return host_dir + suffix except Exception: pass diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 9a73a7d976..b189a96880 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -353,7 +353,7 @@ class ModalEnvironment(BaseEnvironment): """Download remote .hermes/ as a tar archive.""" async def _download(): proc = await self._sandbox.exec.aio( - "bash", "-c", "tar cf - -C /root/.hermes ." + "bash", "-c", "tar cf - -C / root/.hermes" ) data = await proc.stdout.read.aio() exit_code = await proc.wait.aio() diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 433c3c2b1f..17b8b92de6 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -220,9 +220,11 @@ class SSHEnvironment(BaseEnvironment): def _ssh_bulk_download(self, dest: Path) -> None: """Download remote .hermes/ as a tar archive.""" - base = f"{self._remote_home}/.hermes" + # Tar from / with the full path so archive entries preserve absolute + # paths (e.g. home/user/.hermes/skills/f.py), matching _pushed_hashes keys. + rel_base = f"{self._remote_home}/.hermes".lstrip("/") ssh_cmd = self._build_ssh_command() - ssh_cmd.append(f"tar cf - -C {shlex.quote(base)} .") + ssh_cmd.append(f"tar cf - -C / {shlex.quote(rel_base)}") with open(dest, "wb") as f: result = subprocess.run(ssh_cmd, stdout=f, stderr=subprocess.PIPE, timeout=120) if result.returncode != 0: