fix(vision): make desktop image uploads reachable from profile Docker sandboxes (#69575) (#75671)

* 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:
Austin Pickett
2026-07-31 17:43:37 -04:00
committed by GitHub
parent 4b60979dc1
commit e444d16580
7 changed files with 137 additions and 2 deletions
+33
View File
@@ -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."""
+22
View File
@@ -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
+5
View File
@@ -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"),
]
+1
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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}"