Files
openai--openai-agents-python/tests/sandbox/test_docker.py
T
2026-07-13 12:39:17 +08:00

3265 lines
103 KiB
Python

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 <n>`` 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",
}
]