Vision image-source resolution and Docker backend patch
Cluster 32709 · agent. Data as of
Nothing open on GitHub. Fix merged in #57890 on 4 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #57890. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a bug where the vision_analyze tool does not send images when using the Docker terminal backend, and introduce a unified image-source resolver to consolidate how image sources are handled across terminal backends.
Open issues0
Open PRs0
Fixes on record
-
#57890 fix(vision): unified image-source resolver + terminal-backend confinement
files:
scripts/release.py,tests/integration/test_vision_docker_resolve.py,tests/tools/test_image_source.py,tests/tools/test_vision_native_fast_path.py,tests/tools/test_vision_tools.py,tools/credential_files.py,tools/image_source.py,tools/vision_tools.pyShow the diff
-
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "JAlmanzarMint", # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890 "[redacted:email]": "ooiuuii", # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings) "[redacted:email]": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677). "[redacted:email]": "danilofalcao", # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel) -
tests/integration/test_vision_docker_resolve.py@@ -0,0 +1,157 @@ +"""Docker integration tests for the vision image-source resolver. + +Exercises the in-sandbox exec-read path that unit tests can only mock. Two +scenarios that ONLY the container filesystem can serve: + + * a tmpfs ``/workspace`` file with no host path at all, and + * a root-owned mode-600 file the host user cannot read, + +both delivered by the resolver's ``base64`` exec-read fallback +(``tools/image_source._resolve_container_fallback``). This is the same path +that provides terminal-backend confinement for GHSA-gpxw-6wxv-w3qq: under a +non-local backend a non-cache path is read INSIDE the container, never on the +host. + +Gating follows the repo convention: the ``integration`` marker excludes these +from the default suite (``addopts = -m 'not integration'`` in pyproject.toml); +they run under ``pytest -m integration`` when a Docker daemon is available and +skip cleanly when it is not. Container spin-up exceeds the 30s suite default, +so the timeout is bumped to 180s. + +Run: pytest -m integration tests/integration/test_vision_docker_resolve.py +""" +import base64 +import shutil +import subprocess + +import pytest + + +def _docker_available() -> bool: + """True iff a docker CLI is on PATH and the daemon answers.""" + if shutil.which("docker") is None: + return False + try: + return subprocess.run( + ["docker", "info"], capture_output=True, timeout=5 + ).returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +pytestmark = [ + pytest.mark.integration, + pytest.mark.timeout(180), + pytest.mark.skipif(not _docker_available(), reason="Docker daemon not available"), +] + +# A real 1x1 PNG. +_TINY_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +) + + [redacted:email] +def docker_backend(request, monkeypatch): + """A live DockerEnvironment registered so ``get_active_env`` finds it. + + Unique task_id per test: DockerEnvironment derives the container name from + the task_id, so a shared id would make one test's teardown remove the + other's container. + """ + from tools import terminal_tool + from tools.environments import docker as docker_env + + # The resolver keys the exec-read off TERMINAL_ENV=docker. + monkeypatch.setenv("TERMINAL_ENV", "docker") + + task_id = f"vision-docker-resolve-{request.node.name}" + env = docker_env.DockerEnvironment( + image="python:3.11-slim", + cwd="/workspace", + timeout=120, + task_id=task_id, + volumes=[], + network=False, + ) + # Register under the raw task_id; get_active_env() falls back to the raw + # key after trying the _resolve_container_task_id() mapping. + with terminal_tool._env_lock: + terminal_tool._active_environments[task_id] = env + try: + env._task_id = task_id + yield env + finally: + with terminal_tool._env_lock: + terminal_tool._active_environments.pop(task_id, None) + try: + env.cleanup() + except Exception: + pass + + +def _write_png_in_container(env, path, *, mode=None): + b64 = base64.b64encode(_TINY_PNG).decode() + cmd = f"printf %s {b64} | base64 -d > {path}" + if mode: + cmd += f" && chmod {mode} {path}" + res = env.execute(cmd) + assert res.get("returncode", 1) == 0, res + + [redacted:email] +async def test_resolves_tmpfs_workspace_file(docker_backend): + """A container-only path (no host file) is delivered via exec-read.""" + from tools.image_source import ResolveContext, resolve_image_source + + _write_png_in_container(docker_backend, "/workspace/shot.png") + res = await resolve_image_source( + "/workspace/shot.png", ResolveContext(task_id=docker_backend._task_id)) + assert res.origin == "container" + assert res.mime == "image/png" + assert res.data == _TINY_PNG + + [redacted:email] +async def test_resolves_root_owned_mode600_file(docker_backend): + """Root-owned mode-600 (host user can't read it) is served in-container.""" + from tools.image_source import ResolveContext, resolve_image_source + + _write_png_in_container(docker_backend, "/workspace/secret.png", mode="600") + res = await resolve_image_source( + "/workspace/secret.png", ResolveContext(task_id=docker_backend._task_id)) + assert res.origin == "container" + assert res.mime == "image/png" + assert res.data == _TINY_PNG + + [redacted:email] +async def test_host_secret_path_reads_container_not_host(docker_backend, tmp_path): + """The GHSA-gpxw invariant, end-to-end against real Docker. + + A path that exists on the HOST with secret bytes but does NOT exist in the + container must resolve to the container read (which fails to find it) — + never to the host bytes. Proves vision cannot exfiltrate a host file under + a sandbox backend even when the exact path is real on the host. + """ + from tools.image_source import ( + ImageResolutionError, + ResolveContext, + resolve_image_source, + ) + + # A real host file outside any media cache, holding a secret. + host_secret = [redacted:secret-assignment] / "id_rsa" + host_secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK") + + # That path does not exist inside the fresh container, so the exec-read + # finds nothing and the resolver refuses. The exact failure shape depends + # on the container's base64 build (some exit non-zero -> SourceNotFound, + # some print an error to stderr and exit 0 with empty stdout -> NotAnImage); + # both are ImageResolutionError. What matters for GHSA-gpxw: the resolver + # never returns the HOST bytes. + with pytest.raises(ImageResolutionError) as excinfo: + await resolve_image_source( + str(host_secret), ResolveContext(task_id=docker_backend._task_id)) + # Belt and suspenders: the host secret must not appear anywhere in the error. + assert b"HOST-PRIVATE-KEY".decode() not in str(excinfo.value) -
tests/tools/test_image_source.py@@ -0,0 +1,282 @@ +"""Tests for tools/image_source.py — the unified vision image-source resolver. + +Covers the delivery contract (data:/http/file/local/container source handling, +size cap, magic-byte sniff) AND the terminal-backend confinement security model +(GHSA-gpxw-6wxv-w3qq): under a non-local backend, host reads are confined to the +media caches and every other path is read inside the sandbox via exec-read. +""" + +import base64 +import importlib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + + +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 +JPEG = b"\xff\xd8\xff" + b"\x00" * 64 + + +def _reload(monkeypatch, hermes_home: Path): + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_constants + importlib.reload(hermes_constants) + import tools.image_source as isrc + importlib.reload(isrc) + return isrc + + +class TestDataUrl: + @pytest.mark.asyncio + async def test_valid_data_url_resolves_to_bytes(self, tmp_path, monkeypatch): + isrc = _reload(monkeypatch, tmp_path / "hermes") + b64 = base64.b64encode(PNG).decode() + res = await isrc.resolve_image_source( + f"data:image/png;base64,{b64}", isrc.ResolveContext()) + assert res.data == PNG + assert res.mime == "image/png" + assert res.origin == "data" + + @pytest.mark.asyncio + async def test_non_image_data_url_rejected(self, tmp_path, monkeypatch): + isrc = _reload(monkeypatch, tmp_path / "hermes") + b64 = base64.b64encode(b"not an image").decode() + with pytest.raises(isrc.NotAnImage): + await isrc.resolve_image_source( + f"data:text/plain;base64,{b64}", isrc.ResolveContext()) + + +class TestLocalBackend: + @pytest.mark.asyncio + async def test_local_backend_reads_any_host_path(self, tmp_path, monkeypatch): + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + img = tmp_path / "outside" / "pic.png" + img.parent.mkdir(parents=True) + img.write_bytes(PNG) + res = await isrc.resolve_image_source(str(img), isrc.ResolveContext()) + assert res.data == PNG + assert res.origin == "file" + + @pytest.mark.asyncio + async def test_file_uri_scheme_stripped(self, tmp_path, monkeypatch): + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + img = tmp_path / "pic.jpg" + img.write_bytes(JPEG) + res = await isrc.resolve_image_source(f"file://{img}", isrc.ResolveContext()) + assert res.mime == "image/jpeg" + + @pytest.mark.asyncio + async def test_bare_relative_path_resolves(self, tmp_path, monkeypatch): + """A cwd-relative bare filename ('pic.png') is a valid local source — + main accepted it; the resolver must not regress it (PR review).""" + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + img = tmp_path / "pic.png" + img.write_bytes(PNG) + monkeypatch.chdir(tmp_path) + res = await isrc.resolve_image_source("pic.png", isrc.ResolveContext()) + assert res.data == PNG + assert res.origin == "file" + + @pytest.mark.asyncio + async def test_unknown_url_scheme_rejected(self, tmp_path, monkeypatch): + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + with pytest.raises(isrc.UnsupportedScheme): + await isrc.resolve_image_source( + "ftp://example.com/pic.png", isrc.ResolveContext()) + + @pytest.mark.asyncio + async def test_svg_passes_through_for_rasterization(self, tmp_path, monkeypatch): + """SVG has no raster magic bytes but is passed through with mime + image/svg+xml so the vision call sites can rasterize it to PNG.""" + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + svg = tmp_path / "art.svg" + svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>' + svg.write_bytes(svg_bytes) + res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext()) + assert res.mime == "image/svg+xml" + assert res.data == svg_bytes + + +class TestNonLocalBackendConfinement: + """The security model: under a sandbox backend, host reads are confined to + the media caches; every other path is read inside the sandbox.""" + + @pytest.mark.asyncio + async def test_media_cache_path_host_read(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + cached = home / "cache" / "images" / "inbound.png" + cached.parent.mkdir(parents=True) + cached.write_bytes(PNG) + # No sandbox env needed — a cache path is host-read directly. + res = await isrc.resolve_image_source(str(cached), 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 + routes to the in-sandbox exec-read, which reads the CONTAINER's file.""" + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + + # A real host file outside the caches, holding a "secret". + secret = [redacted:secret-assignment] / "id_rsa" + secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK") + + # Fake sandbox env: its exec-read returns a *different* (container) image, + # proving we read the container filesystem, not the host secret. + container_png_b64 = base64.b64encode(PNG).decode() + calls = {} + + def fake_execute(cmd, **kw): + calls["cmd"] = cmd + return {"returncode": 0, "output": container_png_b64} + + with patch("tools.image_source._get_active_env", + return_value=SimpleNamespace(execute=fake_execute)): + res = await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1")) + + # Read came from the sandbox exec-read, returning the container image — + # the host secret bytes never appear. + assert res.origin == "container" + assert res.data == PNG + assert b"HOST-PRIVATE-KEY" not in res.data + assert "head -c" in calls["cmd"] and "< " in calls["cmd"] # bounded, redirect-safe form + + @pytest.mark.asyncio + async def test_non_cache_path_fails_closed_without_sandbox(self, tmp_path, monkeypatch): + """No active sandbox env -> refuse rather than fall back to a host read.""" + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + secret = [redacted:secret-assignment] / "id_rsa" + secret.write_bytes(b"HOST-PRIVATE-KEY") + + with patch("tools.image_source._get_active_env", return_value=None): + with pytest.raises(isrc.SourceNotFound): + await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1")) + + @pytest.mark.asyncio + async def test_symlink_in_cache_pointing_outside_is_not_host_read(self, tmp_path, monkeypatch): + """A symlink planted inside a cache dir that points at a host secret must + not be host-read (resolve() escapes the cache) — it routes to sandbox.""" + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + secret = [redacted:secret-assignment] / "outside" / "id_rsa" + secret.parent.mkdir(parents=True) + secret.write_bytes(b"HOST-PRIVATE-KEY") + cache_dir = home / "cache" / "images" + cache_dir.mkdir(parents=True) + link = cache_dir / "sneaky.png" + try: + link.symlink_to(secret) + except (OSError, NotImplementedError): + pytest.skip("symlinks unsupported") + + # Fails closed (no sandbox) rather than host-reading the symlink target. + with patch("tools.image_source._get_active_env", return_value=None): + with pytest.raises(isrc.SourceNotFound): + await isrc.resolve_image_source(str(link), isrc.ResolveContext(task_id="t1")) + + +class TestExecReadSafety: + @pytest.mark.asyncio + async def test_exec_read_is_bounded_and_redirect_safe(self, tmp_path, monkeypatch): + """Leading-dash paths go through an input redirect (no argv exposure) + and the read is size-bounded via head -c.""" + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + captured = {} + + def fake_execute(cmd, **kw): + captured["cmd"] = cmd + return {"returncode": 0, "output": base64.b64encode(PNG).decode()} + + with patch("tools.image_source._get_active_env", + return_value=SimpleNamespace(execute=fake_execute)): + await isrc.resolve_image_source( + "/workspace/-i-etc-shadow.png", isrc.ResolveContext(task_id="t1")) + assert f"head -c {isrc._MAX_INGEST_BYTES + 1} < " in captured["cmd"] + assert "'-i-etc-shadow.png'" in captured["cmd"] or "-i-etc-shadow.png" in captured["cmd"] + + @pytest.mark.asyncio + async def test_exec_read_over_cap_rejected(self, tmp_path, monkeypatch): + """A sandbox file larger than the ingest cap is rejected, not embedded.""" + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + # head -c returns cap+1 bytes for an oversized file. + over = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * (isrc._MAX_INGEST_BYTES - 7)).decode() + + def fake_execute(cmd, **kw): + return {"returncode": 0, "output": over} + + with patch("tools.image_source._get_active_env", + return_value=SimpleNamespace(execute=fake_execute)): + with pytest.raises(isrc.SourceTooLarge): + await isrc.resolve_image_source( + "/workspace/huge.png", isrc.ResolveContext(task_id="t1")) + + @pytest.mark.asyncio + async def test_exec_read_nonzero_returncode_raises(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + isrc = _reload(monkeypatch, home) + monkeypatch.setenv("TERMINAL_ENV", "docker") + + def fake_execute(cmd, **kw): + return {"returncode": 1, "output": ""} + + with patch("tools.image_source._get_active_env", + return_value=SimpleNamespace(execute=fake_execute)): + with pytest.raises(isrc.SourceNotFound): + await isrc.resolve_image_source( + "/workspace/nope.png", isrc.ResolveContext(task_id="t1")) + + +class TestSvgNormalization: + """SVG resolves end-to-end: the resolver passes it through as + image/svg+xml and the vision call sites rasterize it to PNG via + _normalize_to_supported_image (PR #52688, folded in).""" + + @pytest.mark.asyncio + async def test_svg_rasterized_when_converter_available(self, tmp_path, monkeypatch): + from tools import vision_tools as vt + isrc = _reload(monkeypatch, tmp_path / "hermes") + monkeypatch.setenv("TERMINAL_ENV", "local") + svg = tmp_path / "art.svg" + svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>') + + def fake_rasterize(svg_path, out_path): + out_path.write_bytes(PNG) + return True + + with patch.object(vt, "_rasterize_svg_to_png", side_effect=fake_rasterize): + res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext()) + assert res.mime == "image/svg+xml" + path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml") + assert err is None + assert mime == "image/png" + assert path.read_bytes() == PNG + path.unlink() + + def test_svg_actionable_error_when_no_converter(self, tmp_path, monkeypatch): + from tools import vision_tools as vt + _reload(monkeypatch, tmp_path / "hermes") + svg = tmp_path / "art.svg" + svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg"/>') + with patch.object(vt, "_rasterize_svg_to_png", return_value=False): + path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml") + assert path is None + assert "rasterizer" in err -
tests/tools/test_vision_native_fast_path.py@@ -119,7 +119,9 @@ def test_missing_file_returns_error_string(self, tmp_path): assert isinstance(result, str) parsed = json.loads(result) assert parsed.get("success") is False - assert "Invalid image source" in parsed.get("error", "") + # Unified resolver: local backend reports a clean not-found. + err = parsed.get("error", "").lower() + assert "image file not found" in err or "no active sandbox" in err def test_empty_image_url_returns_error(self): result = asyncio.get_event_loop().run_until_complete( -
tests/tools/test_vision_tools.py@@ -1,5 +1,6 @@ """Tests for tools/vision_tools.py — URL validation, type hints, error logging.""" +import base64 import json import logging import os @@ -381,26 +382,20 @@ async def test_analysis_error_logs_exc_info(self, caplog): @pytest.mark.asyncio async def test_cleanup_error_logs_exc_info(self, tmp_path, caplog): """Temp file cleanup failure should log warning with exc_info.""" - # Create a real temp file that will be "downloaded" - temp_dir = tmp_path / "temp_vision_images" - temp_dir.mkdir() - - async def fake_download(url, dest, max_retries=3): - """Simulate download by writing file to the expected destination.""" - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(b"\xff\xd8\xff" + b"\x00" * 16) - return dest + # A data: URL resolves to bytes without any network, materializes to a + # vision temp file, then the analysis runs — exercising the temp-cleanup + # path in the finally block. A tiny valid JPEG (magic bytes) passes the + # resolver's magic-byte sniff. + jpeg_b64 = base64.b64encode(b"\xff\xd8\xff" + b"\x00" * 32).decode("ascii") + data_url = f"data:image/jpeg;base64,{jpeg_b64}" with ( - patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), - patch("tools.vision_tools._download_image", side_effect=fake_download), patch( "tools.vision_tools._image_to_base64_data_url", return_value="data:image/jpeg;base64,abc", ), caplog.at_level(logging.WARNING, logger="tools.vision_tools"), ): - # Mock the async_call_llm function to return a mock response mock_response = MagicMock() mock_choice = MagicMock() mock_choice.message.content = "A test image description" @@ -409,16 +404,12 @@ async def fake_download(url, dest, max_retries=3): with ( patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response), ): - # Make unlink fail to trigger cleanup warning - original_unlink = Path.unlink - + # Make unlink fail to trigger the cleanup warning. def failing_unlink(self, *args, **kwargs): raise PermissionError("no permission") with patch.object(Path, "unlink", failing_unlink): - result = await vision_analyze_tool( - "https://example.com/tempimg.jpg", "describe", "test/model" - ) + result = await vision_analyze_tool(data_url, "describe", "test/model") warning_records = [ r @@ -500,7 +491,8 @@ async def test_local_non_image_file_rejected_before_llm_call(self, tmp_path): result = json.loads(await vision_analyze_tool(str(secret), "extract text")) assert result["success"] is False - assert "Only real image files are supported" in result["error"] + # The unified resolver's magic-byte sniff rejects non-images. + assert "not a recognized image" in result["error"] mock_llm.assert_not_awaited() @pytest.mark.asyncio @@ -513,8 +505,8 @@ async def test_blocked_remote_url_short_circuits_before_download(self): } with ( - patch("tools.vision_tools.check_website_access", return_value=blocked), - patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), + patch("tools.website_policy.check_website_access", return_value=blocked), + patch("tools.url_safety.is_safe_url", return_value=True), patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download, ): result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe")) @@ -1258,7 +1250,7 @@ def slow_encode(path, mime_type=None): enc_inflight -= 1 return "data:image/jpeg;base64,AAAA" - async def fake_native(image_url, question): + async def fake_native(image_url, question, task_id=None): nonlocal calls_inflight, calls_peak calls_inflight += 1 calls_peak = max(calls_peak, calls_inflight) -
tools/credential_files.py@@ -402,6 +402,30 @@ def map_cache_path_to_container( return None +def from_agent_visible_cache_path( + container_path: str, + container_base: str = "/root/.hermes", +) -> str: + """Translate a sandbox/container cache path back to its host path. + + Inverse of :func:`to_agent_visible_cache_path`. Returns the input unchanged + when the active backend is not Docker, or when the path is not under any + auto-mounted cache directory — the caller then treats a still-container + path as "no host file" and falls back to an in-container read. + """ + if os.environ.get("TERMINAL_ENV", "local") != "docker": + return container_path + + path = Path(container_path) + for mount in get_cache_directory_mounts(container_base=container_base): + try: + rel = path.relative_to(mount["container_path"]) + except ValueError: + continue + return str(Path(mount["host_path"]) / rel) + return container_path + + def to_agent_visible_cache_path( host_path: str, container_base: str = "/root/.hermes", -
tools/image_source.py@@ -0,0 +1,322 @@ +"""Single resolver for every vision_analyze image source -> bytes + mime. + +All source handling (data:/http(s)/file/local/container) funnels through +:func:`resolve_image_source` so size and magic-byte checks are enforced exactly +once. Returns raw bytes (not a path): the downstream step is base64 -> data URL +(RFC 2397) and provider base64 content blocks. + +Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local +terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), +but vision read images host-side. This resolver enforces the same boundary: + + * local backend -> read any host path (chosen posture, unchanged) + * non-local backend: + path in a media cache -> host-read (the gateway/download caches live on + the host and are bind-mounted into the sandbox) + path anywhere else -> read the bytes *inside the sandbox* via exec-read + (the agent can already ``cat`` any container file; + this stays within the sandbox boundary and never + reaches the host's ``/etc/passwd`` / ``~/.ssh``). + +So a prompt-injected ``vision_analyze('/etc/passwd')`` under Docker reads the +*container's* file (what every other tool sees), not the host's — no escape — +while container-only images (tmpfs ``/workspace``, root-owned) are still +deliverable. This is the unified delivery + confinement model: the same +mechanism that fixes "vision can't see container files" also closes the escape. +""" +from __future__ import annotations + +import asyncio +import base64 +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Raw-bytes INGEST budget — what the resolver will load before handing off. +# This is deliberately the 50MB download cap (tools/vision_tools._VISION_MAX_DOWNLOAD_BYTES), +# NOT the 20MB provider payload cap. The 20MB cap (_MAX_BASE64_BYTES) is a +# *post-resize* limit enforced at the call sites: an oversized raw image must +# still reach the resizer so it can be downscaled under the payload cap. Capping +# raw bytes at 20MB here would reject every 20-50MB photo before resize can run. +_MAX_INGEST_BYTES = 50 * 1024 * 1024 + + +class ImageResolutionError(Exception): + def __init__(self, message: str, *, src: str = "", origin: str = ""): + super().__init__(message) + self.src, self.origin = src, origin + + +class UnsupportedScheme(ImageResolutionError): + pass + + +class SourceUnsafe(ImageResolutionError): # SSRF / path-allowlist + pass + + +class SourceTooLarge(ImageResolutionError): + pass + + +class SourceNotFound(ImageResolutionError): + pass + + +class NotAnImage(ImageResolutionError): + pass + + +@dataclass +class ResolveContext: + task_id: Optional[str] = None + + +@dataclass +class ResolvedImage: + data: bytes + mime: str + origin: str # one of: data | http | file | local | container + + +# Explicit URL scheme, e.g. "ftp://", "s3://". Bare Windows drive paths +# ("C:\x.png") don't match because they lack the "//". +_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://") + + +async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage: + if not isinstance(src, str) or not src.strip(): + raise SourceNotFound("image_url is required", src=str(src)) + s = src.strip() + if s.startswith("data:"): + data, mime = _resolve_data_url(s) + return _finalize(data, mime, "data", s) + if s.startswith(("http://", "https://")): + reason = _http_block_reason(s) + if reason: + raise SourceUnsafe(reason, src=s) + return _finalize(await _download_to_bytes(s), "", "http", s) + + if _SCHEME_RE.match(s) and not s.lower().startswith("file://"): + raise UnsupportedScheme( + "Unrecognized image source scheme. Use an http(s) URL, a local " + "file path, a file:// URI, or a data: URL.", + src=s, + ) + + # Everything else is a filesystem path — including bare relative names + # like "pic.png" (accepted on main; a path-shape gate here regressed them). + candidate = s[len("file://"):] if s.lower().startswith("file://") else s + p = Path(os.path.expanduser(candidate)) + # Confinement decision (see module docstring). Under a non-local backend + # a path is host-readable ONLY if it lands in a media cache (after + # translating a container-visible cache path back to its host mount); + # every other path is read inside the sandbox via exec-read, so a host + # path outside the caches never yields the host's bytes. + host_target = _permitted_host_read_target(p, ctx) + if host_target is not None and host_target.is_file(): + data = await asyncio.to_thread(host_target.read_bytes) + return _finalize(data, "", "file", s) + if _is_local_terminal_backend(): + # Local backend: any path was host-readable, so a miss simply means + # the file doesn't exist — no sandbox to fall back to. + raise SourceNotFound(f"image file not found: '{p}'", src=s, origin="file") + # Not a permitted host read (or the host file is absent) -> read the + # bytes inside the sandbox. Under a sandbox this reads the container's + # filesystem, never the host's. + return await _resolve_container_fallback(p, ctx, s) + + +def _resolve_data_url(s: str) -> tuple[bytes, str]: + header, _, payload = s.partition(",") + if ";base64" not in header: + raise NotAnImage("data: URL must be base64-encoded", src=s[:64]) + declared = header[len("data:"):].split(";", 1)[0].strip() or "application/octet-stream" + # Cheap pre-decode size gate on the encoded length (~4/3 expansion). + if (len(payload) * 3) // 4 > _MAX_INGEST_BYTES: + raise SourceTooLarge("data: URL exceeds size limit", src=s[:64]) + try: + data = base64.b64decode(payload, validate=True) + except Exception as exc: + raise NotAnImage(f"invalid base64 in data: URL: {exc}", src=s[:64]) + return data, declared # real mime verified in _finalize via magic bytes + + +def _http_block_reason(url: str) -> Optional[str]: + """Return a human-readable block reason, or None when the URL is allowed. + + Pre-flight short-circuit: policy-blocked URLs are refused BEFORE any + network I/O. ``_download_image`` re-checks policy internally (per attempt + and against the final redirect target) — that second evaluation is + intentional, not redundant: this one guarantees no bytes move for a + blocked URL; the inner one covers redirects and non-resolver callers. + Preserves the specific website-policy message so the agent sees *why*. + """ + from tools.url_safety import is_safe_url + from tools.website_policy import check_website_access + + if not is_safe_url(url): + return "blocked: unsafe or private URL" + blocked = check_website_access(url) + if blocked: + return blocked.get("message") or "blocked by website policy" + return None + + +async def _download_to_bytes(url: str) -> bytes: + import tempfile + + from tools.vision_tools import _download_image + + with tempfile.NamedTemporaryFile(suffix=".img", delete=False) as tf: + tmp = Path(tf.name) + try: + # Enforces the 50MB stream cap, redirect SSRF guard, and website policy. + await _download_image(url, tmp) + return await asyncio.to_thread(tmp.read_bytes) + except PermissionError as exc: # website policy block + raise SourceUnsafe(str(exc), src=url, origin="http") + finally: + tmp.unlink(missing_ok=True) + + +def _is_local_terminal_backend() -> bool: + """True when the terminal backend runs directly on the host. + + Mirrors ``tools.browser_tool._is_local_backend`` and terminal_tool's own + dispatch, which key off ``TERMINAL_ENV``. + """ + return os.getenv("TERMINAL_ENV", "local").strip().lower() in ("local", "") + + +def _media_cache_roots() -> list: + """Agent-managed media cache directories under HERMES_HOME (host side). + + The only host paths vision may read under a non-local backend: gateway- + downloaded inbound media and the tools' own URL-download temp dirs. Covers + the consolidated ``cache/`` layout and the legacy flat directories. + """ + from hermes_constants import get_hermes_home + + home = get_hermes_home() + return [ + home / "cache", # cache/images, cache/vision, cache/video(s), cache/audio + home / "image_cache", + home / "audio_cache", + home / "video_cache", + home / "temp_vision_images", + home / "temp_video_files", + ] + + +def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]: + """Return the host path to read, or ``None`` if a host read is not permitted. + + - Local backend: any path is permitted (chosen posture). Returns ``p``. + - Non-local backend: permitted only if the path resolves inside a media + cache root. A container-visible cache path (e.g. ``/root/.hermes/cache/ + images/x.png``) is first translated back to its host mount; anything that + is not under a cache returns ``None`` so the caller routes it to the + in-sandbox exec-read instead of reading the host filesystem. + """ + if _is_local_terminal_backend(): + try: + return p.resolve() + except Exception: # noqa: BLE001 — unresolved path: let is_file() fail downstream + return p + + from tools.credential_files import from_agent_visible_cache_path + + host_candidate = Path(from_agent_visible_cache_path(str(p))) + try: + real = host_candidate.resolve() + except Exception: # noqa: BLE001 — cannot resolve -> not a safe host read + return None + for root in _media_cache_roots(): + try: + real.relative_to(root.resolve()) + return real + except ValueError: + continue + return None + + +def _get_active_env(task_id: Optional[str]): + if not task_id: + return None + try: + from tools.terminal_tool import get_active_env + + return get_active_env(task_id) + except Exception: + return None + + +async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> ResolvedImage: + """Read the image bytes inside the sandbox (fail-closed when none exists). + + Reached when a host read is not permitted or the host file is absent. The + agent can already ``cat`` any container file (file_operations.py reads + root-owned mode-600 files this way), so this stays within the same sandbox + boundary and never touches the host filesystem. ``--`` stops a leading-dash + path from being parsed as a ``base64`` option; ``base64 -w0`` is GNU-only, + so pipe through ``tr -d`` for BusyBox. + + Fail-closed: if there is no active sandbox env we refuse rather than falling + back to a host read, so a non-cache host path under a sandbox never leaks. + """ + import asyncio + import shlex + + env = _get_active_env(ctx.task_id) + if env is None: + raise SourceNotFound( + f"'{p}' is not reachable inside the sandbox and no active sandbox " + f"session is available to read it", + src=src, origin="container") + + # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 bytes + # so a huge file (or /dev/zero) can't stream unbounded base64 into host + # memory — the +1 byte lets us distinguish "exactly at the cap" from + # "over the cap" after decode. The input redirect (< path) avoids argv + # entirely, so leading-dash paths can't be parsed as options; base64 + # -w0 is GNU-only, so pipe through tr -d for BusyBox. + # env.execute is a blocking backend exec; keep it off the event loop so a + # multi-MB base64 read doesn't stall every other coroutine. + qp = shlex.quote(str(p)) + res = await asyncio.to_thread( + env.execute, + f"head -c {_MAX_INGEST_BYTES + 1} < {qp} | base64 | tr -d '\\n'") + if res.get("returncode", 1) != 0: + raise SourceNotFound(f"could not read '{p}' inside the sandbox", src=src, origin="container") + try: + data = base64.b64decode(res.get("output", ""), validate=True) + except Exception as exc: + raise NotAnImage(f"sandbox returned non-image data for '{p}': {exc}", src=src) + if len(data) > _MAX_INGEST_BYTES: + raise SourceTooLarge("image exceeds size limit", src=src, origin="container") + return _finalize(data, "", "container", src) + + +def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> ResolvedImage: + """Intrinsic-correctness chokepoint: ingest byte cap + magic-byte sniff. + + The cap here is the generous 50MB *ingest* budget, not the 20MB provider + payload cap — a 20-50MB image must survive this step so the call site can + resize it under the payload cap. See ``_MAX_INGEST_BYTES``. + """ + from tools.vision_tools import _detect_image_mime_type_from_bytes + + if len(data) > _MAX_INGEST_BYTES: + raise SourceTooLarge("image exceeds size limit", src=src, origin=origin) + sniffed = _detect_image_mime_type_from_bytes(data) + if sniffed is None: + if b"<svg" in data[:4096].lower(): + # Pass SVG through — the vision call sites rasterize it to PNG + # via _normalize_to_supported_image before embedding (providers + # only ingest raster images). + return ResolvedImage(data=data, mime="image/svg+xml", origin=origin) + raise NotAnImage("source is not a recognized image", src=src, origin=origin) + return ResolvedImage(data=data, mime=sniffed, origin=origin) -
tools/vision_tools.py@@ -221,11 +221,14 @@ async def _validate_image_url_async(url: str) -> bool: return await async_is_safe_url(url) -def _detect_image_mime_type(image_path: Path) -> Optional[str]: - """Return a MIME type when the file looks like a supported image.""" - with image_path.open("rb") as f: - header = f.read(64) +def _detect_image_mime_type_from_bytes(data: bytes) -> Optional[str]: + """Magic-byte MIME sniff on raw bytes (authoritative; no extension trust). + Returns ``None`` for anything without a recognized image header — including + SVG, which has no magic bytes. The resolver special-cases SVG (sniffs + ``<svg``) and passes it through for rasterization at the call sites. + """ + header = data[:64] if header.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" if header.startswith(b"\xff\xd8\xff"): @@ -236,13 +239,125 @@ def _detect_image_mime_type(image_path: Path) -> Optional[str]: return "image/bmp" if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP": return "image/webp" - if image_path.suffix.lower() == ".svg": - head = image_path.read_text(encoding="utf-8", errors="ignore")[:4096].lower() - if "<svg" in head: - return "image/svg+xml" return None +# Media types the major vision providers (Anthropic in particular) accept for +# inline base64 images. Anything outside this set — SVG, BMP, TIFF, etc. — is +# rejected with a non-retryable 400. Because a vision tool-result is baked into +# immutable conversation history and re-sent every turn, embedding an +# unsupported media_type permanently wedges the session (retries re-send the +# same bad bytes). We MUST normalize to one of these before embedding. +_ANTHROPIC_SUPPORTED_MEDIA_TYPES = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp"} +) + + +def _rasterize_svg_to_png(svg_path: Path, out_path: Path) -> bool: + """Best-effort SVG → PNG rasterization. Returns True on success. + + Tries, in order: cairosvg, svglib+reportlab, then system rasterizers + (rsvg-convert, inkscape). All are soft dependencies; if none is available + we return False and the caller rejects the image with an actionable error + rather than embedding an unsupported media_type that would wedge the + session. + """ + # 1) cairosvg (pure-python-ish, most common) + try: + import cairosvg # type: ignore + cairosvg.svg2png(url=str(svg_path), write_to=str(out_path)) + return out_path.exists() and out_path.stat().st_size > 0 + except Exception: + pass + # 2) svglib + reportlab + try: + from svglib.svglib import svg2rlg # type: ignore + from reportlab.graphics import renderPM # type: ignore + drawing = svg2rlg(str(svg_path)) + if drawing is not None: + renderPM.drawToFile(drawing, str(out_path), fmt="PNG") + return out_path.exists() and out_path.stat().st_size > 0 + except Exception: + pass + # 3) system rasterizers + import shutil as _shutil + import subprocess as _subprocess + for cmd in ( + ["rsvg-convert", "-o", str(out_path), str(svg_path)], + ["inkscape", str(svg_path), "--export-type=png", + f"--export-filename={out_path}"], + ): + if _shutil.which(cmd[0]): + try: + _subprocess.run( + cmd, check=True, capture_output=True, timeout=30, + stdin=_subprocess.DEVNULL, + ) + if out_path.exists() and out_path.stat().st_size > 0: + return True + except Exception: + continue + return False + + +def _normalize_to_supported_image( + image_path: Path, detected_mime: str +) -> tuple[Optional[Path], Optional[str], Optional[str]]: + """Ensure an image is in a vision-provider-supported format. + + Returns a 3-tuple ``(path, mime, error)``: + - If ``detected_mime`` is already supported: ``(image_path, detected_mime, None)``. + - If conversion succeeds: ``(new_png_path, "image/png", None)`` — the new + path is a temp file the CALLER must clean up. + - If conversion is impossible: ``(None, None, <error message>)``. + + SVG is rasterized to PNG (best-effort, soft deps). Other raster formats + Pillow can read (BMP, TIFF, etc.) are re-encoded to PNG. This runs BEFORE + the image is base64-embedded into conversation history, so an unsupported + media_type can never reach the provider and wedge the session. + """ + if detected_mime in _ANTHROPIC_SUPPORTED_MEDIA_TYPES: + return image_path, detected_mime, None + + out_dir = get_hermes_dir("cache/vision", "temp_vision_images") + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"converted_{uuid.uuid4()}.png" + + # SVG: needs a rasterizer (Pillow cannot render SVG). + if detected_mime == "image/svg+xml": + if _rasterize_svg_to_png(image_path, out_path): + return out_path, "image/png", None + return ( + None, + None, + "This is an SVG, which vision models cannot read directly, and no " + "SVG rasterizer is installed (tried cairosvg, svglib, rsvg-convert, " + "inkscape). Convert the SVG to PNG first — e.g. open it in a browser " + "and screenshot it, or install a rasterizer " + "(`pip install cairosvg`) — then re-run vision_analyze on the PNG.", + ) + + # Other non-supported raster formats (BMP, TIFF, ...): re-encode via Pillow. + try: + from PIL import Image as _PILImage + with _PILImage.open(image_path) as _img: + if _img.mode not in ("RGB", "RGBA", "L"): + _img = _img.convert("RGBA") + _img.save(out_path, format="PNG") + if out_path.exists() and out_path.stat().st_size > 0: + return out_path, "image/png", None + except Exception as _exc: + logger.warning("Failed to normalize %s image to PNG: %s", + detected_mime, _exc) + return ( + None, + None, + f"Image format {detected_mime!r} is not supported by the vision API " + f"and could not be converted to PNG (install Pillow for raster " + f"conversion). Convert it to PNG or JPEG and try again.", + ) + + def _is_retryable_download_error(error: Exception) -> bool: """Return True only for transient image-download failures worth retrying. @@ -817,13 +932,14 @@ async def _vision_concurrency_slot(): async def _vision_analyze_native( image_url: str, question: str, + task_id: Optional[str] = None, ) -> Any: """Fast path for vision-capable main models. - Loads the image (local file OR remote URL), base64-encodes it, and - returns a multimodal tool-result envelope. The agent loop unwraps it; - provider adapters serialize it into the right tool-result-with-image - shape for each backend. + Loads the image (data: / http(s) / file:// / local path / sandbox-container + path) via the unified resolver, base64-encodes it, and returns a multimodal + tool-result envelope. The agent loop unwraps it; provider adapters serialize + it into the right tool-result-with-image shape for each backend. Returns: A ``_multimodal`` envelope dict on success. @@ -840,38 +956,51 @@ async def _vision_analyze_native( if is_interrupted(): return tool_error("Interrupted", success=False) - # Resolve the image source (mirrors vision_analyze_tool's logic - # exactly so behaviour is consistent). - resolved_url = image_url - if resolved_url.startswith("file://"): - resolved_url = resolved_url[len("file://"):] - local_path = Path(os.path.expanduser(resolved_url)) - - if local_path.is_file(): - temp_image_path = local_path - should_cleanup = False - elif await _validate_image_url_async(image_url): - blocked = check_website_access(image_url) - if blocked: - return tool_error(blocked["message"], success=False) - temp_dir = get_hermes_dir("cache/vision", "temp_vision_images") - temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg" - await _download_image(image_url, temp_image_path) - should_cleanup = True - else: - return tool_error( - "Invalid image source. Provide an HTTP/HTTPS URL or a " - "valid local file path.", - success=False, - ) + # Resolve the source to raw bytes through the single resolver (unifies + # data:/http/file/local/container and enforces terminal-backend + # confinement). Materialize to a temp file so the existing path-based + # encode/resize/embed-cap pipeline below is reused verbatim. + from tools.image_source import ( + ImageResolutionError, + ResolveContext, + resolve_image_source, + ) - image_size_bytes = temp_image_path.stat().st_size - detected_mime_type = _detect_image_mime_type(temp_image_path) - if not detected_mime_type: + try: + resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id)) + except ImageResolutionError as exc: + return tool_error(str(exc), success=False) + + detected_mime_type = resolved.mime + image_size_bytes = len(resolved.data) + temp_dir = get_hermes_dir("cache/vision", "temp_vision_images") + temp_dir.mkdir(parents=True, exist_ok=True) + temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.img" + await asyncio.to_thread(temp_image_path.write_bytes, resolved.data) + should_cleanup = True + + # Normalize unsupported formats (SVG, BMP, ...) to PNG BEFORE embedding. + # Anthropic only accepts jpeg/png/gif/webp; an unsupported media_type + # baked into immutable history wedges the session with a 400 on every + # resume. Convert here so it can never enter history. Offloaded — the + # rasterizers/Pillow are blocking. + normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread( + _normalize_to_supported_image, temp_image_path, detected_mime_type, + ) + if _norm_err or normalized_path is None: return tool_error( - "Only real image files are supported for vision analysis.", - success=False, + _norm_err or "Image normalization failed.", success=False, ) + if normalized_path != temp_image_path: + # We created a temp PNG — swap to it and ensure it's cleaned up. + if should_cleanup and temp_image_path.exists(): + try: + temp_image_path.unlink() + except Exception: + pass + temp_image_path = normalized_path + should_cleanup = True + image_size_bytes = temp_image_path.stat().st_size image_data_url = await _run_encode_on_cpu_executor( _image_to_base64_data_url, @@ -935,6 +1064,7 @@ async def vision_analyze_tool( image_url: str, user_prompt: str, model: str = None, + task_id: Optional[str] = None, ) -> str: """ Analyze an image from a URL or local file path using vision AI. @@ -996,42 +1126,50 @@ async def vision_analyze_tool( logger.info("Analyzing image: %s", image_url[:60]) logger.info("User prompt: %s", user_prompt[:100]) - - # Determine if this is a local file path or a remote URL - # Strip file:// scheme so file URIs resolve as local paths. - resolved_url = image_url - if resolved_url.startswith("file://"): - resolved_url = resolved_url[len("file://"):] - local_path = Path(os.path.expanduser(resolved_url)) - if local_path.is_file(): - # Local file path (e.g. from platform image cache) -- skip download - logger.info("Using local image file: %s", image_url) - temp_image_path = local_path - should_cleanup = False # Don't delete cached/local files - elif await _validate_image_url_async(image_url): - # Remote URL -- download to a temporary location - blocked = check_website_access(image_url) - if blocked: - raise PermissionError(blocked["message"]) - logger.info("Downloading image from URL...") - temp_dir = get_hermes_dir("cache/vision", "temp_vision_images") - temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg" - await _download_image(image_url, temp_image_path) - should_cleanup = True - else: - raise ValueError( - "Invalid image source. Provide an HTTP/HTTPS URL or a valid local file path." - ) - + + # Resolve the source to raw bytes through the single resolver (unifies + # data:/http/file/local/container and enforces terminal-backend + # confinement). Materialize to a temp file so the existing path-based + # encode/resize pipeline below is reused verbatim. + from tools.image_source import ( + ImageResolutionError, + ResolveContext, + resolve_image_source, + ) + + try: + resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id)) + except ImageResolutionError as exc: + raise ValueError(str(exc)) + + detected_mime_type = resolved.mime + temp_dir = get_hermes_dir("cache/vision", "temp_vision_images") + temp_dir.mkdir(parents=True, exist_ok=True) + temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.img" + await asyncio.to_thread(temp_image_path.write_bytes, resolved.data) + should_cleanup = True + # Get image file size for logging - image_size_bytes = temp_image_path.stat().st_size + image_size_bytes = len(resolved.data) image_size_kb = image_size_bytes / 1024 logger.info("Image ready (%.1f KB)", image_size_kb) + # Normalize unsupported formats (SVG, BMP, ...) to PNG. Vision providers + # reject these media types; convert before encoding. Offloaded — the + # rasterizers/Pillow are blocking. + normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread( + _normalize_to_supported_image, temp_image_path, detected_mime_type, + ) + if _norm_err or normalized_path is None: + raise ValueError(_norm_err or "Image normalization failed.") + if normalized_path != temp_image_path: + if should_cleanup and temp_image_path.exists(): + try: + temp_image_path.unlink() + except Exception: + pass + temp_image_path = normalized_path + should_cleanup = True - detected_mime_type = _detect_image_mime_type(temp_image_path) - if not detected_mime_type: - raise ValueError("Only real image files are supported for vision analysis.") - # Convert image to base64 — send at full resolution first. # If the provider rejects it as too large, we auto-resize and retry. # Offloaded to the bounded vision CPU executor so a fan-out of encodes @@ -1335,6 +1473,7 @@ def check_vision_requirements() -> bool: async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str: image_url = args.get("image_url", "") question = args.get("question", "") + task_id = kw.get("task_id") # The fan-out cap lives inside the encode/resize step (offloaded to the # bounded _vision_cpu_executor), NOT around the whole analysis — so a @@ -1349,7 +1488,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str: # information loss, no extra latency. if _should_use_native_vision_fast_path(): logger.info("vision_analyze: native fast path") - return await _vision_analyze_native(image_url, question) + return await _vision_analyze_native(image_url, question, task_id=task_id) # Legacy path: aux LLM describes the image and we return its text. full_prompt = ( @@ -1368,7 +1507,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str: pass if not model: model = os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None - return await vision_analyze_tool(image_url, full_prompt, model) + return await vision_analyze_tool(image_url, full_prompt, model, task_id=task_id) registry.register(
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#35362 fix(vision): unify vision_analyze image-source resolution through one resolver · merged via another change →
#57890
Closed issues · 1
- Closed#32709Bug: `vision_analyze` tool fails to send images when using Docker terminal backend
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #57890
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 4 Jul 2026