diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 67fa8fd1b3..09b11d6ea1 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -362,6 +362,39 @@ class TestCacheDirectoryMounts: assert get_cache_directory_mounts() == [] + def test_images_upload_dir_is_mounted(self, tmp_path, monkeypatch): + """The flat top-level ``images/`` upload dir is mounted (#69575). + + Desktop / clipboard / PDF uploads land in ``HERMES_HOME/images``, not + under ``cache/``. Without this entry vision_analyze on a desktop upload + fails because the file is not reachable inside the sandbox. + """ + hermes_home = tmp_path / ".hermes" + (hermes_home / "images").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mounts = get_cache_directory_mounts() + by_container = {m["container_path"]: m["host_path"] for m in mounts} + assert "/root/.hermes/images" in by_container + assert by_container["/root/.hermes/images"] == str(hermes_home / "images") + + def test_images_upload_file_maps_into_container(self, tmp_path, monkeypatch): + """A concrete upload under ``images/`` maps to its container path. + + This is the reverse mapping vision uses to translate a container-visible + path back to the host mount; it must recognise the ``images/`` dir. + """ + hermes_home = tmp_path / ".hermes" + (hermes_home / "images").mkdir(parents=True) + upload = hermes_home / "images" / "upload_20260722_181019_1.png" + upload.write_bytes(bytes.fromhex("89504e470d0a1a0a")) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + assert ( + map_cache_path_to_container(str(upload)) + == "/root/.hermes/images/upload_20260722_181019_1.png" + ) + class TestMapCachePathToContainer: """Tests for map_cache_path_to_container() — the backend-agnostic mapper.""" diff --git a/tests/tools/test_image_source.py b/tests/tools/test_image_source.py index 8d4887629e..112dbc7037 100644 --- a/tests/tools/test_image_source.py +++ b/tests/tools/test_image_source.py @@ -106,6 +106,28 @@ class TestNonLocalBackendConfinement: assert res.data == PNG assert res.origin == "file" + @pytest.mark.asyncio + async def test_desktop_upload_images_dir_host_read(self, tmp_path, monkeypatch): + """Desktop/clipboard uploads under ``HERMES_HOME/images`` are host-read. + + Regression for #69575: uploads land in the flat top-level ``images/`` + dir (not ``cache/images``). Under a sandbox backend the vision resolver + must permit reading them host-side — otherwise it falls through to the + task-id-less sandbox reader and fails with "not reachable inside the + sandbox". + """ + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + upload = home / "images" / "upload_20260722_181019_1.png" + upload.parent.mkdir(parents=True) + upload.write_bytes(PNG) + # No sandbox env: an uploads path must be host-read directly, not routed + # to the in-sandbox exec-read. + res = await isrc.resolve_image_source(str(upload), isrc.ResolveContext()) + assert res.data == PNG + assert res.origin == "file" + @pytest.mark.asyncio async def test_host_secret_outside_cache_routes_to_sandbox_not_host(self, tmp_path, monkeypatch): """A non-cache host path (e.g. /etc/passwd) must NOT be host-read — it diff --git a/tests/tui_gateway/test_session_images_dir.py b/tests/tui_gateway/test_session_images_dir.py new file mode 100644 index 0000000000..0393ec80a2 --- /dev/null +++ b/tests/tui_gateway/test_session_images_dir.py @@ -0,0 +1,54 @@ +"""Write-side scoping for desktop/clipboard image uploads (#69575). + +Attach RPCs (``image.attach_bytes``, ``clipboard.paste``, ``pdf.attach``) run +before ``prompt.submit`` installs the session's profile HERMES_HOME override, so +the upload must be written under the session's *stored* ``profile_home`` — the +same scope the Docker mount and the vision host-read allowlist resolve at run +time. Otherwise, in a multi-profile / root-gateway deployment, the file is +written to the launch home while the sandbox mounts (and vision reads) the +profile home, and the agent can never see the upload it was handed. +""" + +from pathlib import Path +from unittest.mock import patch + +from tui_gateway.server import _session_images_dir + + +def test_profile_home_session_writes_under_profile(tmp_path): + """A session pinned to a profile writes uploads under that profile's home.""" + profile_home = tmp_path / ".hermes" / "profiles" / "coder" + session = {"profile_home": str(profile_home)} + + assert _session_images_dir(session) == profile_home / "images" + + +def test_launch_home_fallback_when_no_profile(tmp_path): + """No ``profile_home`` on the session → the gateway launch home is used.""" + launch_home = tmp_path / ".hermes" + session = {} + + with patch("tui_gateway.server._hermes_home", launch_home): + assert _session_images_dir(session) == launch_home / "images" + + +def test_empty_profile_home_falls_back_to_launch_home(tmp_path): + """An empty-string ``profile_home`` is treated as absent, not as ``/images``.""" + launch_home = tmp_path / ".hermes" + session = {"profile_home": ""} + + with patch("tui_gateway.server._hermes_home", launch_home): + assert _session_images_dir(session) == launch_home / "images" + + +def test_two_profiles_are_isolated(tmp_path): + """Uploads from different profile sessions never share an images dir.""" + home_a = tmp_path / ".hermes" / "profiles" / "a" + home_b = tmp_path / ".hermes" / "profiles" / "b" + + dir_a = _session_images_dir({"profile_home": str(home_a)}) + dir_b = _session_images_dir({"profile_home": str(home_b)}) + + assert dir_a == home_a / "images" + assert dir_b == home_b / "images" + assert dir_a != dir_b diff --git a/tools/credential_files.py b/tools/credential_files.py index a86164ffc3..e57af0bbb4 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -395,6 +395,11 @@ _CACHE_DIRS: list[tuple[str, str]] = [ ("cache/screenshots", "browser_screenshots"), ("cache/web", "web_cache"), ("cache/delegation", "delegation_cache"), + # Desktop/clipboard/PDF uploads land in the flat top-level ``images/`` dir + # (tui_gateway attach RPCs), not under ``cache/``. Mount it so vision can + # reach uploads inside sandbox containers (#69575). No legacy alias exists, + # so both tuple slots are ``images``. + ("images", "images"), ] diff --git a/tools/image_source.py b/tools/image_source.py index e46651e482..d41d3103e9 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -230,6 +230,7 @@ def _media_cache_roots() -> list: home = get_hermes_home() return [ home / "cache", # cache/images, cache/vision, cache/video(s), cache/audio + home / "images", # desktop/clipboard/PDF uploads (tui_gateway) — #69575 home / "image_cache", home / "audio_cache", home / "video_cache", diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 763de70ac8..098b6aa4c2 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -301,7 +301,7 @@ def _(rid, params: dict) -> dict: return _err(rid, 5027, f"clipboard unavailable: {e}") session["image_counter"] = session.get("image_counter", 0) + 1 - img_dir = _hermes_home / "images" + img_dir = _session_images_dir(session) img_dir.mkdir(parents=True, exist_ok=True) img_path = ( img_dir diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2ecb4c3ed9..327ae0f717 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9810,6 +9810,26 @@ def _allowed_image_extensions() -> frozenset[str]: return frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) +def _session_images_dir(session: dict) -> Path: + """Resolve the uploads ``images/`` dir against the session's effective home. + + Attach RPCs (``image.attach_bytes``, ``clipboard.paste``, ``pdf.attach``) + run BEFORE ``prompt.submit`` installs the session's profile HERMES_HOME + override, so ``get_hermes_home()`` here would return the gateway's launch + home. In a multi-profile / root-gateway deployment that writes the upload to + the launch home's ``images/`` while the sandbox mount and the vision host- + read allowlist both resolve the *session profile's* ``images/`` at run time + — so the file the agent tries to read is never the file we wrote (#69575). + + Anchor the write on the session's stored ``profile_home`` when present + (matching the mount/read scope), else fall back to the launch home. Keeps + per-profile isolation: a profile's uploads stay under that profile's home. + """ + profile_home = session.get("profile_home") + base = Path(profile_home) if profile_home else _hermes_home + return base / "images" + + def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: str) -> Path: """Write image bytes into the gateway's images dir and queue them. @@ -9818,7 +9838,7 @@ def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: the existing native-image-attach pipeline. Returns the written path. """ session["image_counter"] = session.get("image_counter", 0) + 1 - img_dir = _hermes_home / "images" + img_dir = _session_images_dir(session) img_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") img_path = img_dir / f"{prefix}_{ts}_{session['image_counter']}{ext}"