* fix(vision): mount images/ upload dir into sandboxes and permit host read (#69575) Desktop, clipboard, and PDF uploads land in the flat top-level HERMES_HOME/images/ dir, but Docker sandboxes only mounted the cache/ subtree and the vision resolver only permitted host reads from the media caches. So vision_analyze on any desktop-app upload failed under a Docker backend with "not reachable inside the sandbox". - Add ("images", "images") to _CACHE_DIRS so the uploads dir is bind-mounted into sandbox containers through the existing profile-scoped cache-mount and reverse-mapping mechanism. - Add home/"images" to _media_cache_roots() so the non-local host-read allowlist permits reading uploads directly from the host filesystem. - Cover the mount entry, the container path mapping, and the Docker-mode resolver read for a profile-scoped upload. Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> * fix(tui_gateway): write image uploads under the session's profile home (#69575) The attach RPCs (image.attach_bytes, clipboard.paste, pdf.attach) wrote uploads to the gateway's module-cached launch home via _hermes_home/"images". Those RPCs run before prompt.submit installs the session's profile HERMES_HOME override, so in a multi-profile / root-gateway deployment the file landed in the launch home while the sandbox mount and the vision host-read allowlist both resolve the session profile's images/ at run time — the agent could never see the upload it was handed. Add _session_images_dir(session), which anchors the write on the session's stored profile_home when present (matching the mount/read scope) and falls back to the launch home otherwise. Route both write sites through it, keeping per-profile isolation. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> --------- Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-1
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user