diff --git a/package-lock.json b/package-lock.json index d7cd4adf58..791c78c4d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1798,6 +1798,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1814,6 +1815,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1830,6 +1832,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1846,6 +1849,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1862,6 +1866,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1878,6 +1883,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1894,6 +1900,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1910,6 +1917,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1926,6 +1934,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1942,6 +1951,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1958,6 +1968,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1974,6 +1985,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1990,6 +2002,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2006,6 +2019,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2022,6 +2036,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2038,6 +2053,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2054,6 +2070,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2070,6 +2087,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2086,6 +2104,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2102,6 +2121,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2118,6 +2138,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2134,6 +2155,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -2150,6 +2172,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2166,6 +2189,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2182,6 +2206,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2198,6 +2223,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4709,9 +4735,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4728,9 +4751,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4747,9 +4767,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4766,9 +4783,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4785,9 +4799,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4804,9 +4815,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5295,9 +5303,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5314,9 +5319,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5333,9 +5335,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5352,9 +5351,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5603,9 +5599,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -5623,9 +5616,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -5643,9 +5633,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -5663,9 +5650,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -5683,9 +5667,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -12196,9 +12177,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12219,9 +12197,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12242,9 +12217,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12265,9 +12237,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16534,6 +16503,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 09b11d6ea1..cb48d2a721 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -355,12 +355,22 @@ class TestCacheDirectoryMounts: assert "/root/.hermes/cache/images" in container_paths def test_empty_hermes_home(self, tmp_path, monkeypatch): - """No cache dirs → empty list.""" + """Empty home → every staging dir is created and mounted (#76577). + + Docker snapshots the mount list at container creation; skipping + not-yet-existing dirs meant the first attachment/clipboard file after + container start dangled forever. All _CACHE_DIRS entries mount.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - assert get_cache_directory_mounts() == [] + mounts = get_cache_directory_mounts() + container_paths = {m["container_path"] for m in mounts} + assert "/root/.hermes/attachments" in container_paths + assert "/root/.hermes/images" in container_paths + assert "/root/.hermes/cache/images" in container_paths + for mount in mounts: + assert Path(mount["host_path"]).is_dir() def test_images_upload_dir_is_mounted(self, tmp_path, monkeypatch): """The flat top-level ``images/`` upload dir is mounted (#69575). @@ -412,12 +422,55 @@ class TestMapCachePathToContainer: ) - def test_returns_none_when_no_cache_dirs_exist(self, tmp_path, monkeypatch): + def test_maps_path_even_when_cache_dir_missing(self, tmp_path, monkeypatch): + """Missing staging dirs are auto-created at mount-list time (#76577): + Docker snapshots mounts at container creation, so a dir that appears + later would dangle for the container's whole life. The map must + therefore succeed (and the dir exist) even before first use.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - assert map_cache_path_to_container(str(hermes_home / "cache" / "images" / "x.png")) is None + mapped = map_cache_path_to_container(str(hermes_home / "cache" / "images" / "x.png")) + assert mapped == "/root/.hermes/cache/images/x.png" + assert (hermes_home / "cache" / "images").is_dir() + + +class TestToAgentVisiblePathPerBackend: + """#76577 follow-up: translation covers every backend that relocates the + Hermes cache — not just docker — and skips the ones where the host path + stays correct (local; singularity auto-binds the host home).""" + + def _staged(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + (hermes_home / "attachments").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + return str(hermes_home / "attachments" / "drop.zip") + + def test_docker_maps_to_root_hermes(self, tmp_path, monkeypatch): + staged = self._staged(tmp_path, monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "docker") + from tools.credential_files import to_agent_visible_cache_path + assert to_agent_visible_cache_path(staged) == "/root/.hermes/attachments/drop.zip" + + def test_ssh_maps_to_tilde_hermes(self, tmp_path, monkeypatch): + staged = self._staged(tmp_path, monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + from tools.credential_files import to_agent_visible_cache_path + assert to_agent_visible_cache_path(staged) == "~/.hermes/attachments/drop.zip" + + @pytest.mark.parametrize("backend", ["local", "singularity", ""]) + def test_untranslated_backends_keep_host_path(self, tmp_path, monkeypatch, backend): + staged = self._staged(tmp_path, monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", backend) + from tools.credential_files import to_agent_visible_cache_path + assert to_agent_visible_cache_path(staged) == staged + + def test_non_cache_path_passes_through(self, tmp_path, monkeypatch): + self._staged(tmp_path, monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "docker") + from tools.credential_files import to_agent_visible_cache_path + assert to_agent_visible_cache_path("/etc/hosts") == "/etc/hosts" class TestIterCacheFiles: diff --git a/tools/credential_files.py b/tools/credential_files.py index a0d5edb889..cd1234a92f 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -422,13 +422,25 @@ def get_cache_directory_mounts( mounts: List[Dict[str, str]] = [] for new_subpath, old_name in _CACHE_DIRS: host_dir = get_hermes_dir(new_subpath, old_name) - if host_dir.is_dir(): - # Always map to the *new* container layout regardless of host layout. - container_path = f"{container_base.rstrip('/')}/{new_subpath}" - mounts.append({ - "host_path": str(host_dir), - "container_path": container_path, - }) + if not host_dir.is_dir(): + # Create missing staging dirs instead of skipping them: Docker + # snapshots this mount list at container CREATION, so a dir that + # appears later (first desktop attachment, first clipboard image) + # would dangle for the whole life of a persistent container + # (#76577). An empty bind-mounted dir costs nothing; a missing + # mount costs the feature. get_hermes_dir() already resolved + # new-vs-legacy layout, so creating its answer cannot shadow a + # populated legacy dir. + try: + host_dir.mkdir(parents=True, exist_ok=True) + except OSError: + continue # unwritable home (tests, RO mounts) — skip as before + # Always map to the *new* container layout regardless of host layout. + container_path = f"{container_base.rstrip('/')}/{new_subpath}" + mounts.append({ + "host_path": str(host_dir), + "container_path": container_path, + }) return mounts @@ -488,14 +500,33 @@ def to_agent_visible_cache_path( Returns the input unchanged if it is not under any auto-mounted cache directory, or if the active terminal backend does not require path - translation (only Docker for now). + translation (local). + + Per-backend base (mirrors ``_agent_cache_base_for_env`` in + tools/image_generation_tool.py, the proven heuristics for where each + backend's Hermes cache lands): + + * docker / modal — bind-mounted (docker) or per-file-synced (modal) at + ``/root/.hermes`` (the *container_base* default). + * ssh / daytona / vercel_sandbox — file-synced under the remote user's + home; ``~/.hermes`` is shell-expanded by the remote shell, so tool + commands resolve it regardless of the actual remote home. Previously + these backends synced the bytes but still rendered the dangling host + path (#76577 gap). + * singularity — NOT translated: Apptainer auto-binds the host home, so + the host path is directly readable and translation would dangle + (cache dirs are not remapped into that sandbox). + + Backend is identified by TERMINAL_ENV (same env var + tools/terminal_tool.py reads in _get_environment_config). """ - # Only Docker backend requires translation at this time. Other backends - # (Modal, Daytona, Vercel) use different mount semantics and will be - # addressed separately if needed. Backend is identified by TERMINAL_ENV - # (same env var tools/terminal_tool.py reads in _get_environment_config). - if os.environ.get("TERMINAL_ENV", "local") != "docker": - return host_path + backend = (os.environ.get("TERMINAL_ENV") or "local").strip().lower() + if backend in ("docker", "modal"): + pass # /root/.hermes default + elif backend in ("ssh", "daytona", "vercel_sandbox"): + container_base = "~/.hermes" + else: + return host_path # local, singularity, unknown: host path is correct mapped = map_cache_path_to_container(host_path, container_base=container_base) return mapped if mapped is not None else host_path