| """Docker-based sandbox backend for isolated execution. |
| |
| Uses ``subprocess`` to manage a single container, avoiding extra dependencies. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import shlex |
| import subprocess |
| import tempfile |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| class DockerSandboxBackend: |
| """A sandbox backend backed by a Docker container for process isolation.""" |
|
|
| def __init__( |
| self, |
| image: str = "python:3.11-slim", |
| timeout_seconds: int = 20, |
| ) -> None: |
| self._image = image |
| self._timeout = timeout_seconds |
| self._container_id: str | None = None |
| self._workspace_dir = "/workspace" |
| self._start_container() |
|
|
| def _start_container(self) -> None: |
| result = subprocess.run( |
| [ |
| "docker", "run", "-d", "--rm", |
| "-w", "/workspace", |
| self._image, |
| "tail", "-f", "/dev/null", |
| ], |
| capture_output=True, text=True, timeout=30, |
| check=False, |
| ) |
| if result.returncode != 0: |
| stderr = result.stderr.strip() |
| if "docker" in stderr.lower() and ("not found" in stderr.lower() or "daemon" in stderr.lower()): |
| raise RuntimeError( |
| "Docker is not available. Install Docker or set DEEPAGENT_SANDBOX_PROVIDER=local." |
| ) |
| raise RuntimeError(f"Failed to start Docker container: {stderr}") |
| self._container_id = result.stdout.strip() |
| self._exec("mkdir", "-p", self._workspace_dir) |
|
|
| def write(self, path: str, content: str) -> Any: |
| return self._copy_to(path, content.encode("utf-8")) |
|
|
| def read(self, path: str) -> Any: |
| data = self._copy_from(path) |
| content = data.decode("utf-8") if isinstance(data, bytes) else str(data) |
| return type("ReadResult", (), {"error": None, "file_data": {"content": content}})() |
|
|
| def ls(self, path: str) -> Any: |
| output = self._exec_output("find", path.rstrip("/"), "-maxdepth", "1", "-printf", "%p\t%y\n") |
| entries = [] |
| for line in output.splitlines(): |
| if not line.strip(): |
| continue |
| parts = line.split("\t") |
| if len(parts) < 2: |
| continue |
| filepath, kind = parts[0], parts[1] |
| if filepath == path.rstrip("/"): |
| continue |
| entries.append({"path": filepath, "is_dir": kind == "d"}) |
| return type("LsResult", (), {"error": None, "entries": entries})() |
|
|
| def execute(self, command: str, timeout: int | None = None) -> Any: |
| t = timeout or self._timeout |
| result = subprocess.run( |
| ["docker", "exec", self._container_id, "sh", "-c", command], |
| capture_output=True, text=True, timeout=t, |
| check=False, |
| ) |
| output = result.stdout |
| if result.stderr: |
| output = f"{output}\n{result.stderr}" |
| return type("ExecResult", (), {"error": None, "output": output, "exit_code": result.returncode})() |
|
|
| def glob(self, pattern: str) -> Any: |
| find_pattern = "/".join( |
| part.replace("**", ".").replace("*", "[^/]*") if part != "**" else part |
| for part in pattern.split("/") |
| ) |
| output = self._exec_output("find", "/", "-path", pattern, "-not", "-type", "d") |
| matches = [] |
| for line in output.splitlines(): |
| line = line.strip() |
| if line: |
| matches.append({"path": line, "is_dir": False}) |
| return type("GlobResult", (), {"error": None, "matches": matches})() |
|
|
| def download_files(self, paths: list[str]) -> list[Any]: |
| results = [] |
| for path in paths: |
| try: |
| data = self._copy_from(path) |
| results.append(type("DownloadResult", (), {"error": None, "path": path, "content": data})()) |
| except Exception as exc: |
| results.append(type("DownloadResult", (), {"error": str(exc), "path": path, "content": b""})()) |
| return results |
|
|
| def upload_files(self, files: list[tuple[str, bytes]]) -> list[Any]: |
| results = [] |
| for path, content in files: |
| try: |
| self._copy_to(path, content) |
| results.append(type("UploadResult", (), {"error": None, "path": path})()) |
| except Exception as exc: |
| results.append(type("UploadResult", (), {"error": str(exc), "path": path})()) |
| return results |
|
|
| def stop(self) -> None: |
| if self._container_id: |
| subprocess.run( |
| ["docker", "rm", "-f", self._container_id], |
| capture_output=True, timeout=10, check=False, |
| ) |
| self._container_id = None |
|
|
| def delete(self) -> None: |
| self.stop() |
|
|
| def _exec_output(self, *cmd: str) -> str: |
| result = subprocess.run( |
| ["docker", "exec", self._container_id, *cmd], |
| capture_output=True, text=True, timeout=self._timeout, |
| check=False, |
| ) |
| return result.stdout |
|
|
| def _exec(self, *cmd: str) -> None: |
| result = subprocess.run( |
| ["docker", "exec", self._container_id, *cmd], |
| capture_output=True, text=True, timeout=self._timeout, |
| check=False, |
| ) |
| if result.returncode != 0: |
| raise RuntimeError(f"Docker exec failed: {result.stderr.strip()}") |
|
|
| def _copy_to(self, container_path: str, content: bytes) -> Any: |
| with tempfile.NamedTemporaryFile(delete=False) as tmp: |
| tmp.write(content) |
| tmp_path = tmp.name |
| try: |
| result = subprocess.run( |
| ["docker", "cp", tmp_path, f"{self._container_id}:{shlex.quote(container_path)}"], |
| capture_output=True, text=True, timeout=self._timeout, |
| check=False, |
| ) |
| finally: |
| Path(tmp_path).unlink(missing_ok=True) |
| if result.returncode != 0: |
| raise RuntimeError(f"docker cp failed: {result.stderr.strip()}") |
| return type("WriteResult", (), {"error": None})() |
|
|
| def _copy_from(self, container_path: str) -> bytes: |
| with tempfile.NamedTemporaryFile(delete=False) as tmp: |
| tmp_path = tmp.name |
| try: |
| result = subprocess.run( |
| ["docker", "cp", f"{self._container_id}:{shlex.quote(container_path)}", tmp_path], |
| capture_output=True, text=True, timeout=self._timeout, |
| check=False, |
| ) |
| if result.returncode != 0: |
| raise RuntimeError(f"docker cp failed: {result.stderr.strip()}") |
| return Path(tmp_path).read_bytes() |
| finally: |
| Path(tmp_path).unlink(missing_ok=True) |
|
|
| def _workspace_dir(self) -> str: |
| return self._workspace_dir |
|
|