fix(vision): unify vision_analyze image-source resolution through one resolver - #35362
fix(vision): unify vision_analyze image-source resolution through one resolver#35362banditburai wants to merge 17 commits into
Conversation
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary: PR #35362
Verdict: APPROVE ✅
Overall: Significant, well-architected refactor that unifies vision image-source resolution through a single resolve_image_source bytes-returning chokepoint in tools/image_source.py. Closes 6 issues, supersedes 2 previous PRs. 851 additions, 306 deletions across 10 files.
✅ Looks Good
- Architecture: Single
resolve_image_source(src, ctx)→ResolvedImage(data, mime, origin)— a single correctness chokepoint replacing divergent implementations in two vision call sites - Source coverage: Handles
data:(base64),http(s)(with existing SSRF guard reuse),file://, local path, and container paths — closes all 6 linked issues - Container delivery, dual mechanism:
- Host fast-path via
from_agent_visible_cache_pathreverse map - Universal exec-read fallback with
base64 --injection neutralization +shlex.quote
- Host fast-path via
- Fail-closed: No active sandbox env → resolution refuses rather than probing host
- _finalize chokepoint: Magic-byte sniff + 50 MB ingest cap (20-50 MB images resize through rather than hard-rejecting)
- SSRF retained: Reuses existing
is_safe_urland redirect re-validation - Honest scope documentation: Clearly marks #9077 as "Addresses" not "Closes", acknowledges CDP-level and routing-level gaps
- 236 tests pass + dedicated Docker integration test for exec-read round-trip
- Test coverage: New
test_image_source.py(every resolve branch, SSRF reject, oversize, injection neutralization),test_vision_bytes_helpers.py(magic-byte sniff, base64, resize) - SVG rejection: Rejected by magic-byte sniff — correct, no provider ingests SVG as vision
- Clean behavior changes table: Every change in behavior documented with before/after/why/risk
- Out-of-scope documentation: Lists readable-root allowlist,
docker_volumesrewrite, decompression-bomb guard as future work — prevents scope creep
💡 Suggestions
- The Docker integration test (
test_vision_docker_resolve.py) is markedintegrationand excluded from CI — consider adding a lightweight smoke test that can run without Docker (e.g., mockingenv.execute) to gate CI - Consider adding
_within_allowed_rootsas a no-op seam for now but documenting the expectedvision.allowed_image_rootsconfig key name for future use — makes the follow-up simpler
Testing Completeness
- 236 unit tests pass
- Integration test verified against real Docker 29.4.0
- Regression tests for: resize (incompressible PNG), injection neutralization, fail-closed, oversize, all resolve branches
Reviewed by Hermes Agent
Code Review Summary: PR #35362Verdict: APPROVE ✅ Overall: Significant, well-architected refactor that unifies vision image-source resolution through a single ✅ Looks Good
💡 Suggestions
Testing Completeness
Reviewed by Hermes Agent |
Task: fix+vision-analyze-image-sources-1f3.14
…rsions wrap them Task: fix+vision-analyze-image-sources-1f3.15
Task: fix+vision-analyze-image-sources-1f3.27
…d errors Task: fix+vision-analyze-image-sources-1f3.16
Task: fix+vision-analyze-image-sources-1f3.17
…PR1) Task: fix+vision-analyze-image-sources-1f3.18
…fail-closed Task: fix+vision-analyze-image-sources-1f3.19
…a leading-dash path Adds '--' to terminate option parsing so a path like './-i/etc/shadow' (which expanduser normalizes to '-i/etc/shadow') can't be parsed as a base64 flag. Found in adversarial review of the NousResearch#32709 exec-read fallback. Task: fix+vision-analyze-image-sources-1f3.31
Task: fix+vision-analyze-image-sources-1f3.20
Routes vision_analyze_tool through resolve_image_source (adds data:/Docker, unifies SSRF/policy with the native path). Removes the temp-file/cleanup bookkeeping the bytes contract makes unreachable; threads task_id. Updates the safety-guard tests to assert the same behavior at the resolver boundary and drops the obsolete temp-cleanup test. Task: fix+vision-analyze-image-sources-1f3.21
… source handling (review .33) Task: fix+vision-analyze-image-sources-1f3.33
… regression tests) Task: fix+vision-analyze-image-sources-1f3.22
…finalize split (Task 12 skip) Task: fix+vision-analyze-image-sources-1f3.23
…rm + tmpfs cases Task: fix+vision-analyze-image-sources-1f3.24
…(not reject) - _MAX_BYTES(20MB) -> _MAX_INGEST_BYTES(50MB); the 20MB provider payload cap stays a post-resize limit at the call sites, so a 20-50MB photo reaches the resizer instead of being hard-rejected by _finalize (the plan's byte-cap reconciliation correction). - preserve the specific website-policy block message via _http_block_reason instead of collapsing every block to a generic string. - run the blocking container exec-read off the event loop (asyncio.to_thread). - regression tests: oversize-resize end-to-end through _vision_analyze_native, within-ingest-budget resolves, over-ingest-budget rejected, policy message.
…sync + assertions - test_model_tools_async_bridge: the URL-safety gate moved into the resolver, so the NousResearch#2104 loop-safety tests now patch tools.image_source._http_block_reason and _download_to_bytes (was patching the moved-away tools.vision_tools seams, which failed on a live DNS lookup). - test_vision_native_fast_path: convert get_event_loop().run_until_complete to @pytest.mark.asyncio/await (matches suite style); strengthen the two fall- through gating tests to assert the aux path is actually taken (mock called + sentinel result), not merely 'not fast-path'; drop unused monkeypatch params and the now-dead asyncio import.
…espoke env var Align with the repo convention instead of the invented HERMES_DOCKER_TESTS gate: - pytest.mark.integration → excluded from the default suite by addopts (-m 'not integration'), as every other tests/integration/ file is. - skipif(not _docker_available()) → auto-skip without a daemon (mirrors tests/docker/conftest.py's shutil.which + 'docker info' probe). - pytest.mark.timeout(180) → container spin-up exceeds the 30s suite default; the bespoke gate would have let a real run be killed mid image-pull. - Per-test unique task_id (request.node.name): DockerEnvironment derives the container from task_id, so the shared id made one test's teardown remove the other's container — the second test failed with 'No such container' when both ran. Surfaced only once the test actually executed against real Docker. Both tests now pass against a real Docker daemon (verified locally); default runs deselect them cleanly.
c18c2d8 to
d61a577
Compare
|
any update on when this might be merged? |
…ment Salvage of #35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from #35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (#7571, #25118, #29643, #22328, #32709, #9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills #35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
…ment Salvage of #35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from #35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (#7571, #25118, #29643, #22328, #32709, #9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills #35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
|
Merged via PR #57890 — your unified image-source resolver design was the foundation of the final fix. The PR was 4140 commits stale and vision_tools.py had been rewritten on both sides, so the resolver was re-authored against current main rather than cherry-picked, but the architecture (single resolver for data:/http/file/local/container sources with one size/magic-byte chokepoint) is yours, and the deliberately-stubbed _within_allowed_roots seam you left became the terminal-backend confinement that closed GHSA-gpxw-6wxv-w3qq. Credited in the PR body. Thanks for the design work — it fixed six open bug reports and a security advisory in one shape. |
…ment Salvage of NousResearch#35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from NousResearch#35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills NousResearch#35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (NousResearch#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
…ment Salvage of NousResearch#35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from NousResearch#35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills NousResearch#35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (NousResearch#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
…ment Salvage of NousResearch#35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from NousResearch#35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills NousResearch#35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (NousResearch#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
…ment Salvage of NousResearch#35362, evolved to also close the vision sandbox-escape (GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image bytes host-side while every other tool reads through the terminal backend — so one resolver fixes both the delivery gaps and the escape. Delivery (from NousResearch#35362, re-authored against current main since the branch was 4140 commits stale and vision_tools.py had been rewritten on both sides): - tools/image_source.py: one resolver for data:/http(s)/file/local/container image sources, returning raw bytes through a single magic-byte-sniff + 50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source' for every source type (NousResearch#7571, NousResearch#25118, NousResearch#29643, NousResearch#22328, NousResearch#32709, NousResearch#9077). - tools/credential_files.py: from_agent_visible_cache_path, the container->host cache reverse-map (inverse of the existing forward twin). - tools/vision_tools.py: both vision sites route through the resolver with task_id threaded from the handler; resolved bytes are materialized to a temp file so main's evolved encode/resize/embed-cap pipeline is reused verbatim (kept over the PR's older bytes-core resize to avoid touching browser_tool / conversation_compression callers). Security (fills NousResearch#35362's deliberately-stubbed _within_allowed_roots seam): - Under a non-local terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2), but vision read host-side — a prompt-injected vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even redirects the model to vision_analyze for image paths. The resolver now enforces the same boundary: local backend reads any host path (chosen posture); non-local backend host-reads ONLY the media caches under HERMES_HOME (where the gateway/download media lives) and routes every other path to an in-sandbox base64 exec-read — which reads the CONTAINER's file, the same one 'cat' would, never the host's. Paths are resolve()-d so a symlink can't escape a cache; fail-closed when no sandbox env exists. This closes the escape AND delivers container-only images (NousResearch#32709) with the same mechanism. Tests: unified resolver + confinement model (tests/tools/test_image_source.py, incl. proof a non-cache host path under Docker yields container bytes not the host secret); existing vision tests updated to the resolver boundary; Docker integration test verified green against a real daemon (exec-read of a tmpfs /workspace file, a root-owned mode-600 file, and the host-secret invariant). Fixes GHSA-gpxw-6wxv-w3qq. Co-authored-by: banditburai <promptsiren@gmail.com>
fix(vision): unify vision_analyze image-source resolution through one bytes-returning resolver
Closes #7571
Closes #25118
Closes #29643
Closes #22328
Closes #32709
Addresses #9077 (delivery path only — see Caveats)
Supersedes #30197
Supersedes #14990
TL;DR
vision_analyze/mcp_vision_analyzenow load images reliably from every source —data:URLs,http(s),file://, local paths, and Docker-container-only paths — instead of returning "no image attached" / "Invalid image source". This is a byte-delivery fix, not a routing change: both call sites now funnel through one resolvertools/image_source.pythat returns raw bytes through a single correctness chokepoint. Container files are reached two ways — a cache-dir host fast-path and a universaldocker exec base64exec-read fallback. Puretools/layer;agent/image_routing.pyis deliberately untouched. Security hardening (a readable-root allowlist) is intentionally out of scope here and left as a no-op seam for a possible follow-up.Problem
The six clustered issues looked like four unrelated bugs (
data:,file://, local paths, sandbox paths), but they share one root cause.vision_analyzeread image bytes in-process on the host (Path.read_bytes(), no subprocess), whileread_file/terminal/execute_coderead through the sandbox viaenv.execute→docker exec. So when a file lives in the Docker terminal backend, every other tool can see it — and vision is the only tool reading the wrong filesystem. The reporter of #22328 attributed this to "vision runs in an isolated mount/process namespace"; that is incorrect (vision is in-process on the host). The real boundary is host-vs-sandbox, and it is the same mechanism behind #32709.A secondary problem: each source branch re-implemented its own ingestion (
tools/vision_tools.py:551_vision_analyze_nativeand:605vision_analyze_tooldiverged), so size caps, MIME sniffing, and error handling drifted between them. Routing was a red herring — the routing flip was investigated and dropped as non-load-bearing; all six issues fail at byte-delivery.Solution / Architecture
One resolver, both sites delegate to it:
resolve_image_source(src, ctx: ResolveContext) -> ResolvedImage(tools/image_source.py:66).ResolvedImage(data: bytes, mime, origin)withorigin ∈ {data, http, file, local, container}(:60). Both vision sites call it (tools/vision_tools.py:575,:669); the resolver always returns raw bytes regardless of source.data:(base64) ·http(s)(reusestools/url_safety.is_safe_urlSSRF guard + the existing 50 MB download cap) ·file://· local path · container._finalizechokepoint (:204): the single place enforcing intrinsic correctness — a generous_MAX_INGEST_BYTES = 50 MBingest cap (:23) plus a magic-byte sniff. The cap is deliberately the 50 MB ingest budget, not the 20 MB provider payload cap, so a 20–50 MB image survives to be resized rather than hard-rejected.tools/credential_files.py:405from_agent_visible_cache_path(container→host, beside its existing forward twin), called atimage_source.py:160._resolve_container_fallback(:174) runsbase64 -- <shlex.quoted path> | tr -d '\n'viaenv.execute, wrapped inasyncio.to_thread, fail-closed when no active env.Path-signature wrappers (_resize_image_for_vision,_image_to_base64_data_url) are preserved so external callers (tools/browser_tool.py,agent/conversation_compression.py) are untouched.tools/layer:agent/image_routing.pyis intentionally unchanged.Scope & Stats
tools/image_source.py(new),tools/vision_tools.py(mod, +265/−243),tools/credential_files.py(mod).test_image_source.py(new),test_vision_bytes_helpers.py(new),tests/integration/test_vision_docker_resolve.py(new),test_credential_files.py(mod),test_vision_native_fast_path.py(mod),test_vision_tools.py(mod),test_model_tools_async_bridge.py(mod).bcc830100..HEAD: 10 files, +829 / −306. 236 tests pass.main); gateway path-exposure fix: preserve local image attachment metadata across vision fallback paths #34315 / fix(telegram): expose inbound media as tool-accessible attachments (#20899) #20906 / fix(gateway): expose native image cache paths to tools #20971; data:-blob session-DB bloat fix: strip base64 image blobs from session DB to prevent context overflow #31705.Issue → fix mapping
file:/// local path supportfile://+_looks_like_path→ file/local branch reads bytes (image_source.py:79)cache/images/), direct read; reverse-map not load-bearing on local backend_finalize(:204):160) + exec-read (:174)_resolve_container_fallback(:174) + cache reverse-map;task_idthreaded dispatch→handler→resolver, seam locked by regression testdata:_finalize#22328 reframe: the reporter's stated cause (vision in an isolated mount/process namespace) is wrong —
vision_analyzeruns in-process on the host. The actual cause is the host-vs-sandbox filesystem boundary, identical to #32709, and the same exec-read mechanism resolves both.Supersedes
validate=False, no magic-byte check) → ours:_resolve_data_url(:98) decodes straight to bytes (no temp file),validate=True, with an authoritative magic-byte sniff in_finalize. Strict superset — nothing lost, validation added.TERMINAL_CWD/docker_volumeshost rewrites — only worked when a host mount backed the path) → ours: cache reverse-map + universal exec-read reaches container-only files (tmpfs, root-owned mode-600) that Fix/vision sandbox path resolution #14990 could never read. The explicitdocker_volumes/TERMINAL_CWDhost rewrite is absorbed by exec-read (same bytes); the allowlist portion is out of scope here (see below).(No other source-resolver PRs exist — searched
vision_analyze in:title, all states.)Caveats / honest scope
Addresses, notCloses. Two of its sub-symptoms are out of delivery scope: (a) thebrowser_vision>90 s timeout is latency, not delivery; (b) "model can't see image" (success: true+ "I don't see an image" on a non-vision provider, e.g. MiniMax) is a routing/capability matter. The delivery path is fixed.vision_analyzetool fails to send images when using Docker terminal backend #32709 / [Bug]: vision_analyze 工具无法读取任何本地文件(browser 截图也无法分析) #22328 exec-read round-trip is covered by anintegration-marked Docker test that is excluded from the default suite (and from CI, which runs-m 'not integration'). It has been run green locally against a real Docker daemon, but it does not gate CI — re-runpytest -m integration tests/integration/test_vision_docker_resolve.pyon a Docker host to re-verify. Thetask_idseam it depends on is in the default unit suite.vision_analyzetool fails to send images when using Docker terminal backend #32709 preconditions: the container path requires a Docker terminal backend + a livetask_idthreaded dispatch→handler→resolver. Outside those, the local host read applies.Behavior changes
_MAX_INGEST_BYTES=50 MB (:23); 20 MB provider cap enforced post-resize at call sites_http_block_reason(:113) keeps the reasontest_image_source.pycat.png, no/,./,~)UnsupportedScheme(:91)./cat.pngor an absolute pathNone)should_cleanupat both sites → lifecycle in_download_to_bytesfinally+unlink(missing_ok=True)(:141)Security
base64 -- {shlex.quote(path)}(:194) —--stops a leading-dash path being parsed as abase64option;tr -d '\n'handles BusyBox (no GNU-w0)._download_image(called from_download_to_bytes,:138).Testing
236 tests pass.
test_image_source.py(new) — every resolve branch:data:/http/file/ local / container, SSRF reject, oversize, leading-dash injection neutralization, fail-closed (no env); asserts the policy-block message is preserved.test_vision_bytes_helpers.py(new) — magic-byte sniff (incl. SVG →None), base64 encode, resize-noop under cap.test_vision_native_fast_path.py(mod) — fast-path gating matrix, 20–50 MB oversize-resize regression (incompressible PNG), and thetask_idseam.test_credential_files.py(mod) —from_agent_visible_cache_pathreverse-map: docker / non-docker / unmapped.test_model_tools_async_bridge.py(mod) — [Bug]: 'Exception Event loop is closed' aftervision_analyzeused as first call to hermes in chat session/some chained tool calls #2104 loop-safety, repatched to resolver seams_http_block_reason/_download_to_bytes.test_vision_tools.py(mod) — call-site integration.Docker integration (
integration-marked, excluded from the default suite):tests/integration/test_vision_docker_resolve.py(new) carriespytest.mark.integration, soaddopts = -m 'not integration'deselects it from normal runs; it auto-skips when no Docker daemon is present and gets a 180s timeout (repo convention, mirroringtests/docker/conftest.py). It exercises the real-Docker exec-read round-trip for #32709 — a tmpfs/workspacefile (no host path) and a root-owned mode-600 file — and is not part of the 236. Verified green against a real Docker daemon (29.4.0), so #32709 / #22328 are fixed-by-test, not just by construction. Run:Out of scope
This PR is scoped to byte delivery. The items below are intentionally not included — the resolver leaves a clean seam (
_within_allowed_roots) so the security piece can be added later without reworking the delivery path._within_allowed_rootsseam (:83/:148) with real root enforcement, avision.allowed_image_rootsconfig, and a threat model. The fail-open-vs-fail-closed choice is an open design decision, and any such work must not regress the user-typed-path workflows that mcp_vision_analyze does not support local file paths (file://) #7571 / [Bug]: vision_analyze 工具无法读取任何本地文件(browser 截图也无法分析) #22328 / Vision tool cannot analyze images sent via Telegram gateway #25118 depend on, nor reject an untranslated/workspacepath before the container → exec-read route runs.docker_volumes//workspace→TERMINAL_CWDhost rewrite — not added; no issue exercises it and the exec-read fallback already reaches those paths (same bytes)._resize_image_bytes_for_vision), and the 50 MB ingest cap already bounds file size; if ever wanted, it belongs as a conditional check there, not a blanket requirement.