import subprocess import os import json import shlex import tempfile import config class Sandbox: def __init__(self, container_id: str): self.container_id = container_id self.patch_applied: bool = False self.patch_output: str = "" @classmethod def create(cls, task) -> "Sandbox": """Create container, clone repo, create git checkpoint.""" cmd = [ "docker", "run", "-d", f"--network={config.SANDBOX_NETWORK}", f"--memory={config.SANDBOX_MEMORY_LIMIT}", "-w", "/workspace", ] # Mount host path for file:// repo URLs so git clone works inside container repo_url = task.repo_url if repo_url.startswith("file://"): host_path = repo_url[7:] # strip file:// cmd.extend(["-v", f"{host_path}:{host_path}:ro"]) cmd.extend([config.SANDBOX_IMAGE, "sleep", "infinity"]) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"Failed to create container: {result.stderr.strip()}") container_id = result.stdout.strip() sandbox = cls(container_id) try: # Install git in the container (apt-get needs update first on slim images) sandbox.exec("apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1 || apk add git 2>/dev/null || true", timeout=120) # Allow cloning from mounted volumes with different ownership sandbox.exec("git config --global safe.directory '*'", timeout=5) # Install pytest (needed by most tasks' success_cmd) sandbox.exec("pip install -q pytest >/dev/null 2>&1 || true", timeout=60) # Clone repo repo_ref = task.repo_ref or "main" rc, stdout, stderr = sandbox.exec( f"git clone --depth=1 -b {shlex.quote(repo_ref)} {shlex.quote(repo_url)} .", timeout=120, ) if rc != 0: raise RuntimeError(f"Failed to clone repo: {stderr.strip()}") # Create checkpoint tag rc, _, stderr = sandbox.exec("git tag arena-checkpoint", timeout=30) if rc != 0: raise RuntimeError(f"Failed to create git checkpoint: {stderr.strip()}") except Exception: sandbox.destroy() raise return sandbox @classmethod def create_judge_sandbox(cls, task, diff_text: str) -> "Sandbox": """Create an isolated judge container: fresh clone + apply diff. This container is separate from the agent container so the agent cannot manipulate pytest, test files, or git history seen by the judge. """ cmd = [ "docker", "run", "-d", f"--network={config.SANDBOX_NETWORK}", f"--memory={config.SANDBOX_MEMORY_LIMIT}", "-w", "/workspace", ] repo_url = task.repo_url if repo_url.startswith("file://"): host_path = repo_url[7:] cmd.extend(["-v", f"{host_path}:{host_path}:ro"]) cmd.extend([config.SANDBOX_IMAGE, "sleep", "infinity"]) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"Failed to create judge container: {result.stderr.strip()}") container_id = result.stdout.strip() sandbox = cls(container_id) try: sandbox.exec("apt-get update -qq && apt-get install -y -qq git patch >/dev/null 2>&1 || apk add git patch 2>/dev/null || true", timeout=120) sandbox.exec("git config --global safe.directory '*'", timeout=5) sandbox.exec("pip install -q pytest >/dev/null 2>&1 || true", timeout=60) repo_ref = task.repo_ref or "main" rc, stdout, stderr = sandbox.exec( f"git clone --depth=1 -b {shlex.quote(repo_ref)} {shlex.quote(repo_url)} .", timeout=120, ) if rc != 0: raise RuntimeError(f"Judge container: failed to clone repo: {stderr.strip()}") # Apply the agent's diff so the judge sees exactly the agent's changes # in a fresh environment the agent has never touched. if diff_text and diff_text.strip(): with tempfile.NamedTemporaryFile( mode="w", suffix=".patch", delete=False ) as tmp: tmp.write(diff_text) tmp_path = tmp.name try: result2 = subprocess.run( ["docker", "cp", tmp_path, f"{container_id}:/tmp/agent.patch"], capture_output=True, text=True, timeout=10, ) if result2.returncode != 0: raise RuntimeError(f"Judge container: failed to copy patch: {result2.stderr.strip()}") rc2, out2, err2 = sandbox.exec( "git apply --whitespace=fix /tmp/agent.patch 2>&1 || patch -p1 < /tmp/agent.patch 2>&1", timeout=30, ) sandbox.patch_applied = rc2 == 0 sandbox.patch_output = (out2 + err2).strip()[:500] if rc2 != 0 else "" finally: os.unlink(tmp_path) except Exception: sandbox.destroy() raise return sandbox def exec(self, cmd: str, timeout: int = 300) -> tuple[int, str, str]: """Execute command in container. Returns (returncode, stdout, stderr).""" try: result = subprocess.run( ["docker", "exec", self.container_id, "sh", "-c", cmd], capture_output=True, text=True, timeout=timeout, ) return result.returncode, result.stdout, result.stderr except subprocess.TimeoutExpired: return 124, "", f"Command timed out after {timeout}s" except Exception as e: return -1, "", str(e) def get_diff(self) -> str: """Get git diff from checkpoint to current state (committed + uncommitted).""" # Committed changes rc, committed, _ = self.exec("git diff arena-checkpoint..HEAD", timeout=30) # Working tree (uncommitted) changes rc2, uncommitted, _ = self.exec("git diff arena-checkpoint", timeout=30) # Return whichever has more content (uncommitted includes committed + staged) if len(uncommitted) >= len(committed): return uncommitted return committed def reset(self): """Reset to checkpoint.""" self.exec("git checkout arena-checkpoint -- . && git clean -fd", timeout=60) def destroy(self): """Remove container.""" try: subprocess.run( ["docker", "rm", "-f", self.container_id], capture_output=True, text=True, timeout=30, ) except Exception: pass # Best-effort cleanup def copy_file_out(self, container_path: str, local_path: str): """Copy file from container to host.""" result = subprocess.run( ["docker", "cp", f"{self.container_id}:{container_path}", local_path], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"docker cp failed: {result.stderr.strip()}")