from __future__ import annotations import asyncio import builtins import errno import io import queue import shutil import socket import tarfile import threading import time import uuid from collections.abc import Callable, Iterator from pathlib import Path from typing import cast import docker.errors # type: ignore[import-untyped] import pytest from pydantic import Field, PrivateAttr import agents.sandbox.sandboxes.docker as docker_sandbox from agents.sandbox import SandboxPathGrant from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.entries import ( AzureBlobMount, BoxMount, Dir, DockerVolumeMountStrategy, File, FuseMountPattern, GCSMount, InContainerMountStrategy, Mount, MountpointMountPattern, MountStrategy, RcloneMountPattern, S3FilesMount, S3FilesMountPattern, S3Mount, ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter from agents.sandbox.errors import ( ExecTimeoutError, ExecTransportError, InvalidManifestPathError, MountConfigError, PtySessionNotFoundError, WorkspaceArchiveWriteError, ) from agents.sandbox.files import EntryKind, FileEntry from agents.sandbox.manifest import Manifest from agents.sandbox.materialization import MaterializedFile from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, DockerSandboxSession, DockerSandboxSessionState, ) from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, Permissions class _FakeDockerContainer: def __init__(self, host_root: Path, *, archive_error: Exception | None = None) -> None: self._host_root = host_root self.client: object | None = None self.id = "container" self.status = "running" self.archive_calls: list[str] = [] self.archive_error = archive_error def reload(self) -> None: return def get_archive(self, path: str) -> tuple[object, dict[str, object]]: self.archive_calls.append(path) if self.archive_error is not None: raise self.archive_error if path == "/workspace": raise docker.errors.APIError("root archive unsupported") host_path = self._host_path(path) buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: tar.add(host_path, arcname=Path(path).name) buf.seek(0) return iter([buf.getvalue()]), {} def _host_path(self, path: str | Path) -> Path: container_path = Path(path) return self._host_root / container_path.relative_to("/") class _PullRecorder: def __init__(self) -> None: self.calls: list[tuple[str, str | None, bool]] = [] def pull(self, repo: str, *, tag: str | None = None, all_tags: bool = False) -> None: self.calls.append((repo, tag, all_tags)) class _FakeDockerClient: def __init__(self) -> None: self.images = _PullRecorder() class _StreamingArchiveResponse: def __init__(self, chunks: list[bytes]) -> None: self._chunks = chunks self.headers: dict[str, str] = {} self.close_calls = 0 def iter_content(self, chunk_size: int, decode: bool) -> Iterator[bytes]: del chunk_size, decode return iter(self._chunks) def close(self) -> None: self.close_calls += 1 class _StreamingArchiveAPI: def __init__(self, response: _StreamingArchiveResponse) -> None: self._response = response self.get_calls: list[dict[str, object]] = [] self.stream_calls: list[tuple[int, bool]] = [] def _url(self, template: str, container_id: str) -> str: return template.format(container_id) def _get( self, url: str, *, params: dict[str, str], stream: bool, headers: dict[str, str], ) -> _StreamingArchiveResponse: self.get_calls.append( { "url": url, "params": dict(params), "stream": stream, "headers": dict(headers), } ) return self._response def _raise_for_status(self, response: _StreamingArchiveResponse) -> None: assert response is self._response def _stream_raw_result( self, response: _StreamingArchiveResponse, *, chunk_size: int, decode: bool, ) -> Iterator[bytes]: assert response is self._response self.stream_calls.append((chunk_size, decode)) yield from response.iter_content(chunk_size, decode) class _StreamingArchiveContainerClient: def __init__(self, api: _StreamingArchiveAPI) -> None: self.api = api class _SocketStartResponse: def __init__(self) -> None: self.close_calls = 0 def close(self) -> None: self.close_calls += 1 class _SocketStartSocket: def __init__(self) -> None: self.close_calls = 0 def close(self) -> None: self.close_calls += 1 class _SocketStartAPI: def __init__(self) -> None: self.response = _SocketStartResponse() self.sock = _SocketStartSocket() self.post_calls: list[dict[str, object]] = [] def _url(self, template: str, exec_id: str) -> str: return template.format(exec_id) def _post_json( self, url: str, *, headers: dict[str, str], data: dict[str, object], stream: bool, ) -> _SocketStartResponse: self.post_calls.append( { "url": url, "headers": dict(headers), "data": dict(data), "stream": stream, } ) return self.response def _get_raw_response_socket(self, response: _SocketStartResponse) -> _SocketStartSocket: assert response is self.response return self.sock class _CreateRecorder: def __init__(self, container: object) -> None: self._container = container self.calls: list[dict[str, object]] = [] def create(self, **kwargs: object) -> object: self.calls.append(dict(kwargs)) return self._container class _FakeCreateDockerClient(_FakeDockerClient): def __init__(self, container: object) -> None: super().__init__() self.containers = _CreateRecorder(container) class _DeleteVolume: def __init__(self) -> None: self.remove_calls = 0 def remove(self) -> None: self.remove_calls += 1 class _DeleteVolumeCollection: def __init__(self, volumes: dict[str, _DeleteVolume]) -> None: self._volumes = volumes self.get_calls: list[str] = [] def get(self, name: str) -> _DeleteVolume: self.get_calls.append(name) try: return self._volumes[name] except KeyError as exc: raise docker.errors.NotFound("volume not found") from exc class _DeleteContainer: def __init__(self) -> None: self.status = "exited" self.remove_calls: list[dict[str, object]] = [] self.stop_calls = 0 def reload(self) -> None: return None def stop(self) -> None: self.stop_calls += 1 def remove(self, **kwargs: object) -> None: self.remove_calls.append(kwargs) class _DeleteContainerCollection: def __init__(self, container: _DeleteContainer) -> None: self._container = container self.get_calls: list[str] = [] def get(self, container_id: str) -> _DeleteContainer: self.get_calls.append(container_id) return self._container class _DeleteDockerClient(_FakeDockerClient): def __init__( self, *, container: _DeleteContainer, volumes: dict[str, _DeleteVolume], ) -> None: super().__init__() self.containers = _DeleteContainerCollection(container) self.volumes = _DeleteVolumeCollection(volumes) class _HostBackedDockerSession(DockerSandboxSession): def __init__( self, *, host_root: Path, manifest: Manifest, event_log: list[tuple[str, str]] | None = None, archive_error: Exception | None = None, ) -> None: container = _FakeDockerContainer(host_root, archive_error=archive_error) state = DockerSandboxSessionState( manifest=manifest, snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ) super().__init__( docker_client=object(), container=container, state=state, ) self._host_root = host_root self._fake_container = container self._event_log = event_log if event_log is not None else [] async def _exec_internal( self, *command: str | Path, timeout: float | None = None, ) -> ExecResult: _ = timeout cmd = [str(part) for part in command] helper_path = str(RESOLVE_WORKSPACE_PATH_HELPER.install_path) if cmd[:2] == ["sh", "-c"] and RESOLVE_WORKSPACE_PATH_HELPER.install_marker in cmd[2]: return ExecResult(stdout=b"", stderr=b"", exit_code=0) if cmd == ["test", "-x", helper_path]: return ExecResult(stdout=b"", stderr=b"", exit_code=0) if cmd and cmd[0] == helper_path: for_write = cmd[3] candidate = self._host_path(cmd[2]).resolve(strict=False) workspace_root = self._host_path(cmd[1]).resolve(strict=False) try: candidate.relative_to(workspace_root) except ValueError: pass else: return ExecResult( stdout=self._container_path(candidate).as_posix().encode("utf-8"), stderr=b"", exit_code=0, ) best_root: Path | None = None best_original = "" best_read_only = False grant_args = cmd[4:] assert len(grant_args) % 2 == 0 for original_root, read_only_text in zip( grant_args[::2], grant_args[1::2], strict=False, ): root = self._host_path(original_root).resolve(strict=False) if root == root.parent: return ExecResult( stdout=b"", stderr=( f"extra path grant must not resolve to filesystem root: {original_root}" ).encode(), exit_code=113, ) try: candidate.relative_to(root) except ValueError: continue if best_root is None or len(root.parts) > len(best_root.parts): best_root = root best_original = original_root best_read_only = read_only_text == "1" if best_root is not None: if for_write == "1" and best_read_only: return ExecResult( stdout=b"", stderr=( f"read-only extra path grant: {best_original}\n" f"resolved path: {self._container_path(candidate).as_posix()}\n" ).encode(), exit_code=114, ) return ExecResult( stdout=self._container_path(candidate).as_posix().encode("utf-8"), stderr=b"", exit_code=0, ) return ExecResult(stdout=b"", stderr=b"workspace escape", exit_code=111) if cmd[:2] == ["mkdir", "-p"]: self._host_path(cmd[2]).mkdir(parents=True, exist_ok=True) return ExecResult(stdout=b"", stderr=b"", exit_code=0) if cmd[:3] == ["cp", "-R", "--"]: self._event_log.append(("cp", cmd[3])) src = self._host_path(cmd[3]) dst = self._host_path(cmd[4]) if src.is_dir(): shutil.copytree(src, dst) else: shutil.copy2(src, dst) return ExecResult(stdout=b"", stderr=b"", exit_code=0) if cmd[:2] == ["cat", "--"]: src = self._host_path(cmd[2]) try: return ExecResult(stdout=src.read_bytes(), stderr=b"", exit_code=0) except OSError as exc: return ExecResult(stdout=b"", stderr=str(exc).encode(), exit_code=1) if cmd[:2] == ["rm", "--"] or cmd[:3] == ["rm", "-rf", "--"]: recursive = cmd[1] == "-rf" target = self._host_path(cmd[3] if recursive else cmd[2]) if target.is_symlink() or target.is_file(): try: target.unlink() except FileNotFoundError: pass return ExecResult(stdout=b"", stderr=b"", exit_code=0) if target.is_dir() and recursive: shutil.rmtree(target, ignore_errors=True) return ExecResult(stdout=b"", stderr=b"", exit_code=0) return ExecResult(stdout=b"", stderr=b"is a directory", exit_code=1) raise AssertionError(f"Unexpected command: {cmd!r}") async def ls( self, path: Path | str, *, user: object = None, ) -> list[FileEntry]: _ = user container_path = await self._validate_path_access(path) host_path = self._host_path(container_path) entries: list[FileEntry] = [] for child in sorted(host_path.iterdir()): if child.is_dir(): kind = EntryKind.DIRECTORY elif child.is_symlink(): kind = EntryKind.SYMLINK else: kind = EntryKind.FILE entries.append( FileEntry( path=(container_path / child.name).as_posix(), permissions=Permissions.from_mode(child.stat().st_mode), owner="root", group="root", size=child.stat().st_size, kind=kind, ) ) return entries def _host_path(self, path: str | Path) -> Path: container_path = Path(path) return self._host_root / container_path.relative_to("/") def _container_path(self, path: Path) -> Path: return Path("/") / path.relative_to(self._host_root) class _CleanupTrackingDockerSession(_HostBackedDockerSession): def __init__(self, *, host_root: Path, manifest: Manifest) -> None: super().__init__(host_root=host_root, manifest=manifest) self.stage_cleanup_calls: list[Path] = [] self.last_staging_parent: Path | None = None async def _stage_workspace_copy( self, *, skip_rel_paths: set[Path], ) -> tuple[Path, Path]: staging_parent, staging_workspace = await super()._stage_workspace_copy( skip_rel_paths=skip_rel_paths ) self.last_staging_parent = staging_parent return staging_parent, staging_workspace async def _rm_best_effort(self, path: Path) -> None: self.stage_cleanup_calls.append(path) await super()._rm_best_effort(path) class _RecordingMount(Mount): type: str = f"recording_mount_{uuid.uuid4().hex}" mount_strategy: MountStrategy = Field( default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern()) ) remove_on_unmount: bool = True remount_marker: str | None = None _events: list[tuple[str, str]] = PrivateAttr(default_factory=list) def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount: self._events = events return self def supported_in_container_patterns( self, ) -> tuple[builtins.type[MountpointMountPattern], ...]: return (MountpointMountPattern,) def supported_docker_volume_drivers(self) -> frozenset[str]: return frozenset({"rclone"}) def build_docker_volume_driver_config( self, strategy: DockerVolumeMountStrategy, ) -> tuple[str, dict[str, str], bool]: _ = strategy raise MountConfigError( message="docker-volume mounts are not supported for this mount type", context={"mount_type": self.type}, ) def in_container_adapter(self) -> InContainerMountAdapter: mount = self class _Adapter(InContainerMountAdapter): def validate(self, strategy: InContainerMountStrategy) -> None: _ = strategy async def activate( self, strategy: InContainerMountStrategy, session: BaseSandboxSession, dest: Path, base_dir: Path, ) -> list[MaterializedFile]: _ = (strategy, base_dir) mount_path = mount._resolve_mount_path(session, dest) host_path = cast(_HostBackedDockerSession, session)._host_path(mount_path) host_path.mkdir(parents=True, exist_ok=True) mount._events.append(("mount", mount_path.as_posix())) if mount.remount_marker is not None: (host_path / mount.remount_marker).write_text("remounted", encoding="utf-8") return [] async def deactivate( self, strategy: InContainerMountStrategy, session: BaseSandboxSession, dest: Path, base_dir: Path, ) -> None: _ = (strategy, base_dir) mount_path = mount._resolve_mount_path(session, dest) await self.teardown_for_snapshot(strategy, session, mount_path) async def teardown_for_snapshot( self, strategy: InContainerMountStrategy, session: BaseSandboxSession, path: Path, ) -> None: _ = strategy host_path = cast(_HostBackedDockerSession, session)._host_path(path) mount._events.append(("unmount", path.as_posix())) if not mount.remove_on_unmount: return shutil.rmtree(host_path, ignore_errors=True) async def restore_after_snapshot( self, strategy: InContainerMountStrategy, session: BaseSandboxSession, path: Path, ) -> None: _ = strategy host_path = cast(_HostBackedDockerSession, session)._host_path(path) host_path.mkdir(parents=True, exist_ok=True) mount._events.append(("mount", path.as_posix())) if mount.remount_marker is not None: (host_path / mount.remount_marker).write_text("remounted", encoding="utf-8") return _Adapter(self) def _archive_member_names(archive: io.IOBase) -> list[str]: payload = archive.read() if not isinstance(payload, bytes): raise AssertionError(f"Expected bytes archive payload, got {type(payload)!r}") with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as tar: return tar.getnames() def _tar_bytes(*members: str) -> bytes: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: for name in members: payload = b"pwned" info = tarfile.TarInfo(name=name) info.size = len(payload) tar.addfile(info, io.BytesIO(payload)) return buf.getvalue() def _tar_symlink_bytes(*, name: str, target: str) -> bytes: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: info = tarfile.TarInfo(name=name) info.type = tarfile.SYMTYPE info.linkname = target tar.addfile(info) return buf.getvalue() class _RejectUnboundedRead(io.BytesIO): def read(self, size: int | None = -1) -> bytes: if size is None or size < 0: raise AssertionError("hydrate_workspace() must read archive streams in bounded chunks") return super().read(size) @pytest.mark.asyncio async def test_docker_persist_workspace_stages_copy_before_get_archive( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "README.md").write_text("hello from workspace", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert "/workspace" not in session._fake_container.archive_calls assert "." in names assert "README.md" in names assert not any(name == "workspace" or name.startswith("workspace/") for name in names) @pytest.mark.asyncio async def test_docker_persist_workspace_closes_archive_http_response_after_normalization( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "README.md").write_text("hello from workspace", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) payload = _tar_bytes("workspace/README.md") response = _StreamingArchiveResponse([payload]) api = _StreamingArchiveAPI(response) session._fake_container.client = _StreamingArchiveContainerClient(api) session._fake_container.id = "container" archive = await session.persist_workspace() assert response.close_calls == 1 assert _archive_member_names(archive) == ["README.md"] assert response.close_calls == 1 @pytest.mark.asyncio async def test_docker_persist_workspace_defers_stage_cleanup_until_archive_close( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "README.md").write_text("hello from workspace", encoding="utf-8") session = _CleanupTrackingDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) archive = await session.persist_workspace() assert session.last_staging_parent is not None assert session.stage_cleanup_calls == [] _ = archive.read() await session._wait_for_cleanup_tasks() assert session.stage_cleanup_calls == [session.last_staging_parent] assert session._cleanup_tasks == set() @pytest.mark.asyncio async def test_docker_shutdown_drains_deferred_cleanup_before_backend_stop( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: host_root = tmp_path / "container" host_root.mkdir() session = _CleanupTrackingDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) cleanup_started = asyncio.Event() release_cleanup = asyncio.Event() events: list[str] = [] async def blocked_cleanup(_path: Path) -> None: cleanup_started.set() await release_cleanup.wait() events.append("cleanup") async def shutdown_backend() -> None: events.append("shutdown") monkeypatch.setattr(session, "_rm_best_effort", blocked_cleanup) monkeypatch.setattr(session, "_shutdown_backend", shutdown_backend) session._schedule_rm_best_effort(Path("/tmp/stage")) await cleanup_started.wait() shutdown_task = asyncio.create_task(session.shutdown()) await asyncio.sleep(0) assert events == [] release_cleanup.set() await shutdown_task assert events == ["cleanup", "shutdown"] assert session._cleanup_tasks == set() @pytest.mark.asyncio async def test_docker_after_stop_bounds_deferred_cleanup_wait( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: host_root = tmp_path / "container" host_root.mkdir() session = _CleanupTrackingDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) cleanup_started = asyncio.Event() cleanup_cancelled = asyncio.Event() release_cleanup = asyncio.Event() async def stalled_cleanup(_path: Path) -> None: cleanup_started.set() try: await asyncio.Event().wait() except asyncio.CancelledError: cleanup_cancelled.set() await release_cleanup.wait() monkeypatch.setattr(docker_sandbox, "_DEFERRED_CLEANUP_TIMEOUT_S", 0.01) monkeypatch.setattr(session, "_rm_best_effort", stalled_cleanup) session._schedule_rm_best_effort(Path("/tmp/stage")) cleanup_task = next(iter(session._cleanup_tasks)) await cleanup_started.wait() await asyncio.wait_for(session._after_stop(), timeout=0.5) await asyncio.wait_for(cleanup_cancelled.wait(), timeout=0.5) assert cleanup_task in session._cleanup_tasks assert not cleanup_task.done() release_cleanup.set() await cleanup_task await asyncio.sleep(0) assert session._cleanup_tasks == set() def test_docker_start_exec_socket_closes_underlying_http_response() -> None: api = _SocketStartAPI() exec_socket = DockerSandboxSession._start_exec_socket(api=api, exec_id="exec-123", tty=True) assert api.post_calls == [ { "url": "/exec/exec-123/start", "headers": {"Connection": "Upgrade", "Upgrade": "tcp"}, "data": {"Tty": True, "Detach": False}, "stream": True, } ] assert exec_socket.sock is api.sock assert exec_socket.raw_sock is api.sock exec_socket.close() assert api.sock.close_calls == 1 assert api.response.close_calls == 1 class _RecordingStreamSocket: """Exec socket that records stdin bytes and returns EOF immediately, as a real daemon does once the (length-bounded) in-container command exits.""" def __init__(self) -> None: self.sent = bytearray() self.shutdown_calls: list[int] = [] self.closed = False @property def _sock(self) -> _RecordingStreamSocket: return self def sendall(self, data: bytes) -> None: self.sent.extend(data) def shutdown(self, how: int) -> None: self.shutdown_calls.append(how) def recv(self, _n: int) -> bytes: return b"" def close(self) -> None: self.closed = True class _RecordingStreamAPI: def __init__(self) -> None: self.exec_create_calls: list[dict[str, object]] = [] self.sock = _RecordingStreamSocket() def exec_create(self, container_id: str, cmd: list[str], **kwargs: object) -> dict[str, str]: self.exec_create_calls.append({"container_id": container_id, "cmd": cmd, **kwargs}) return {"Id": "exec-stream"} def exec_start(self, exec_id: str, *, socket: bool = False, tty: bool = False) -> object: return self.sock def exec_inspect(self, exec_id: str) -> dict[str, int]: return {"ExitCode": 0} def _make_streaming_session(api: _RecordingStreamAPI) -> DockerSandboxSession: class _Client: def __init__(self) -> None: self.api = api class _Container: def __init__(self) -> None: self.client = _Client() self.id = "container" def _coerce(user: object = None) -> str: return "" session = object.__new__(DockerSandboxSession) session._container = _Container() session._coerce_exec_user = _coerce # type: ignore[method-assign] return session @pytest.mark.asyncio async def test_stream_into_exec_length_frames_stdin_payload() -> None: """The in-container command is wrapped in ``head -c `` so it terminates on a byte count rather than a stdin half-close (which is unreliable over a TLS DOCKER_HOST — see the DinD hang this guards against). Regression test.""" api = _RecordingStreamAPI() session = _make_streaming_session(api) payload = b"hello-\x00\xff-world" * 500 # includes NULs / non-utf8 bytes await session._stream_into_exec( cmd=["tar", "-x", "-C", "/workspace"], stream=io.BytesIO(payload), error_path=Path("/workspace"), ) assert len(api.exec_create_calls) == 1 framed = cast("list[str]", api.exec_create_calls[0]["cmd"]) assert framed == [ "sh", "-c", docker_sandbox._LENGTH_FRAMED_STDIN_SCRIPT, "sh", str(len(payload)), "tar", "-x", "-C", "/workspace", ] # The framing script bounds the read by byte count (`head -c`) and preflights # `head -c` so a missing head OR a POSIX-only head that rejects `-c` is fatal # (exit 98) instead of silently writing an empty file — no temp file involved. assert 'head -c "$n"' in framed[2] assert "head -c 1" in framed[2] and "exit 98" in framed[2] # Exactly the payload is streamed, and the count matches the head -c bound — # so completion never depends on the stdin half-close working. assert bytes(api.sock.sent) == payload assert framed[4] == str(len(api.sock.sent)) @pytest.mark.asyncio async def test_stream_into_exec_frames_non_seekable_stream() -> None: """A non-seekable stream is buffered so the byte count is still correct.""" class _NonSeekable(io.RawIOBase): def __init__(self, data: bytes) -> None: self._data = data self._read = False def readable(self) -> bool: return True def seekable(self) -> bool: return False def seek(self, *_a: object, **_k: object) -> int: raise OSError("not seekable") def read(self, _size: int = -1) -> bytes: if self._read: return b"" self._read = True return self._data api = _RecordingStreamAPI() session = _make_streaming_session(api) payload = b"x" * 1234 await session._stream_into_exec( cmd=["sh", "-lc", 'cat > "$1"', "sh", "/workspace/f"], stream=cast(io.IOBase, _NonSeekable(payload)), error_path=Path("/workspace/f"), ) framed = cast("list[str]", api.exec_create_calls[0]["cmd"]) assert framed[:4] == ["sh", "-c", docker_sandbox._LENGTH_FRAMED_STDIN_SCRIPT, "sh"] assert framed[4] == str(len(payload)) assert bytes(api.sock.sent) == payload @pytest.mark.asyncio async def test_stream_into_exec_fails_when_stream_ends_before_measured_length() -> None: """If the stream yields fewer bytes than measured (e.g. truncated after _measure_stream), fail loudly and send at most the framed count — never short-feed `head -c` and re-introduce the TLS stdin hang.""" class _ShrinkingStream(io.RawIOBase): """Reports length 100 via seek/tell but only yields 10 bytes.""" def __init__(self) -> None: self._pos = 0 self._served = False def seekable(self) -> bool: return True def readable(self) -> bool: return True def tell(self) -> int: return self._pos def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: self._pos = 100 if whence == io.SEEK_END else offset return self._pos def read(self, _size: int = -1) -> bytes: if self._served: return b"" self._served = True return b"x" * 10 api = _RecordingStreamAPI() session = _make_streaming_session(api) with pytest.raises(WorkspaceArchiveWriteError): await session._stream_into_exec( cmd=["sh", "-lc", 'cat > "$1"', "sh", "/workspace/f"], stream=cast(io.IOBase, _ShrinkingStream()), error_path=Path("/workspace/f"), ) # It framed for 100 bytes but sent at most what the stream produced (10) — # never more than the measured count. framed = cast("list[str]", api.exec_create_calls[0]["cmd"]) assert framed[4] == "100" assert len(api.sock.sent) == 10 @pytest.mark.asyncio async def test_stream_into_exec_clamps_length_when_position_past_end() -> None: """A stream positioned past its end measures to a negative delta; clamp to 0 so it never becomes `head -c -N` (which reads to EOF and re-hangs over TLS).""" api = _RecordingStreamAPI() session = _make_streaming_session(api) stream = io.BytesIO(b"abc") stream.seek(10) # past EOF -> end - start would be negative await session._stream_into_exec( cmd=["tar", "-x", "-C", "/workspace"], stream=stream, error_path=Path("/workspace"), ) framed = cast("list[str]", api.exec_create_calls[0]["cmd"]) assert framed[4] == "0" # not "-7" assert api.sock.sent == bytearray() # nothing sent; no unbounded read def test_measure_stream_closes_spool_when_copy_fails(monkeypatch: pytest.MonkeyPatch) -> None: """If reading a non-seekable stream into the spool raises, _measure_stream must close the spool itself — the caller never receives it to close.""" created: list[object] = [] class _RecordingSpool: def __init__(self, *args: object, **kwargs: object) -> None: self.closed = False def write(self, _data: bytes) -> int: return 0 def seek(self, *_a: object, **_k: object) -> int: return 0 def close(self) -> None: self.closed = True def _factory(*_a: object, **_k: object) -> _RecordingSpool: spool = _RecordingSpool() created.append(spool) return spool monkeypatch.setattr("tempfile.SpooledTemporaryFile", _factory) class _ExplodingNonSeekable(io.RawIOBase): def seekable(self) -> bool: return False def readable(self) -> bool: return True def seek(self, *_a: object, **_k: object) -> int: raise OSError("not seekable") # forces the spool branch def read(self, *_a: object, **_k: object) -> bytes: raise RuntimeError("read boom") with pytest.raises(RuntimeError, match="read boom"): docker_sandbox._measure_stream(cast(io.IOBase, _ExplodingNonSeekable())) assert created, "expected a spool to be created" assert cast("_RecordingSpool", created[0]).closed, "spool was leaked (not closed)" @pytest.mark.asyncio async def test_docker_persist_workspace_prunes_ephemeral_entries_from_staged_copy( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "keep.txt").write_text("keep", encoding="utf-8") (workspace / "skip.txt").write_text("skip", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "skip.txt": File(content=b"skip", ephemeral=True), }, ), ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert "keep.txt" in names assert "skip.txt" not in names @pytest.mark.asyncio async def test_docker_persist_workspace_prunes_mount_paths_without_mount_lifecycle( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" mount_dir = workspace / "repo" / "mount" mount_dir.mkdir(parents=True) (mount_dir / "remote.txt").write_text("remote", encoding="utf-8") events: list[tuple[str, str]] = [] mount = _RecordingMount(remount_marker="remounted.txt").bind_events(events) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "repo": Dir( children={ "mount": mount, } ) }, ), event_log=events, ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert events == [] assert not any(name.endswith("repo/mount/remote.txt") for name in names) assert not (mount_dir / "remounted.txt").exists() @pytest.mark.asyncio async def test_docker_persist_workspace_skips_workspace_root_mount_without_traversing_remote_data( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "remote.txt").write_text("remote", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "root-mount": _RecordingMount(mount_path=Path("/workspace")), }, ), ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert "." in names assert "remote.txt" not in names @pytest.mark.asyncio async def test_docker_persist_workspace_pruned_copy_skips_mount_subtree_but_copies_siblings( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" repo_dir = workspace / "repo" mount_dir = repo_dir / "mount" mount_dir.mkdir(parents=True) (repo_dir / "keep.txt").write_text("keep", encoding="utf-8") (mount_dir / "remote.txt").write_text("remote", encoding="utf-8") events: list[tuple[str, str]] = [] session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "repo": Dir( children={ "mount": _RecordingMount().bind_events(events), } ) }, ), event_log=events, ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert ("cp", "/workspace/repo/keep.txt") in events assert not any( path.startswith("/workspace/repo/mount") for kind, path in events if kind == "cp" ) assert "repo/keep.txt" in names assert "repo/mount/remote.txt" not in names @pytest.mark.asyncio async def test_docker_persist_workspace_prunes_runtime_only_skip_paths_from_staged_copy( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" logs = workspace / "logs" logs.mkdir(parents=True) (logs / "keep.txt").write_text("keep", encoding="utf-8") (logs / "events.jsonl").write_text("skip", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) session.register_persist_workspace_skip_path(Path("logs/events.jsonl")) archive = await session.persist_workspace() names = _archive_member_names(archive) assert "logs/keep.txt" in names assert "logs/events.jsonl" not in names @pytest.mark.asyncio async def test_docker_persist_workspace_prunes_explicit_mount_path_from_staged_copy( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" actual_mount_path = workspace / "actual" actual_mount_path.mkdir(parents=True) (actual_mount_path / "remote.txt").write_text("remote", encoding="utf-8") mount = _RecordingMount(mount_path=Path("actual"), remove_on_unmount=False) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "logical": mount, }, ), ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert "actual/remote.txt" not in names assert (actual_mount_path / "remote.txt").read_text(encoding="utf-8") == "remote" @pytest.mark.asyncio async def test_docker_persist_workspace_prunes_nested_mount_paths_without_mount_lifecycle( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" parent_mount_dir = workspace / "repo" child_mount_dir = parent_mount_dir / "sub" child_mount_dir.mkdir(parents=True) (child_mount_dir / "remote.txt").write_text("remote", encoding="utf-8") events: list[tuple[str, str]] = [] session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", entries={ "repo": _RecordingMount( remount_marker="parent-remounted.txt", ).bind_events(events), "child": _RecordingMount( mount_path=Path("repo/sub"), remount_marker="child-remounted.txt", ).bind_events(events), }, ), event_log=events, ) archive = await session.persist_workspace() names = _archive_member_names(archive) assert events == [] assert "repo/remote.txt" not in names assert "repo/sub/remote.txt" not in names assert not (parent_mount_dir / "parent-remounted.txt").exists() assert not (child_mount_dir / "child-remounted.txt").exists() @pytest.mark.asyncio async def test_docker_read_and_write_reject_paths_outside_workspace_root(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.read(Path("../secret.txt")) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.write(Path("../secret.txt"), io.BytesIO(b"nope")) @pytest.mark.asyncio async def test_docker_read_returns_file_bytes_without_archive_api(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) (workspace / "hello.bin").write_bytes(b"hello\x00world") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) data = await session.read(Path("hello.bin")) assert data.read() == b"hello\x00world" assert session._fake_container.archive_calls == [] @pytest.mark.asyncio async def test_docker_normalize_path_preserves_safe_leaf_symlink_path(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) target = workspace / "target.txt" target.write_text("hello", encoding="utf-8") (workspace / "link.txt").symlink_to(target) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) normalized = await session._validate_path_access(Path("link.txt")) # noqa: SLF001 assert normalized == Path("/workspace/link.txt") @pytest.mark.asyncio async def test_docker_read_allows_extra_path_grant(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" extra_root = host_root / "tmp" workspace.mkdir(parents=True) extra_root.mkdir(parents=True) (extra_root / "result.txt").write_text("scratch output", encoding="utf-8") session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", extra_path_grants=(SandboxPathGrant(path="/tmp"),), ), ) data = await session.read(Path("/tmp/result.txt")) assert data.read() == b"scratch output" @pytest.mark.asyncio async def test_docker_write_rejects_read_only_extra_path_grant(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" extra_root = host_root / "tmp" workspace.mkdir(parents=True) extra_root.mkdir(parents=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),), ), ) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.write(Path("/tmp/result.txt"), io.BytesIO(b"scratch output")) assert str(exc_info.value) == "failed to write archive for path: /tmp/result.txt" assert exc_info.value.context == { "path": "/tmp/result.txt", "reason": "read_only_extra_path_grant", "grant_path": "/tmp", } @pytest.mark.asyncio async def test_docker_write_rejects_workspace_symlink_to_read_only_extra_path_grant( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" extra_root = host_root / "tmp" workspace.mkdir(parents=True) extra_root.mkdir(parents=True) (workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),), ), ) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.write(Path("tmp-link/result.txt"), io.BytesIO(b"scratch output")) assert str(exc_info.value) == "failed to write archive for path: /workspace/tmp-link/result.txt" assert exc_info.value.context == { "path": "/workspace/tmp-link/result.txt", "reason": "read_only_extra_path_grant", "grant_path": "/tmp", "resolved_path": "/tmp/result.txt", } @pytest.mark.asyncio async def test_docker_write_rejects_workspace_symlink_to_nested_read_only_extra_path_grant( tmp_path: Path, ) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" extra_root = host_root / "tmp" protected_root = extra_root / "protected" workspace.mkdir(parents=True) protected_root.mkdir(parents=True) (workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest( root="/workspace", extra_path_grants=( SandboxPathGrant(path="/tmp"), SandboxPathGrant(path="/tmp/protected", read_only=True), ), ), ) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.write( Path("tmp-link/protected/result.txt"), io.BytesIO(b"scratch output"), ) assert ( str(exc_info.value) == "failed to write archive for path: /workspace/tmp-link/protected/result.txt" ) assert exc_info.value.context == { "path": "/workspace/tmp-link/protected/result.txt", "reason": "read_only_extra_path_grant", "grant_path": "/tmp/protected", "resolved_path": "/tmp/protected/result.txt", } @pytest.mark.asyncio async def test_docker_rm_unlinks_safe_internal_leaf_symlink(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) target = workspace / "target.txt" target.write_text("hello", encoding="utf-8") link = workspace / "link.txt" link.symlink_to(target) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) await session.rm(Path("link.txt")) assert target.read_text(encoding="utf-8") == "hello" assert not link.exists() @pytest.mark.asyncio async def test_docker_workspace_file_ops_reject_symlink_escape(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" outside = host_root / "outside" workspace.mkdir(parents=True) outside.mkdir(parents=True) (outside / "secret.txt").write_text("secret", encoding="utf-8") (workspace / "link").symlink_to(outside, target_is_directory=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.read(Path("link/secret.txt")) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.write(Path("link/secret.txt"), io.BytesIO(b"overwrite")) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.ls(Path("link")) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.mkdir(Path("link/newdir"), parents=True) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.rm(Path("link/secret.txt")) def test_manifest_requires_fuse_detects_nested_mounts() -> None: manifest = Manifest( entries={ "workspace": Dir( children={ "mount": AzureBlobMount( account="account", container="container", mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), ) } ) } ) assert docker_sandbox._manifest_requires_fuse(manifest) is True @pytest.mark.asyncio @pytest.mark.parametrize( ("member_name", "reason"), [ ("/etc/passwd", "absolute path"), ("../escape.txt", "parent traversal"), ], ) async def test_docker_hydrate_workspace_rejects_unsafe_tar_members( tmp_path: Path, member_name: str, reason: str, ) -> None: session = _HostBackedDockerSession( host_root=tmp_path / "container", manifest=Manifest(root="/workspace"), ) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.hydrate_workspace(io.BytesIO(_tar_bytes(member_name))) assert str(exc_info.value) == "failed to write archive for path: /workspace" assert exc_info.value.context == { "path": "/workspace", "reason": reason, "member": member_name, } @pytest.mark.asyncio async def test_docker_hydrate_workspace_rejects_workspace_root_symlink( tmp_path: Path, ) -> None: session = _HostBackedDockerSession( host_root=tmp_path / "container", manifest=Manifest(root="/workspace"), ) async def _unexpected_stream_into_exec( *, cmd: list[str], stream: io.IOBase, error_path: Path, user: object = None, ) -> None: _ = (cmd, stream, error_path, user) raise AssertionError("unsafe archive must be rejected before raw tar extraction") session._stream_into_exec = _unexpected_stream_into_exec # type: ignore[method-assign] with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.hydrate_workspace( io.BytesIO(_tar_symlink_bytes(name=".", target="/tmp/outside")) ) assert exc_info.value.context == { "path": "/workspace", "reason": "archive root symlink", "member": ".", } @pytest.mark.asyncio async def test_docker_hydrate_workspace_reads_archive_in_bounded_chunks(tmp_path: Path) -> None: host_root = tmp_path / "container" workspace = host_root / "workspace" workspace.mkdir(parents=True) session = _HostBackedDockerSession( host_root=host_root, manifest=Manifest(root="/workspace"), ) streamed = bytearray() stream_cmd: list[str] | None = None async def _fake_stream_into_exec( *, cmd: list[str], stream: io.IOBase, error_path: Path, user: object = None, ) -> None: nonlocal stream_cmd _ = (error_path, user) stream_cmd = cmd while True: chunk = stream.read(7) if not chunk: break assert isinstance(chunk, bytes) streamed.extend(chunk) session._stream_into_exec = _fake_stream_into_exec # type: ignore[method-assign] await session.hydrate_workspace(_RejectUnboundedRead(_tar_bytes("hello.txt"))) assert bytes(streamed) == _tar_bytes("hello.txt") assert stream_cmd == ["tar", "-x", "-C", "/workspace"] @pytest.mark.asyncio async def test_docker_create_container_parses_registry_port_image_refs( monkeypatch: pytest.MonkeyPatch, ) -> None: docker_client = _FakeDockerClient() client = DockerSandboxClient(docker_client=cast(object, docker_client)) def _missing_image(_image: str) -> bool: return False monkeypatch.setattr(client, "image_exists", _missing_image) with pytest.raises(AssertionError): await client._create_container("localhost:5000/myimg:latest") assert docker_client.images.calls == [("localhost:5000/myimg", "latest", False)] @pytest.mark.asyncio async def test_docker_create_container_publishes_exposed_ports( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container( DEFAULT_PYTHON_SANDBOX_IMAGE, exposed_ports=(8765, 9000) ) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": None, "ports": { "8765/tcp": ("127.0.0.1", None), "9000/tcp": ("127.0.0.1", None), }, } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_s3_with_volume_driver_ignoring_mount_pattern( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ "data": S3Mount( bucket="bucket", access_key_id="key-id", secret_access_key="secret", read_only=False, prefix="logs/", region="us-west-2", endpoint_url="https://s3.example.test", mount_strategy=DockerVolumeMountStrategy( driver="mountpoint", driver_options={"allow_other": "true"}, ), ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container( DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest, session_id=session_id, ) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": ( "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" ), "Type": "volume", "ReadOnly": False, "VolumeOptions": { "DriverConfig": { "Name": "mountpoint", "Options": { "bucket": "bucket", "access_key_id": "key-id", "secret_access_key": "secret", "endpoint_url": "https://s3.example.test", "region": "us-west-2", "prefix": "logs/", "allow_other": "true", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_s3_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ "data": S3Mount( bucket="bucket", access_key_id="key-id", secret_access_key="secret", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container( DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest, session_id=session_id, ) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": ( "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" ), "Type": "volume", "ReadOnly": True, "VolumeOptions": { "DriverConfig": { "Name": "rclone", "Options": { "type": "s3", "s3-provider": "AWS", "path": "bucket", "s3-access-key-id": "key-id", "s3-secret-access-key": "secret", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_gcs_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": GCSMount( bucket="bucket", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), service_account_file="/data/config/gcs.json", ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": "sandbox_ac6cdb3eb035_workspace_data", "Type": "volume", "ReadOnly": True, "VolumeOptions": { "DriverConfig": { "Name": "rclone", "Options": { "type": "google cloud storage", "path": "bucket", "gcs-service-account-file": "/data/config/gcs.json", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_gcs_hmac_with_rclone_s3_compat( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": GCSMount( bucket="bucket", access_id="access-id", secret_access_key="secret-key", prefix="prefix/", region="auto", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), read_only=False, ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": "sandbox_ac6cdb3eb035_workspace_data", "Type": "volume", "ReadOnly": False, "VolumeOptions": { "DriverConfig": { "Name": "rclone", "Options": { "type": "s3", "path": "bucket/prefix/", "s3-provider": "GCS", "s3-access-key-id": "access-id", "s3-secret-access-key": "secret-key", "s3-endpoint": "https://storage.googleapis.com", "s3-region": "auto", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_azure_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": AzureBlobMount( account="acct", container="container", endpoint="https://blob.example.test", identity_client_id="client-id", account_key="account-key", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": "sandbox_ac6cdb3eb035_workspace_data", "Type": "volume", "ReadOnly": True, "VolumeOptions": { "DriverConfig": { "Name": "rclone", "Options": { "type": "azureblob", "path": "container", "azureblob-account": "acct", "azureblob-endpoint": "https://blob.example.test", "azureblob-msi-client-id": "client-id", "azureblob-key": "account-key", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_create_container_mounts_box_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": BoxMount( path="/Shared/Finance", client_id="client-id", client_secret="client-secret", access_token="access-token", root_folder_id="12345", impersonate="user-42", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), read_only=False, ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "mounts": [ { "Target": "/workspace/data", "Source": "sandbox_ac6cdb3eb035_workspace_data", "Type": "volume", "ReadOnly": False, "VolumeOptions": { "DriverConfig": { "Name": "rclone", "Options": { "type": "box", "path": "Shared/Finance", "box-client-id": "client-id", "box-client-secret": "client-secret", "box-access-token": "access-token", "box-root-folder-id": "12345", "box-impersonate": "user-42", }, } }, } ], } ] @pytest.mark.asyncio async def test_docker_delete_removes_generated_docker_volumes() -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") manifest = Manifest( entries={ "data": S3Mount( bucket="bucket", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ), "in-container": S3Mount( bucket="bucket", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), } ) expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" container = _DeleteContainer() volume = _DeleteVolume() docker_client = _DeleteDockerClient( container=container, volumes={expected_volume_name: volume}, ) client = DockerSandboxClient(docker_client=cast(object, docker_client)) inner = DockerSandboxSession( docker_client=cast(object, docker_client), container=container, state=DockerSandboxSessionState( manifest=manifest, snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", session_id=session_id, ), ) session = client._wrap_session(inner, instrumentation=client._instrumentation) deleted = await client.delete(session) assert deleted is session assert docker_client.containers.get_calls == ["container"] assert container.remove_calls == [{}] assert docker_client.volumes.get_calls == [expected_volume_name] assert volume.remove_calls == 1 @pytest.mark.asyncio async def test_docker_clear_workspace_root_on_resume_preserves_nested_docker_volume_mounts( monkeypatch: pytest.MonkeyPatch, ) -> None: class _LsEntry: def __init__(self, path: str, kind: EntryKind) -> None: self.path = path self.kind = kind manifest = Manifest( entries={ "a/b": S3Mount( bucket="bucket", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ), } ) session = DockerSandboxSession( docker_client=object(), container=_ResumeContainer(status="running", workspace_exists=True), state=DockerSandboxSessionState( manifest=manifest, snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) ls_calls: list[Path] = [] rm_calls: list[tuple[Path, bool]] = [] async def _fake_ls(path: Path | str) -> list[_LsEntry]: rendered = Path(path) ls_calls.append(rendered) if rendered == Path("/workspace"): return [ _LsEntry("/workspace/a", EntryKind.DIRECTORY), _LsEntry("/workspace/root.txt", EntryKind.FILE), ] if rendered == Path("/workspace/a"): return [ _LsEntry("/workspace/a/b", EntryKind.DIRECTORY), _LsEntry("/workspace/a/local.txt", EntryKind.FILE), ] raise AssertionError(f"unexpected ls path: {rendered}") async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None: rm_calls.append((Path(path), recursive)) monkeypatch.setattr(session, "ls", _fake_ls) monkeypatch.setattr(session, "rm", _fake_rm) await session._clear_workspace_root_on_resume() assert ls_calls == [Path("/workspace"), Path("/workspace/a")] assert rm_calls == [ (Path("/workspace/a/local.txt"), True), (Path("/workspace/root.txt"), True), ] def test_docker_volume_name_is_collision_safe_for_separator_aliases() -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") assert ( docker_sandbox._docker_volume_name( session_id=session_id, mount_path=Path("/workspace/a_b"), ) == "sandbox_12345678123456781234567812345678_e00b2d707edb_workspace_a_b" ) assert ( docker_sandbox._docker_volume_name( session_id=session_id, mount_path=Path("/workspace/a/b"), ) == "sandbox_12345678123456781234567812345678_212366248685_workspace_a_b" ) def test_docker_volume_name_uses_strictly_safe_suffix_characters() -> None: assert ( docker_sandbox._docker_volume_name( session_id=None, mount_path=Path("/workspace/data set/@prod"), ) == "sandbox_fe44fda0e4f6_workspace_data_set__prod" ) @pytest.mark.asyncio async def test_docker_create_container_rejects_unknown_mount_subclasses( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "custom": _RecordingMount(mount_strategy=DockerVolumeMountStrategy(driver="rclone")) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) with pytest.raises( MountConfigError, match="docker-volume mounts are not supported for this mount type", ): await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert docker_client.containers.calls == [] def test_s3_files_mount_rejects_docker_volume_mount() -> None: with pytest.raises( MountConfigError, match="invalid Docker volume driver", ): S3FilesMount( file_system_id="fs-1234567890abcdef0", mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) @pytest.mark.asyncio async def test_docker_create_container_grants_fuse_for_in_container_rclone_mount( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": S3Mount( bucket="bucket", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "devices": ["/dev/fuse"], "cap_add": ["SYS_ADMIN"], "security_opt": ["apparmor:unconfined"], } ] @pytest.mark.asyncio async def test_docker_create_container_grants_sys_admin_for_s3_files_mount( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="created") docker_client = _FakeCreateDockerClient(container) client = DockerSandboxClient(docker_client=cast(object, docker_client)) manifest = Manifest( entries={ "data": S3FilesMount( file_system_id="fs-1234567890abcdef0", mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), ) } ) monkeypatch.setattr(client, "image_exists", lambda _image: True) created = await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, manifest=manifest) assert created is container assert docker_client.containers.calls == [ { "entrypoint": ["tail"], "image": DEFAULT_PYTHON_SANDBOX_IMAGE, "detach": True, "command": ["-f", "/dev/null"], "environment": {}, "cap_add": ["SYS_ADMIN"], "security_opt": ["apparmor:unconfined"], } ] class _ExecRunContainer: def __init__( self, *, workspace_exists: bool = False, exec_exit_code: int | None = 0, exec_output: tuple[bytes | None, bytes | None] = (b"", b""), ) -> None: self.exec_calls: list[dict[str, object]] = [] self._workspace_exists = workspace_exists self._exec_exit_code = exec_exit_code self._exec_output = exec_output def exec_run( self, cmd: list[str], demux: bool = True, workdir: str | None = None, user: str = "", ) -> object: call: dict[str, object] = {"cmd": cmd, "demux": demux, "workdir": workdir} if user: call["user"] = user self.exec_calls.append(call) exit_code = self._exec_exit_code if cmd == ["test", "-d", "/workspace"]: exit_code = 0 if self._workspace_exists else 1 return type( "_ExecResult", (), {"output": self._exec_output, "exit_code": exit_code}, )() class _ResumeDockerClient: def __init__(self, container: object) -> None: self._container = container self.containers = self def get(self, container_id: str) -> object: _ = container_id if isinstance(self._container, BaseException): raise self._container return self._container class _PositionalOnlyMissingDockerClient: def __init__(self) -> None: self.containers = self def get(self, container_id: str, /) -> object: _ = container_id raise docker.errors.NotFound("missing") class _ResumeContainer: def __init__( self, *, status: str, container_id: str = "container", workspace_exists: bool = False, published_ports: dict[str, list[dict[str, str]] | None] | None = None, ) -> None: self.status = status self.id = container_id self.exec_calls: list[dict[str, object]] = [] self._workspace_exists = workspace_exists self.attrs = {"NetworkSettings": {"Ports": published_ports or {}}} def reload(self) -> None: return def exec_run( self, cmd: list[str], demux: bool = True, workdir: str | None = None, user: str = "", ) -> object: call: dict[str, object] = {"cmd": cmd, "demux": demux, "workdir": workdir} if user: call["user"] = user self.exec_calls.append(call) exit_code = 0 if cmd == ["test", "-d", "/workspace"]: exit_code = 0 if self._workspace_exists else 1 return type( "_ExecResult", (), {"output": (b"", b""), "exit_code": exit_code}, )() class _FakePtySocket: def __init__(self, api: _FakePtyApi, *, initial_chunks: list[bytes] | None = None) -> None: self._api = api self._chunks: queue.Queue[bytes | None] = queue.Queue() self.sent: list[bytes] = [] self.shutdown_calls: list[int] = [] self.closed = False for chunk in initial_chunks or []: self._chunks.put(chunk) def sendall(self, payload: bytes) -> None: self.sent.append(payload) self._api.running = False self._api.exit_code = 0 self._chunks.put(payload) self._chunks.put(None) def close(self) -> None: self.closed = True self._chunks.put(None) def shutdown(self, how: int) -> None: self.shutdown_calls.append(how) class _FakePtyApi: def __init__(self, *, socket: _FakePtySocket | None = None) -> None: self.socket = socket or _FakePtySocket(self) self.running = True self.exit_code: int | None = None self.exec_create_calls: list[dict[str, object]] = [] self.exec_start_calls: list[dict[str, object]] = [] self.exec_inspect_calls: list[str] = [] def exec_create(self, container_id: str, cmd: list[str], **kwargs: object) -> dict[str, str]: self.exec_create_calls.append({"container_id": container_id, "cmd": cmd, **kwargs}) return {"Id": "exec-123"} def exec_start(self, exec_id: str, **kwargs: object) -> _FakePtySocket: self.exec_start_calls.append({"exec_id": exec_id, **kwargs}) return self.socket def exec_inspect(self, exec_id: str) -> dict[str, object]: self.exec_inspect_calls.append(exec_id) return { "Running": self.running, "ExitCode": self.exit_code, } class _FakePtyDockerClient: def __init__(self, api: _FakePtyApi) -> None: self.api = api class _FakePtyContainer: def __init__(self, api: _FakePtyApi) -> None: self.id = "container" self.client = _FakePtyDockerClient(api) self.status = "running" self.exec_calls: list[dict[str, object]] = [] def reload(self) -> None: return def exec_run( self, cmd: list[str], demux: bool = True, workdir: str | None = None, user: str = "", ) -> object: call: dict[str, object] = {"cmd": cmd, "demux": demux, "workdir": workdir} if user: call["user"] = user self.exec_calls.append(call) return type( "_ExecResult", (), {"output": (b"", b""), "exit_code": 0}, )() def _fake_frames_iter(socket: _FakePtySocket, *, tty: bool) -> object: _ = tty while True: chunk = socket._chunks.get(timeout=1) if chunk is None: return yield 1, chunk def _assert_pty_exec_create_call( call: dict[str, object], *, command_suffix: list[str], tty: bool, ) -> None: assert call["container_id"] == "container" assert call["stdin"] is True assert call["stdout"] is True assert call["stderr"] is True assert call["tty"] is tty assert call["workdir"] == "/workspace" cmd = cast(list[str], call["cmd"]) assert cmd[:3] == [ "sh", "-lc", 'mkdir -p "$1" && printf "%s" "$$" > "$2" && shift 2 && exec "$@"', ] assert cmd[3] == "sh" assert cmd[-len(command_suffix) :] == command_suffix def _assert_pty_kill_call(call: dict[str, object]) -> None: assert call["demux"] is True assert call["workdir"] is None cmd = cast(list[str], call["cmd"]) assert cmd[:3] == [ "sh", "-lc", ( 'if [ -f "$1" ]; then ' 'pid="$(cat "$1" 2>/dev/null || true)"; ' 'if [ -n "$pid" ]; then kill -KILL "$pid" >/dev/null 2>&1 || true; fi; ' "fi" ), ] assert cmd[3] == "sh" @pytest.mark.asyncio async def test_docker_exec_timeout_uses_shared_executor(monkeypatch: pytest.MonkeyPatch) -> None: container = _ExecRunContainer() session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) submitted_executors: list[object] = [] loop = asyncio.get_running_loop() def fake_run_in_executor(executor: object, func: object) -> asyncio.Future[object]: _ = func submitted_executors.append(executor) return asyncio.Future() monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) with pytest.raises(ExecTimeoutError): await session._exec_internal("sleep", "10", timeout=0.01) with pytest.raises(ExecTimeoutError): await session._exec_internal("sleep", "20", timeout=0.01) assert submitted_executors == [ docker_sandbox._DOCKER_EXECUTOR, docker_sandbox._DOCKER_EXECUTOR, ] assert container.exec_calls == [ { "cmd": ["sh", "-lc", "pkill -f -- 'sleep 10' >/dev/null 2>&1 || true"], "demux": True, "workdir": None, }, { "cmd": ["sh", "-lc", "pkill -f -- 'sleep 20' >/dev/null 2>&1 || true"], "demux": True, "workdir": None, }, ] @pytest.mark.asyncio async def test_docker_exec_omits_workdir_until_workspace_ready( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ExecRunContainer() session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) loop = asyncio.get_running_loop() def fake_run_in_executor( executor: object, func: Callable[[], object] ) -> asyncio.Future[object]: _ = executor future: asyncio.Future[object] = asyncio.Future() future.set_result(func()) return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) result = await session._exec_internal("find", ".", timeout=0.01) assert result.ok() assert container.exec_calls == [ { "cmd": ["find", "."], "demux": True, "workdir": None, } ] @pytest.mark.asyncio async def test_docker_exec_unknown_exit_code_is_transport_error( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ExecRunContainer( exec_exit_code=None, exec_output=(b"partial stdout", b"partial stderr"), ) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) loop = asyncio.get_running_loop() def fake_run_in_executor( executor: object, func: Callable[[], object] ) -> asyncio.Future[object]: _ = executor future: asyncio.Future[object] = asyncio.Future() future.set_result(func()) return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) with pytest.raises(ExecTransportError) as exc_info: await session._exec_internal("find", ".", timeout=0.01) assert exc_info.value.context == { "command": ("find", "."), "command_str": "find .", "reason": "missing_exit_code", "stdout": "partial stdout", "stderr": "partial stderr", "workdir": None, "retry_safe": True, } assert container.exec_calls == [ { "cmd": ["find", "."], "demux": True, "workdir": None, } ] @pytest.mark.asyncio async def test_docker_exec_uses_manifest_root_as_workdir_after_workspace_ready( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ExecRunContainer() session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) session._workspace_root_ready = True loop = asyncio.get_running_loop() def fake_run_in_executor( executor: object, func: Callable[[], object] ) -> asyncio.Future[object]: _ = executor future: asyncio.Future[object] = asyncio.Future() future.set_result(func()) return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) result = await session._exec_internal("find", ".", timeout=0.01) assert result.ok() assert container.exec_calls == [ { "cmd": ["find", "."], "demux": True, "workdir": "/workspace", } ] @pytest.mark.asyncio async def test_docker_exec_uses_native_docker_user_without_sudo( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ExecRunContainer() session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", ), ) loop = asyncio.get_running_loop() def fake_run_in_executor( executor: object, func: Callable[[], object] ) -> asyncio.Future[object]: _ = executor future: asyncio.Future[object] = asyncio.Future() future.set_result(func()) return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) result = await session.exec("whoami", timeout=0.01, user="sandbox-user") assert result.ok() assert container.exec_calls == [ { "cmd": ["sh", "-lc", "whoami"], "demux": True, "workdir": None, "user": "sandbox-user", } ] @pytest.mark.asyncio async def test_docker_resolve_exposed_port_reads_published_port_mapping() -> None: session = DockerSandboxSession( docker_client=object(), container=_ResumeContainer( status="running", published_ports={ "8765/tcp": [ { "HostIp": "127.0.0.1", "HostPort": "45123", } ] }, ), state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", exposed_ports=(8765,), ), ) endpoint = await session.resolve_exposed_port(8765) assert endpoint.host == "127.0.0.1" assert endpoint.port == 45123 assert endpoint.tls is False @pytest.mark.asyncio async def test_docker_resume_preserves_workspace_readiness_from_state() -> None: client = DockerSandboxClient( docker_client=_ResumeDockerClient(_ResumeContainer(status="running")) ) ready_session = await client.resume( DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ) ) not_ready_session = await client.resume( DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=False, ) ) assert isinstance(ready_session._inner, DockerSandboxSession) assert ready_session._inner._workspace_root_ready is True assert ready_session._inner.should_provision_manifest_accounts_on_resume() is False assert isinstance(not_ready_session._inner, DockerSandboxSession) assert not_ready_session._inner._workspace_root_ready is False assert not_ready_session._inner.should_provision_manifest_accounts_on_resume() is False @pytest.mark.asyncio async def test_docker_resume_resets_workspace_readiness_when_container_is_recreated( monkeypatch: pytest.MonkeyPatch, ) -> None: client = DockerSandboxClient( docker_client=cast(object, _ResumeDockerClient(docker.errors.NotFound("missing"))) ) replacement = _ResumeContainer(status="created", container_id="replacement") create_calls: list[tuple[str, Manifest | None, tuple[int, ...]]] = [] async def _fake_create_container( image: str, *, manifest: Manifest | None = None, exposed_ports: tuple[int, ...] = (), session_id: uuid.UUID | None = None, ) -> object: _ = session_id create_calls.append((image, manifest, exposed_ports)) return replacement monkeypatch.setattr(client, "_create_container", _fake_create_container) resumed = await client.resume( DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="missing", workspace_root_ready=True, exposed_ports=(8765,), ) ) assert isinstance(resumed._inner, DockerSandboxSession) inner = resumed._inner assert inner.state.container_id == "replacement" assert inner.state.workspace_root_ready is False assert inner._workspace_root_ready is False assert inner.should_provision_manifest_accounts_on_resume() is True assert create_calls == [(DEFAULT_PYTHON_SANDBOX_IMAGE, inner.state.manifest, (8765,))] @pytest.mark.asyncio async def test_docker_resume_recovers_workspace_workdir_when_root_already_exists( monkeypatch: pytest.MonkeyPatch, ) -> None: container = _ResumeContainer(status="running", workspace_exists=True) client = DockerSandboxClient(docker_client=_ResumeDockerClient(container)) payload = DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ).model_dump(mode="json") payload.pop("workspace_root_ready") resumed = await client.resume(client.deserialize_session_state(payload)) assert isinstance(resumed._inner, DockerSandboxSession) loop = asyncio.get_running_loop() def fake_run_in_executor( executor: object, func: Callable[[], object] ) -> asyncio.Future[object]: _ = executor future: asyncio.Future[object] = asyncio.Future() future.set_result(func()) return future monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) result = await resumed._inner._exec_internal("find", ".", timeout=0.01) assert result.ok() assert resumed._inner.state.workspace_root_ready is True assert resumed._inner._workspace_root_ready is True assert container.exec_calls == [ { "cmd": ["test", "-d", "/workspace"], "demux": True, "workdir": None, }, { "cmd": ["find", "."], "demux": True, "workdir": "/workspace", }, ] @pytest.mark.asyncio async def test_docker_exists_returns_false_for_missing_container() -> None: session = DockerSandboxSession( docker_client=cast(object, _PositionalOnlyMissingDockerClient()), container=_ResumeContainer(status="running"), state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="missing", ), ) assert await session.exists() is False @pytest.mark.asyncio async def test_docker_pty_exec_write_and_poll(monkeypatch: pytest.MonkeyPatch) -> None: api = _FakePtyApi() api.socket = _FakePtySocket(api, initial_chunks=[b"ready\n"]) container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _fake_frames_iter, ) started = await session.pty_exec_start( "python3", shell=False, tty=True, yield_time_s=0.25, ) assert started.process_id is not None assert started.exit_code is None assert started.output == b"ready\n" assert len(api.exec_create_calls) == 1 _assert_pty_exec_create_call( api.exec_create_calls[0], command_suffix=["python3"], tty=True, ) assert api.exec_start_calls == [ { "exec_id": "exec-123", "socket": True, "tty": True, } ] updated = await session.pty_write_stdin( session_id=started.process_id, chars="hello\n", yield_time_s=0.25, ) assert updated.process_id is None assert updated.exit_code == 0 assert updated.output == b"hello\n" assert api.socket.sent == [b"hello\n"] with pytest.raises(PtySessionNotFoundError): await session.pty_write_stdin(session_id=started.process_id, chars="") @pytest.mark.asyncio async def test_docker_pty_exec_uses_native_docker_user_without_sudo( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _FakePtyApi() container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _fake_frames_iter, ) started = await session.pty_exec_start( "whoami", shell=False, user="sandbox-user", yield_time_s=0, ) assert started.process_id is not None assert len(api.exec_create_calls) == 1 _assert_pty_exec_create_call( api.exec_create_calls[0], command_suffix=["whoami"], tty=False, ) assert api.exec_create_calls[0]["user"] == "sandbox-user" pty_pid_path = cast(list[str], api.exec_create_calls[0]["cmd"])[5] assert container.exec_calls == [ { "cmd": [ "sh", "-lc", docker_sandbox._PREPARE_USER_PTY_PID_SCRIPT, "sh", pty_pid_path, "sandbox-user", ], "demux": True, "workdir": "/workspace", } ] await session.pty_terminate_all() @pytest.mark.asyncio @pytest.mark.parametrize( "sendall_error", [ BrokenPipeError(), OSError(errno.EPIPE, "broken pipe"), ], ) async def test_docker_pty_write_stdin_ignores_closed_socket_errors_and_returns_exit( monkeypatch: pytest.MonkeyPatch, sendall_error: OSError, ) -> None: api = _FakePtyApi() api.socket = _FakePtySocket(api, initial_chunks=[b"ready\n"]) container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _fake_frames_iter, ) started = await session.pty_exec_start( "python3", shell=False, tty=True, yield_time_s=0.25, ) assert started.process_id is not None def _sendall(_payload: bytes) -> None: raise sendall_error api.running = False api.exit_code = 0 api.socket._chunks.put(b"tail\n") api.socket._chunks.put(None) monkeypatch.setattr(api.socket, "sendall", _sendall) updated = await session.pty_write_stdin( session_id=started.process_id, chars="hello\n", yield_time_s=0.25, ) assert updated.process_id is None assert updated.exit_code == 0 assert updated.output == b"tail\n" @pytest.mark.asyncio async def test_docker_pty_non_tty_rejects_stdin_and_stop_cleans_up( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _FakePtyApi() api.socket = _FakePtySocket(api, initial_chunks=[b"stdout\n", b"stderr\n"]) container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _fake_frames_iter, ) started = await session.pty_exec_start( "sh", "-c", "sleep 30", shell=False, tty=False, yield_time_s=0.25, ) assert started.process_id is not None assert started.exit_code is None assert started.output == b"stdout\nstderr\n" assert api.socket.shutdown_calls == [socket.SHUT_WR] with pytest.raises(RuntimeError, match="stdin is not available for this process"): await session.pty_write_stdin(session_id=started.process_id, chars="hello") await session.stop() assert api.socket.closed is True assert len(container.exec_calls) == 2 _assert_pty_kill_call(container.exec_calls[0]) assert container.exec_calls[1]["cmd"] == [ "rm", "-rf", "--", cast(list[str], api.exec_create_calls[0]["cmd"])[5], ] with pytest.raises(PtySessionNotFoundError): await session.pty_write_stdin(session_id=started.process_id, chars="") @pytest.mark.asyncio @pytest.mark.parametrize("operation", ["exec_create", "exec_start"]) async def test_docker_pty_exec_start_times_out_blocking_docker_startup( monkeypatch: pytest.MonkeyPatch, operation: str, ) -> None: api = _FakePtyApi() container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) original = getattr(api, operation) def _delayed_operation(*args: object, **kwargs: object) -> object: time.sleep(0.2) return original(*args, **kwargs) monkeypatch.setattr(api, operation, _delayed_operation) with pytest.raises(ExecTimeoutError): await session.pty_exec_start( "python3", shell=False, tty=True, timeout=0.01, yield_time_s=0.01, ) assert len(container.exec_calls) == 2 _assert_pty_kill_call(container.exec_calls[0]) assert container.exec_calls[1]["cmd"] == [ "rm", "-rf", "--", cast(list[str], container.exec_calls[0]["cmd"])[4], ] @pytest.mark.asyncio async def test_docker_pty_exec_returns_exit_code_for_fast_exit( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _FakePtyApi() api.running = False api.exit_code = 0 api.socket = _FakePtySocket(api, initial_chunks=[b"done\n"]) api.socket._chunks.put(None) container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _fake_frames_iter, ) started = await session.pty_exec_start( "sh", "-c", "printf done", shell=False, tty=False, yield_time_s=0.25, ) assert started.process_id is None assert started.exit_code == 0 assert started.output == b"done\n" assert container.exec_calls == [ { "cmd": [ "rm", "-rf", "--", cast(list[str], api.exec_create_calls[0]["cmd"])[5], ], "demux": True, "workdir": "/workspace", } ] @pytest.mark.asyncio async def test_docker_pty_exec_waits_for_socket_drain_after_process_exit( monkeypatch: pytest.MonkeyPatch, ) -> None: api = _FakePtyApi() api.running = False api.exit_code = 0 api.socket = _FakePtySocket(api) container = _FakePtyContainer(api) session = DockerSandboxSession( docker_client=object(), container=container, state=DockerSandboxSessionState( manifest=Manifest(root="/workspace"), snapshot=NoopSnapshot(id="snapshot"), image=DEFAULT_PYTHON_SANDBOX_IMAGE, container_id="container", workspace_root_ready=True, ), ) release_output = threading.Event() original_exec_inspect = api.exec_inspect def _exec_inspect(exec_id: str) -> dict[str, object]: release_output.set() return original_exec_inspect(exec_id) def _delayed_frames_iter(socket: _FakePtySocket, *, tty: bool) -> object: _ = tty assert release_output.wait(timeout=1) yield 1, b"done\n" monkeypatch.setattr(api, "exec_inspect", _exec_inspect) monkeypatch.setattr( "agents.sandbox.sandboxes.docker.docker_socket.frames_iter", _delayed_frames_iter, ) started = await session.pty_exec_start( "sh", "-c", "printf done", shell=False, tty=False, yield_time_s=0.25, ) assert started.process_id is None assert started.exit_code == 0 assert started.output == b"done\n" assert container.exec_calls == [ { "cmd": [ "rm", "-rf", "--", cast(list[str], api.exec_create_calls[0]["cmd"])[5], ], "demux": True, "workdir": "/workspace", } ]