# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
598 lines
27 KiB
Python
598 lines
27 KiB
Python
"""Tests for tools/image_source.py — the unified vision image-source resolver.
|
|
|
|
Covers the delivery contract (data:/http/file/local/container source handling,
|
|
size cap, magic-byte sniff) AND the terminal-backend confinement security model
|
|
(GHSA-gpxw-6wxv-w3qq): under a non-local backend, host reads are confined to the
|
|
media caches and every other path is read inside the sandbox via exec-read.
|
|
"""
|
|
|
|
import base64
|
|
import importlib
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
# Minimal valid 1x1 PNG bytes. Resolver validation requires a decodable fixture.
|
|
PNG = base64.b64decode(
|
|
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
)
|
|
CORRUPT_PNG = base64.b64decode(
|
|
b"iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAFElEQVR4nGP8z8Dwn4EIwESJ5gAAVQ4CH1evYJQAAAAASUVORK5CYII="
|
|
)
|
|
|
|
|
|
def _reload(monkeypatch, hermes_home: Path):
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
import hermes_constants
|
|
importlib.reload(hermes_constants)
|
|
import tools.image_source as isrc
|
|
importlib.reload(isrc)
|
|
return isrc
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_real_sandbox_bringup(monkeypatch):
|
|
"""Neutralize the resolver's lazy sandbox bring-up (issue #62825) so unit
|
|
tests never spawn a real ssh/docker env. Patched on terminal_tool (which
|
|
_reload does not touch) and resolved at call time, so it survives the
|
|
per-test image_source reload. The bring-up tests override it."""
|
|
import tools.terminal_tool as tt
|
|
monkeypatch.setattr(tt, "ensure_task_env", lambda *a, **k: None)
|
|
|
|
|
|
class TestDataUrl:
|
|
@pytest.mark.asyncio
|
|
async def test_valid_data_url_resolves_to_bytes(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
b64 = base64.b64encode(PNG).decode()
|
|
res = await isrc.resolve_image_source(
|
|
f"data:image/png;base64,{b64}", isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.mime == "image/png"
|
|
assert res.origin == "data"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_image_data_url_rejected(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
b64 = base64.b64encode(b"not an image").decode()
|
|
with pytest.raises(isrc.NotAnImage):
|
|
await isrc.resolve_image_source(
|
|
f"data:text/plain;base64,{b64}", isrc.ResolveContext())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_corrupt_png_rejected_at_resolver_boundary(self, tmp_path, monkeypatch):
|
|
"""A PNG-shaped but undecodable payload never becomes a resolved image."""
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
img = tmp_path / "corrupt.png"
|
|
img.write_bytes(CORRUPT_PNG)
|
|
with pytest.raises(isrc.NotAnImage):
|
|
await isrc.resolve_image_source(str(img), isrc.ResolveContext())
|
|
|
|
|
|
class TestLocalBackend:
|
|
@pytest.mark.asyncio
|
|
async def test_local_backend_reads_any_host_path(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
img = tmp_path / "outside" / "pic.png"
|
|
img.parent.mkdir(parents=True)
|
|
img.write_bytes(PNG)
|
|
res = await isrc.resolve_image_source(str(img), isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bare_relative_path_resolves(self, tmp_path, monkeypatch):
|
|
"""A cwd-relative bare filename ('pic.png') is a valid local source —
|
|
main accepted it; the resolver must not regress it (PR review)."""
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
img = tmp_path / "pic.png"
|
|
img.write_bytes(PNG)
|
|
monkeypatch.chdir(tmp_path)
|
|
res = await isrc.resolve_image_source("pic.png", isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
|
|
class TestNonLocalBackendConfinement:
|
|
"""The security model: under a sandbox backend, host reads are confined to
|
|
the media caches; every other path is read inside the sandbox."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_cache_path_host_read(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
cached = home / "cache" / "images" / "inbound.png"
|
|
cached.parent.mkdir(parents=True)
|
|
cached.write_bytes(PNG)
|
|
# No sandbox env needed — a cache path is host-read directly.
|
|
res = await isrc.resolve_image_source(str(cached), isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_desktop_upload_images_dir_host_read(self, tmp_path, monkeypatch):
|
|
"""Desktop/clipboard uploads under ``HERMES_HOME/images`` are host-read.
|
|
|
|
Regression for #69575: uploads land in the flat top-level ``images/``
|
|
dir (not ``cache/images``). Under a sandbox backend the vision resolver
|
|
must permit reading them host-side — otherwise it falls through to the
|
|
task-id-less sandbox reader and fails with "not reachable inside the
|
|
sandbox".
|
|
"""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
upload = home / "images" / "upload_20260722_181019_1.png"
|
|
upload.parent.mkdir(parents=True)
|
|
upload.write_bytes(PNG)
|
|
# No sandbox env: an uploads path must be host-read directly, not routed
|
|
# to the in-sandbox exec-read.
|
|
res = await isrc.resolve_image_source(str(upload), isrc.ResolveContext())
|
|
assert res.data == PNG
|
|
assert res.origin == "file"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_host_secret_outside_cache_routes_to_sandbox_not_host(self, tmp_path, monkeypatch):
|
|
"""A non-cache host path (e.g. /etc/passwd) must NOT be host-read — it
|
|
routes to the in-sandbox exec-read, which reads the CONTAINER's file."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
|
|
# A real host file outside the caches, holding a "secret".
|
|
secret = tmp_path / "id_rsa"
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK")
|
|
|
|
# Fake sandbox env: its exec-read returns a *different* (container) image,
|
|
# proving we read the container filesystem, not the host secret.
|
|
container_png_b64 = base64.b64encode(PNG).decode()
|
|
calls = {}
|
|
|
|
def fake_execute(cmd, **kw):
|
|
calls["cmd"] = cmd
|
|
return {"returncode": 0, "output": container_png_b64}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
res = await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
|
|
|
|
# Read came from the sandbox exec-read, returning the container image —
|
|
# the host secret bytes never appear.
|
|
assert res.origin == "container"
|
|
assert res.data == PNG
|
|
assert b"HOST-PRIVATE-KEY" not in res.data
|
|
assert "head -c" in calls["cmd"] and "< " in calls["cmd"] # bounded, redirect-safe form
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_cache_path_fails_closed_without_sandbox(self, tmp_path, monkeypatch):
|
|
"""No active sandbox env -> refuse rather than fall back to a host read."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
secret = tmp_path / "id_rsa"
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY")
|
|
|
|
with patch("tools.image_source._get_active_env", return_value=None):
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_symlink_in_cache_pointing_outside_is_not_host_read(self, tmp_path, monkeypatch):
|
|
"""A symlink planted inside a cache dir that points at a host secret must
|
|
not be host-read (resolve() escapes the cache) — it routes to sandbox."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
secret = tmp_path / "outside" / "id_rsa"
|
|
secret.parent.mkdir(parents=True)
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY")
|
|
cache_dir = home / "cache" / "images"
|
|
cache_dir.mkdir(parents=True)
|
|
link = cache_dir / "sneaky.png"
|
|
try:
|
|
link.symlink_to(secret)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks unsupported")
|
|
|
|
# Fails closed (no sandbox) rather than host-reading the symlink target.
|
|
with patch("tools.image_source._get_active_env", return_value=None):
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(str(link), isrc.ResolveContext(task_id="t1"))
|
|
|
|
|
|
class TestExecReadSafety:
|
|
@pytest.mark.asyncio
|
|
async def test_exec_read_is_bounded_and_redirect_safe(self, tmp_path, monkeypatch):
|
|
"""Leading-dash paths go through an input redirect (no argv exposure)
|
|
and the read is size-bounded via head -c."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
captured = {}
|
|
|
|
def fake_execute(cmd, **kw):
|
|
captured["cmd"] = cmd
|
|
return {"returncode": 0, "output": base64.b64encode(PNG).decode()}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
await isrc.resolve_image_source(
|
|
"/workspace/-i-etc-shadow.png", isrc.ResolveContext(task_id="t1"))
|
|
assert f"head -c {isrc._MAX_INGEST_BYTES + 1} < " in captured["cmd"]
|
|
assert "'-i-etc-shadow.png'" in captured["cmd"] or "-i-etc-shadow.png" in captured["cmd"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_read_retries_cold_start_then_succeeds(self, tmp_path, monkeypatch):
|
|
"""#76566: under Docker, vision's first exec-read can fail (cold
|
|
container / pipe setup) and an identical retry succeeds. The
|
|
resolver must transparently retry before raising, so users don't
|
|
see 'could not read inside the sandbox' on a file that is fully
|
|
readable on the second attempt."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
|
|
calls = {"n": 0}
|
|
b64 = base64.b64encode(PNG).decode()
|
|
|
|
def fake_execute(cmd, **kw):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
# First call: cold start — empty pipe, exit non-zero.
|
|
return {"returncode": 1, "output": ""}
|
|
return {"returncode": 0, "output": b64}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
res = await isrc.resolve_image_source(
|
|
"/workspace/cold.png", isrc.ResolveContext(task_id="t1"))
|
|
assert res.origin == "container"
|
|
assert res.data == PNG
|
|
assert calls["n"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_read_retries_exhausted_includes_diagnostic(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""#76566: when every retry still fails, the error must carry the
|
|
container's stderr/stdout so the user can tell 'no such file'
|
|
from 'permission denied' from 'cold start never came up'."""
|
|
home = tmp_path / "hermes"
|
|
isrc = _reload(monkeypatch, home)
|
|
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
|
|
|
def fake_execute(cmd, **kw):
|
|
return {"returncode": 1, "output": "head: can't open '/x': No such file or directory"}
|
|
|
|
with patch("tools.image_source._get_active_env",
|
|
return_value=SimpleNamespace(execute=fake_execute)):
|
|
with pytest.raises(isrc.SourceNotFound) as excinfo:
|
|
await isrc.resolve_image_source(
|
|
"/workspace/missing.png", isrc.ResolveContext(task_id="t1"))
|
|
# Diagnostic surfaced — the user can act on it.
|
|
assert "No such file or directory" in str(excinfo.value)
|
|
|
|
|
|
class TestSvgNormalization:
|
|
"""SVG resolves end-to-end: the resolver passes it through as
|
|
image/svg+xml and the vision call sites rasterize it to PNG via
|
|
_normalize_to_supported_image (PR #52688, folded in)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_svg_rasterized_when_converter_available(self, tmp_path, monkeypatch):
|
|
from tools import vision_tools_image_prep as vt
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
svg = tmp_path / "art.svg"
|
|
svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>')
|
|
|
|
def fake_rasterize(svg_path, out_path):
|
|
out_path.write_bytes(PNG)
|
|
return True
|
|
|
|
with patch.object(vt, "_rasterize_svg_to_png", side_effect=fake_rasterize):
|
|
res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext())
|
|
assert res.mime == "image/svg+xml"
|
|
path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml")
|
|
assert err is None
|
|
assert mime == "image/png"
|
|
assert path.read_bytes() == PNG
|
|
path.unlink()
|
|
|
|
def test_svg_actionable_error_when_no_converter(self, tmp_path, monkeypatch):
|
|
from tools import vision_tools_image_prep as vt
|
|
_reload(monkeypatch, tmp_path / "hermes")
|
|
svg = tmp_path / "art.svg"
|
|
svg.write_bytes(b'<svg xmlns="http://www.w3.org/2000/svg"/>')
|
|
with patch.object(vt, "_rasterize_svg_to_png", return_value=False):
|
|
path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml")
|
|
assert path is None
|
|
assert "rasterizer" in err
|
|
|
|
|
|
class TestLazySandboxBringUp:
|
|
"""Issue #62825: under a non-local backend, the FIRST vision_analyze of a
|
|
session (before any terminal command) must bring the sandbox up itself
|
|
instead of failing with 'no active sandbox session'."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_read_brings_up_sandbox_then_reads(self, tmp_path, monkeypatch):
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "ssh")
|
|
|
|
brought_up = []
|
|
fake_env = SimpleNamespace(
|
|
execute=lambda cmd, **kw: {"returncode": 0, "output": base64.b64encode(PNG).decode()}
|
|
)
|
|
|
|
def fake_ensure(task_id):
|
|
brought_up.append(task_id)
|
|
|
|
# Env is absent until the lazy bring-up runs, then available — exactly
|
|
# the SSH-handshake ordering the bug was about.
|
|
def fake_get_active(task_id):
|
|
return fake_env if brought_up else None
|
|
|
|
import tools.terminal_tool as tt
|
|
monkeypatch.setattr(tt, "ensure_task_env", fake_ensure)
|
|
monkeypatch.setattr(isrc, "_get_active_env", fake_get_active)
|
|
|
|
res = await isrc.resolve_image_source("/tmp/test.png", isrc.ResolveContext(task_id="t1"))
|
|
|
|
assert brought_up == ["t1"] # bring-up was triggered before the read
|
|
assert res.origin == "container"
|
|
assert res.data == PNG
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bringup_that_yields_no_env_still_fails_closed(self, tmp_path, monkeypatch):
|
|
"""If the bring-up can't produce an env, the resolver still refuses
|
|
rather than falling back to a host read."""
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "ssh")
|
|
secret = tmp_path / "id_rsa"
|
|
secret.write_bytes(b"HOST-PRIVATE-KEY")
|
|
|
|
import tools.terminal_tool as tt
|
|
monkeypatch.setattr(tt, "ensure_task_env", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(isrc, "_get_active_env", lambda *_a, **_k: None)
|
|
|
|
with pytest.raises(isrc.SourceNotFound):
|
|
await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
|
|
|
|
|
|
# HEIF/HEIC/AVIF ISO-BMFF headers: 'ftyp' box at bytes 4-8, major brand at 8-12,
|
|
# minor version at 12-16, then a list of 4-byte compatible brands.
|
|
# 0x18-byte box length prefix mirrors a real iPhone HEIC (\x00\x00\x00\x18 ftyp...).
|
|
HEIC_HEADER = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic" + b"\x00" * 32
|
|
AVIF_HEADER = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1" + b"\x00" * 32
|
|
MIF1_HEADER = b"\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00mif1heic" + b"\x00" * 32
|
|
# AVIF encoders routinely stamp the generic still-image brand 'mif1' as the
|
|
# MAJOR brand and declare the AV1 codec only in the compatible-brand list, so a
|
|
# major-brand-only sniff mislabels these as HEVC-coded HEIC.
|
|
MIF1_MAJOR_AVIF_COMPATIBLE = (
|
|
b"\x00\x00\x00\x1cftypmif1\x00\x00\x00\x00mif1avif" + b"\x00" * 32
|
|
)
|
|
MIF1_MAJOR_AV01_COMPATIBLE = (
|
|
b"\x00\x00\x00 ftypmif1\x00\x00\x00\x00mif1av01avif" + b"\x00" * 32
|
|
)
|
|
|
|
|
|
class TestHeicDetection:
|
|
"""iPhone HEIC/HEIF (and AVIF) are sniffed from the ISO-BMFF ftyp brand so
|
|
they reach _normalize_to_supported_image instead of being rejected as
|
|
'not a recognized image' (the pre-fix behavior for iPhone photos)."""
|
|
|
|
def test_heic_brand_detected(self):
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(HEIC_HEADER) == "image/heic"
|
|
|
|
def test_generic_mif1_brand_detected_as_heic(self):
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(MIF1_HEADER) == "image/heic"
|
|
|
|
def test_avif_brand_detected(self):
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(AVIF_HEADER) == "image/avif"
|
|
|
|
def test_mif1_major_with_avif_compatible_brand_is_avif(self):
|
|
"""Regression: a 'mif1'-major file that lists 'avif' among its
|
|
compatible brands is AV1-coded and must be reported as AVIF. Sniffing
|
|
only the major brand labeled every mif1 file HEIC, which routes an AVIF
|
|
to the HEVC error text and mis-reports the format to the caller."""
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(
|
|
MIF1_MAJOR_AVIF_COMPATIBLE) == "image/avif"
|
|
|
|
def test_mif1_major_with_av01_compatible_brand_is_avif(self):
|
|
"""Same as above for the 'av01' codec brand, deeper in the list."""
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(
|
|
MIF1_MAJOR_AV01_COMPATIBLE) == "image/avif"
|
|
|
|
|
|
def test_brand_scan_does_not_read_past_the_ftyp_box(self):
|
|
"""The scan is bounded by the declared ftyp box size, so an 'avif' token
|
|
sitting in a FOLLOWING box must not upgrade a HEIC to AVIF."""
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
# ftyp box is exactly 0x18 bytes (major mif1 + one compatible 'heic');
|
|
# the next box then contains the literal bytes 'avif'.
|
|
hdr = (
|
|
b"\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00heic"
|
|
b"\x00\x00\x00\x10metaavif" + b"\x00" * 32
|
|
)
|
|
assert _detect_image_mime_type_from_bytes(hdr) == "image/heic"
|
|
|
|
@pytest.mark.parametrize("declared_size", [0, 1, 4, 8, 12, 15])
|
|
def test_malformed_box_size_fails_closed(self, declared_size):
|
|
"""A malformed declared size must NOT widen the brand scan.
|
|
|
|
Regression: the bound previously fell back to the whole 64-byte sniff
|
|
window whenever the declared size was outside ``16 <= size <= len``. That
|
|
failed OPEN in precisely the attacker-controlled case — a declared size
|
|
of 0/4/8/12 on a genuine HEIC let the literal bytes ``avif`` in a LATER
|
|
box upgrade it to AVIF, defeating the box bound this code exists to
|
|
enforce. Every malformed size must still report HEIC.
|
|
"""
|
|
import struct
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
hdr = (
|
|
struct.pack(">I", declared_size)
|
|
+ b"ftyp" + b"mif1" + b"\x00" * 4 + b"heic"
|
|
+ b"\x00\x00\x00\x10" + b"meta" + b"avif" + b"\x00" * 24
|
|
)
|
|
assert _detect_image_mime_type_from_bytes(hdr) == "image/heic", (
|
|
f"declared size {declared_size} widened the scan and leaked an "
|
|
f"'avif' token from a following box"
|
|
)
|
|
|
|
def test_honest_oversized_box_still_scans_what_arrived(self):
|
|
"""A size >= 16 that overruns the sniff window is a truncated read, not
|
|
an attack: clamp to the available bytes rather than failing closed, so a
|
|
genuine AVIF whose ftyp box is larger than 64 bytes is still detected."""
|
|
import struct
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
hdr = (
|
|
struct.pack(">I", 9999)
|
|
+ b"ftyp" + b"mif1" + b"\x00" * 4 + b"mif1avif"
|
|
)
|
|
assert _detect_image_mime_type_from_bytes(hdr) == "image/avif"
|
|
|
|
def test_misaligned_box_size_does_not_crash_or_misdetect(self):
|
|
"""A size that is not a multiple of 4 must not misalign the brand loop
|
|
into reading a partial brand."""
|
|
import struct
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
for size in (17, 18, 19, 21, 22, 23):
|
|
hdr = (
|
|
struct.pack(">I", size)
|
|
+ b"ftyp" + b"mif1" + b"\x00" * 4 + b"heic"
|
|
+ b"\x00\x00\x00\x10" + b"meta" + b"avif" + b"\x00" * 24
|
|
)
|
|
got = _detect_image_mime_type_from_bytes(hdr)
|
|
assert got == "image/heic", f"size {size} -> {got}"
|
|
|
|
|
|
def test_truncated_ftyp_header_does_not_crash(self):
|
|
"""A short/garbage read must return a value, not raise."""
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
assert _detect_image_mime_type_from_bytes(b"\x00\x00\x00\x18ftyp") is None
|
|
# Bogus (huge) declared box size must fall back to the sniffed window.
|
|
assert _detect_image_mime_type_from_bytes(
|
|
b"\xff\xff\xff\xffftypmif1\x00\x00\x00\x00mif1avif") == "image/avif"
|
|
|
|
def test_ftyp_with_unknown_brand_not_misdetected(self):
|
|
"""An ISO-BMFF ftyp box that isn't an image brand (e.g. mp4) stays
|
|
unrecognized — we must not claim every ftyp container is an image."""
|
|
from tools.vision_tools_image_prep import _detect_image_mime_type_from_bytes
|
|
mp4 = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" + b"\x00" * 32
|
|
assert _detect_image_mime_type_from_bytes(mp4) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heic_resolves_and_normalizes_to_png(self, tmp_path, monkeypatch):
|
|
"""End-to-end: a real HEIC file resolves (mime image/heic) and
|
|
_normalize_to_supported_image re-encodes it to PNG via pillow-heif."""
|
|
pillow_heif = pytest.importorskip("pillow_heif")
|
|
pillow_heif.register_heif_opener()
|
|
from PIL import Image
|
|
from tools import vision_tools_image_prep as vt
|
|
isrc = _reload(monkeypatch, tmp_path / "hermes")
|
|
monkeypatch.setenv("TERMINAL_ENV", "local")
|
|
|
|
heic = tmp_path / "photo.heic"
|
|
# x265's default CPU-sized pool can exhaust CI threads and hang the encoder.
|
|
# This fixture needs one worker, not a pool per parallel test process.
|
|
Image.new("RGB", (8, 8), (120, 60, 200)).save(
|
|
str(heic), format="HEIF",
|
|
enc_params={"x265:pools": "none", "x265:frame-threads": "1"},
|
|
)
|
|
|
|
res = await isrc.resolve_image_source(str(heic), isrc.ResolveContext())
|
|
assert res.mime == "image/heic"
|
|
|
|
path, mime, err = vt._normalize_to_supported_image(heic, "image/heic")
|
|
assert err is None
|
|
assert mime == "image/png"
|
|
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
|
path.unlink()
|
|
|
|
def test_heic_actionable_error_when_decoder_missing(self, tmp_path, monkeypatch):
|
|
"""With pillow-heif unavailable, a HEIC image gets an actionable error
|
|
(install pillow-heif) rather than a generic conversion failure — the
|
|
same soft-dependency posture as SVG-without-rasterizer."""
|
|
from tools import vision_tools_image_prep as vt
|
|
_reload(monkeypatch, tmp_path / "hermes")
|
|
heic = tmp_path / "photo.heic"
|
|
heic.write_bytes(HEIC_HEADER)
|
|
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _no_heif(name, *args, **kwargs):
|
|
if name == "pillow_heif":
|
|
raise ImportError("pillow_heif not installed")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with patch.object(builtins, "__import__", side_effect=_no_heif):
|
|
path, mime, err = vt._normalize_to_supported_image(heic, "image/heic")
|
|
assert path is None
|
|
assert "pillow-heif" in err
|
|
|
|
def test_avif_converts_without_pillow_heif(self, tmp_path, monkeypatch):
|
|
"""Regression: AVIF must NOT be gated on pillow-heif.
|
|
|
|
Pillow >= 11.3 bundles a native AvifImagePlugin, while pillow-heif
|
|
wheels are commonly built with no AV1 codec at all (libheif_info()
|
|
reports ``AVIF: ''``). Gating AVIF on a pillow-heif import therefore
|
|
rejected files Pillow could already decode and told the user to install
|
|
a library that cannot decode them. With pillow-heif absent, a real AVIF
|
|
must still normalize to PNG.
|
|
"""
|
|
pytest.importorskip("PIL")
|
|
from PIL import Image, features
|
|
if not features.check("avif"):
|
|
pytest.skip("this Pillow build has no native AVIF codec")
|
|
from tools import vision_tools_image_prep as vt
|
|
_reload(monkeypatch, tmp_path / "hermes")
|
|
|
|
avif = tmp_path / "photo.avif"
|
|
Image.new("RGB", (8, 8), (10, 180, 90)).save(str(avif), format="AVIF")
|
|
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _no_heif(name, *args, **kwargs):
|
|
if name == "pillow_heif":
|
|
raise ImportError("pillow_heif not installed")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with patch.object(builtins, "__import__", side_effect=_no_heif):
|
|
path, mime, err = vt._normalize_to_supported_image(avif, "image/avif")
|
|
|
|
assert err is None, f"AVIF was rejected without pillow-heif: {err}"
|
|
assert mime == "image/png"
|
|
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
|
path.unlink()
|
|
|
|
def test_avif_error_names_av1_not_just_pillow_heif(self, tmp_path, monkeypatch):
|
|
"""When an AVIF genuinely cannot be decoded, the guidance must mention
|
|
the AV1/Pillow path — not blame pillow-heif alone, which frequently
|
|
ships without any AV1 codec."""
|
|
from tools import vision_tools_image_prep as vt
|
|
_reload(monkeypatch, tmp_path / "hermes")
|
|
# Valid AVIF brand, but the payload is not decodable by anything.
|
|
broken = tmp_path / "broken.avif"
|
|
broken.write_bytes(AVIF_HEADER)
|
|
|
|
path, mime, err = vt._normalize_to_supported_image(broken, "image/avif")
|
|
assert path is None
|
|
assert "AV1" in err or "Pillow" in err
|