Files
docker-agent-sandbox/docker_agent_sandbox/sandbox.py
T
Matte23 ea04d44615
CI / unit-tests (push) Successful in 40s
CI / integration-tests (push) Successful in 1m2s
CI / publish (push) Failing after 18s
feat: Make user configurable
2026-06-17 12:04:16 +02:00

297 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""sandbox.py Docker container lifecycle and execution environment."""
from __future__ import annotations
import io
import select as _select
import tarfile
from pathlib import Path
from typing import TYPE_CHECKING
import docker
import docker.errors
from loguru import logger
_original_poll = getattr(_select, "poll", None)
if TYPE_CHECKING:
import docker.models.containers
class DockerSandbox:
"""
Manages a single long-running Docker container.
All container configuration (volumes, ports, environment variables,
capabilities) is supplied by the caller.
"""
def __init__(
self,
container_name: str,
image: str = "docker-agent-sandbox",
dockerfile_dir: str | None = None,
volumes: dict | None = None,
ports: dict | None = None,
environment: dict[str, str] | None = None,
working_dir: str | None = None,
# TODO(security): default network_mode is "bridge", giving containers unrestricted
# outbound network access. For an untrusted-code sandbox this should default to
# "none" so callers must explicitly opt in to network access.
network_mode: str = "bridge",
cap_drop: list[str] | None = None,
cap_add: list[str] | None = None,
security_opt: list[str] | None = None,
cpu_limit: float = 8,
memory_limit: str = "16g",
command: str | None = None,
uid: int | None = None,
gid: int | None = None,
) -> None:
self.container_name = container_name
self._image = image
self._dockerfile_dir = dockerfile_dir
self._volumes = volumes or {}
self._ports = ports or {}
self._environment = environment or {}
self._working_dir = working_dir
self._network_mode = network_mode
self._cap_drop = cap_drop
self._cap_add = cap_add
self._security_opt = security_opt
self._nano_cpus = int(cpu_limit * 1e9)
self._memory_limit = memory_limit
self._command = command
self._uid = uid
self._gid = gid
self._client: docker.DockerClient = docker.from_env()
self._container: docker.models.containers.Container | None = None
@property
def _user(self) -> str | None:
"""Docker ``user`` spec ("uid" or "uid:gid"), or None to use the image default."""
if self._uid is None and self._gid is None:
return None
uid = "" if self._uid is None else str(self._uid)
if self._gid is None:
return uid
return f"{uid}:{self._gid}"
def __enter__(self) -> "DockerSandbox":
return self
def __exit__(self, *_: object) -> None:
self.stop()
self._client.close()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def build_image_if_missing(self) -> None:
"""Build the Docker image if it is not already present locally."""
try:
self._client.images.get(self._image)
logger.info("Image {!r} already present, skipping build.", self._image)
return
except docker.errors.ImageNotFound:
pass
if self._dockerfile_dir is None:
raise ValueError("dockerfile_dir must be provided to build the image")
logger.info("Building image {!r} from {}", self._image, self._dockerfile_dir)
_, logs = self._client.images.build(
path=self._dockerfile_dir,
tag=self._image,
rm=True,
)
for entry in logs:
line = entry.get("stream", "").rstrip()
if line:
logger.debug(" {}", line)
logger.success("Image {!r} built successfully.", self._image)
def start(self) -> None:
"""
Start the container.
Any existing container with the same name is removed first so that
re-runs always start from a clean state.
"""
try:
old = self._client.containers.get(self.container_name)
old.remove(force=True)
except docker.errors.NotFound:
pass
run_kwargs: dict = dict(
name=self.container_name,
detach=True,
volumes=self._volumes,
environment=self._environment,
ports=self._ports,
network_mode=self._network_mode,
)
if self._working_dir is not None:
run_kwargs["working_dir"] = self._working_dir
if self._cap_drop is not None:
run_kwargs["cap_drop"] = self._cap_drop
if self._cap_add is not None:
run_kwargs["cap_add"] = self._cap_add
if self._security_opt is not None:
run_kwargs["security_opt"] = self._security_opt
run_kwargs["nano_cpus"] = self._nano_cpus
run_kwargs["mem_limit"] = self._memory_limit
if self._user is not None:
run_kwargs["user"] = self._user
try:
self._container = self._client.containers.run(
self._image, self._command, **run_kwargs
)
except docker.errors.ImageNotFound:
raise RuntimeError(
f"Image {self._image!r} not found locally. "
"Call build_image_if_missing() first, or pull/build the image manually."
) from None
self._container.reload()
logger.info(
"Container {!r} started (id={}).",
self.container_name,
self._container.short_id,
)
def stop(self) -> None:
"""Remove the container."""
if self._container is not None:
self._container.remove(force=True)
logger.info("Container {!r} stopped.", self.container_name)
self._container = None
# ------------------------------------------------------------------
# Port inspection
# ------------------------------------------------------------------
def get_host_port(self, container_port: str) -> str:
"""
Return the host port Docker mapped to *container_port*.
*container_port* should include the protocol, e.g. ``"8080/tcp"``.
Raises ``RuntimeError`` if the container is not running or the port
is not exposed.
"""
if self._container is None:
raise RuntimeError("Sandbox container is not running.")
mappings = self._container.ports.get(container_port)
if not mappings:
raise RuntimeError(
f"Port {container_port!r} is not mapped on container {self.container_name!r}"
)
return mappings[0]["HostPort"]
# ------------------------------------------------------------------
# File I/O
# ------------------------------------------------------------------
def _resolve_path(self, path: str) -> Path:
"""Resolve *path* against working_dir if relative."""
p = Path(path)
if not p.is_absolute() and self._working_dir is not None:
p = Path(self._working_dir) / p
return p
def read_file(self, path: str) -> bytes:
"""
Read the file at *path* from the container using ``get_archive``.
Returns the raw file bytes. Raises ``FileNotFoundError`` if the path
does not exist and ``RuntimeError`` if the container is not running.
"""
if self._container is None:
raise RuntimeError("Sandbox container is not running.")
p = self._resolve_path(path)
try:
stream, _ = self._container.get_archive(str(p))
except docker.errors.NotFound:
raise FileNotFoundError(f"No such file in container: {path!r}") from None
buf = io.BytesIO()
for chunk in stream:
buf.write(chunk)
buf.seek(0)
with tarfile.open(fileobj=buf) as tar:
member = tar.getmembers()[0]
f = tar.extractfile(member)
if f is None:
raise IsADirectoryError(f"{path!r} is a directory, not a file")
return f.read()
def write_file(self, path: str, content: str) -> None:
"""
Write *content* to *path* inside the container using ``put_archive``.
The tar archive API avoids all shell-escaping concerns. Parent
directories are created automatically via a preceding ``mkdir -p``.
"""
if self._container is None:
raise RuntimeError("Sandbox container is not running.")
p = self._resolve_path(path)
encoded = content.encode("utf-8")
self._container.exec_run(["mkdir", "-p", str(p.parent)])
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name=p.name)
info.size = len(encoded)
info.mode = 0o644
tar.addfile(info, io.BytesIO(encoded))
buf.seek(0)
self._container.put_archive(str(p.parent), buf)
# ------------------------------------------------------------------
# Command execution
# ------------------------------------------------------------------
def exec(
self, command: str, timeout: int = 120, kill_grace: int = 5
) -> tuple[int, str]:
if self._container is None:
return 1, "Sandbox container is not running."
wrapped = [
"timeout",
"--kill-after",
f"{kill_grace}s",
f"{timeout}s",
"bash",
"-c",
command,
]
kwargs = {}
if self._working_dir is not None:
kwargs["workdir"] = self._working_dir
try:
exit_code, output = self._container.exec_run(
wrapped,
stdout=True,
stderr=True,
demux=False,
**kwargs,
)
text = (output or b"").decode("utf-8", errors="replace")
if exit_code in (124, 137):
return exit_code, text + f"\n[command timed out after {timeout}s]"
return exit_code, text
except Exception as exc:
return 1, f"Error running command in container: {exc}"