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