Files
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

158 lines
5.8 KiB
Python

"""Docker integration tests for the vision image-source resolver.
Exercises the in-sandbox exec-read path that unit tests can only mock. Two
scenarios that ONLY the container filesystem can serve:
* a tmpfs ``/workspace`` file with no host path at all, and
* a root-owned mode-600 file the host user cannot read,
both delivered by the resolver's ``base64`` exec-read fallback
(``tools/image_source._resolve_container_fallback``). This is the same path
that provides terminal-backend confinement for GHSA-gpxw-6wxv-w3qq: under a
non-local backend a non-cache path is read INSIDE the container, never on the
host.
Gating follows the repo convention: the ``integration`` marker excludes these
from the default suite (``addopts = -m 'not integration'`` in pyproject.toml);
they run under ``pytest -m integration`` when a Docker daemon is available and
skip cleanly when it is not. Container spin-up exceeds the 30s suite default,
so the timeout is bumped to 180s.
Run: pytest -m integration tests/integration/test_vision_docker_resolve.py
"""
import base64
import shutil
import subprocess
import pytest
def _docker_available() -> bool:
"""True iff a docker CLI is on PATH and the daemon answers."""
if shutil.which("docker") is None:
return False
try:
return subprocess.run(
["docker", "info"], capture_output=True, timeout=5
).returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
pytestmark = [
pytest.mark.integration,
pytest.mark.timeout(180),
pytest.mark.skipif(not _docker_available(), reason="Docker daemon not available"),
]
# A real 1x1 PNG.
_TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
@pytest.fixture
def docker_backend(request, monkeypatch):
"""A live DockerEnvironment registered so ``get_active_env`` finds it.
Unique task_id per test: DockerEnvironment derives the container name from
the task_id, so a shared id would make one test's teardown remove the
other's container.
"""
from tools import terminal_tool
from tools.environments import docker as docker_env
# The resolver keys the exec-read off TERMINAL_ENV=docker.
monkeypatch.setenv("TERMINAL_ENV", "docker")
task_id = f"vision-docker-resolve-{request.node.name}"
env = docker_env.DockerEnvironment(
image="python:3.11-slim",
cwd="/workspace",
timeout=120,
task_id=task_id,
volumes=[],
network=False,
)
# Register under the raw task_id; get_active_env() falls back to the raw
# key after trying the _resolve_container_task_id() mapping.
with terminal_tool._env_lock:
terminal_tool._active_environments[task_id] = env
try:
env._task_id = task_id
yield env
finally:
with terminal_tool._env_lock:
terminal_tool._active_environments.pop(task_id, None)
try:
env.cleanup()
except Exception:
pass
def _write_png_in_container(env, path, *, mode=None):
b64 = base64.b64encode(_TINY_PNG).decode()
cmd = f"printf %s {b64} | base64 -d > {path}"
if mode:
cmd += f" && chmod {mode} {path}"
res = env.execute(cmd)
assert res.get("returncode", 1) == 0, res
@pytest.mark.asyncio
async def test_resolves_tmpfs_workspace_file(docker_backend):
"""A container-only path (no host file) is delivered via exec-read."""
from tools.image_source import ResolveContext, resolve_image_source
_write_png_in_container(docker_backend, "/workspace/shot.png")
res = await resolve_image_source(
"/workspace/shot.png", ResolveContext(task_id=docker_backend._task_id))
assert res.origin == "container"
assert res.mime == "image/png"
assert res.data == _TINY_PNG
@pytest.mark.asyncio
async def test_resolves_root_owned_mode600_file(docker_backend):
"""Root-owned mode-600 (host user can't read it) is served in-container."""
from tools.image_source import ResolveContext, resolve_image_source
_write_png_in_container(docker_backend, "/workspace/secret.png", mode="600")
res = await resolve_image_source(
"/workspace/secret.png", ResolveContext(task_id=docker_backend._task_id))
assert res.origin == "container"
assert res.mime == "image/png"
assert res.data == _TINY_PNG
@pytest.mark.asyncio
async def test_host_secret_path_reads_container_not_host(docker_backend, tmp_path):
"""The GHSA-gpxw invariant, end-to-end against real Docker.
A path that exists on the HOST with secret bytes but does NOT exist in the
container must resolve to the container read (which fails to find it) —
never to the host bytes. Proves vision cannot exfiltrate a host file under
a sandbox backend even when the exact path is real on the host.
"""
from tools.image_source import (
ImageResolutionError,
ResolveContext,
resolve_image_source,
)
# A real host file outside any media cache, holding a secret.
host_secret = tmp_path / "id_rsa"
host_secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK")
# That path does not exist inside the fresh container, so the exec-read
# finds nothing and the resolver refuses. The exact failure shape depends
# on the container's base64 build (some exit non-zero -> SourceNotFound,
# some print an error to stderr and exit 0 with empty stdout -> NotAnImage);
# both are ImageResolutionError. What matters for GHSA-gpxw: the resolver
# never returns the HOST bytes.
with pytest.raises(ImageResolutionError) as excinfo:
await resolve_image_source(
str(host_secret), ResolveContext(task_id=docker_backend._task_id))
# Belt and suspenders: the host secret must not appear anywhere in the error.
assert b"HOST-PRIVATE-KEY".decode() not in str(excinfo.value)