- Introduced a static method to verify if the Docker storage driver supports the --storage-opt size= option. - Enhanced resource argument handling in DockerEnvironment to conditionally include storage options based on the support check. - Added caching for the support check result to optimize performance across instances.
237 lines
8.7 KiB
Python
237 lines
8.7 KiB
Python
"""Docker execution environment wrapping mini-swe-agent's DockerEnvironment.
|
|
|
|
Adds security hardening, configurable resource limits (CPU, memory, disk),
|
|
and optional filesystem persistence via `docker commit`/`docker create --image`.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Optional
|
|
|
|
from tools.environments.base import BaseEnvironment
|
|
from tools.interrupt import is_interrupted
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
# Security flags applied to every container
|
|
_SECURITY_ARGS = [
|
|
"--read-only",
|
|
"--cap-drop", "ALL",
|
|
"--security-opt", "no-new-privileges",
|
|
"--pids-limit", "256",
|
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m",
|
|
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
|
|
"--tmpfs", "/run:rw,noexec,nosuid,size=64m",
|
|
]
|
|
|
|
|
|
_storage_opt_ok: Optional[bool] = None # cached result across instances
|
|
|
|
|
|
class DockerEnvironment(BaseEnvironment):
|
|
"""Hardened Docker container execution with resource limits and persistence.
|
|
|
|
Security: read-only root, all capabilities dropped, no privilege escalation,
|
|
PID limits, tmpfs for writable scratch. Writable overlay for /home and cwd
|
|
via tmpfs or bind mounts.
|
|
|
|
Persistence: when enabled, `docker commit` saves the container state on
|
|
cleanup, and the next creation restores from that image.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
image: str,
|
|
cwd: str = "/root",
|
|
timeout: int = 60,
|
|
cpu: float = 0,
|
|
memory: int = 0,
|
|
disk: int = 0,
|
|
persistent_filesystem: bool = False,
|
|
task_id: str = "default",
|
|
network: bool = True,
|
|
):
|
|
if cwd == "~":
|
|
cwd = "/root"
|
|
super().__init__(cwd=cwd, timeout=timeout)
|
|
self._base_image = image
|
|
self._persistent = persistent_filesystem
|
|
self._task_id = task_id
|
|
self._container_id: Optional[str] = None
|
|
|
|
from minisweagent.environments.docker import DockerEnvironment as _Docker
|
|
|
|
# Build resource limit args
|
|
resource_args = []
|
|
if cpu > 0:
|
|
resource_args.extend(["--cpus", str(cpu)])
|
|
if memory > 0:
|
|
resource_args.extend(["--memory", f"{memory}m"])
|
|
if disk > 0 and sys.platform != "darwin" and self._storage_opt_supported():
|
|
resource_args.extend(["--storage-opt", f"size={disk}m"])
|
|
if not network:
|
|
resource_args.append("--network=none")
|
|
|
|
# Persistent workspace via bind mounts from a configurable host directory
|
|
# (TERMINAL_SANDBOX_DIR, default ~/.hermes/sandboxes/). Non-persistent
|
|
# mode uses tmpfs (ephemeral, fast, gone on cleanup).
|
|
from tools.environments.base import get_sandbox_dir
|
|
|
|
self._workspace_dir: Optional[str] = None
|
|
self._home_dir: Optional[str] = None
|
|
if self._persistent:
|
|
sandbox = get_sandbox_dir() / "docker" / task_id
|
|
self._workspace_dir = str(sandbox / "workspace")
|
|
self._home_dir = str(sandbox / "home")
|
|
os.makedirs(self._workspace_dir, exist_ok=True)
|
|
os.makedirs(self._home_dir, exist_ok=True)
|
|
writable_args = [
|
|
"-v", f"{self._workspace_dir}:/workspace",
|
|
"-v", f"{self._home_dir}:/root",
|
|
]
|
|
else:
|
|
writable_args = [
|
|
"--tmpfs", "/workspace:rw,exec,size=10g",
|
|
"--tmpfs", "/home:rw,exec,size=1g",
|
|
"--tmpfs", "/root:rw,exec,size=1g",
|
|
]
|
|
|
|
# All containers get full security hardening (read-only root + writable
|
|
# mounts for the workspace). Persistence uses Docker volumes, not
|
|
# filesystem layer commits, so --read-only is always safe.
|
|
all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args
|
|
|
|
self._inner = _Docker(
|
|
image=image, cwd=cwd, timeout=timeout,
|
|
run_args=all_run_args,
|
|
)
|
|
self._container_id = self._inner.container_id
|
|
|
|
@staticmethod
|
|
def _storage_opt_supported() -> bool:
|
|
"""Check if Docker's storage driver supports --storage-opt size=.
|
|
|
|
Only overlay2 on XFS with pquota supports per-container disk quotas.
|
|
Ubuntu (and most distros) default to ext4, where this flag errors out.
|
|
"""
|
|
global _storage_opt_ok
|
|
if _storage_opt_ok is not None:
|
|
return _storage_opt_ok
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "info", "--format", "{{.Driver}}"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
driver = result.stdout.strip().lower()
|
|
if driver != "overlay2":
|
|
_storage_opt_ok = False
|
|
return False
|
|
# overlay2 only supports storage-opt on XFS with pquota.
|
|
# Probe by attempting a dry-ish run — the fastest reliable check.
|
|
probe = subprocess.run(
|
|
["docker", "create", "--storage-opt", "size=1m", "hello-world"],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if probe.returncode == 0:
|
|
# Clean up the created container
|
|
container_id = probe.stdout.strip()
|
|
if container_id:
|
|
subprocess.run(["docker", "rm", container_id],
|
|
capture_output=True, timeout=5)
|
|
_storage_opt_ok = True
|
|
else:
|
|
_storage_opt_ok = False
|
|
except Exception:
|
|
_storage_opt_ok = False
|
|
logger.debug("Docker --storage-opt support: %s", _storage_opt_ok)
|
|
return _storage_opt_ok
|
|
|
|
def execute(self, command: str, cwd: str = "", *,
|
|
timeout: int | None = None,
|
|
stdin_data: str | None = None) -> dict:
|
|
exec_command = self._prepare_command(command)
|
|
work_dir = cwd or self.cwd
|
|
effective_timeout = timeout or self.timeout
|
|
|
|
# docker exec -w doesn't expand ~, so prepend a cd into the command
|
|
if work_dir == "~" or work_dir.startswith("~/"):
|
|
exec_command = f"cd {work_dir} && {exec_command}"
|
|
work_dir = "/"
|
|
|
|
assert self._inner.container_id, "Container not started"
|
|
cmd = [self._inner.config.executable, "exec"]
|
|
if stdin_data is not None:
|
|
cmd.append("-i")
|
|
cmd.extend(["-w", work_dir])
|
|
for key in self._inner.config.forward_env:
|
|
if (value := os.getenv(key)) is not None:
|
|
cmd.extend(["-e", f"{key}={value}"])
|
|
for key, value in self._inner.config.env.items():
|
|
cmd.extend(["-e", f"{key}={value}"])
|
|
cmd.extend([self._inner.container_id, "bash", "-lc", exec_command])
|
|
|
|
try:
|
|
_output_chunks = []
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
stdin=subprocess.PIPE if stdin_data else subprocess.DEVNULL,
|
|
text=True,
|
|
)
|
|
if stdin_data:
|
|
try:
|
|
proc.stdin.write(stdin_data)
|
|
proc.stdin.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def _drain():
|
|
try:
|
|
for line in proc.stdout:
|
|
_output_chunks.append(line)
|
|
except Exception:
|
|
pass
|
|
|
|
reader = threading.Thread(target=_drain, daemon=True)
|
|
reader.start()
|
|
deadline = time.monotonic() + effective_timeout
|
|
|
|
while proc.poll() is None:
|
|
if is_interrupted():
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
reader.join(timeout=2)
|
|
return {
|
|
"output": "".join(_output_chunks) + "\n[Command interrupted]",
|
|
"returncode": 130,
|
|
}
|
|
if time.monotonic() > deadline:
|
|
proc.kill()
|
|
reader.join(timeout=2)
|
|
return self._timeout_result(effective_timeout)
|
|
time.sleep(0.2)
|
|
|
|
reader.join(timeout=5)
|
|
return {"output": "".join(_output_chunks), "returncode": proc.returncode}
|
|
except Exception as e:
|
|
return {"output": f"Docker execution error: {e}", "returncode": 1}
|
|
|
|
def cleanup(self):
|
|
"""Stop and remove the container. Bind-mount dirs persist if persistent=True."""
|
|
self._inner.cleanup()
|
|
|
|
if not self._persistent:
|
|
import shutil
|
|
for d in (self._workspace_dir, self._home_dir):
|
|
if d:
|
|
shutil.rmtree(d, ignore_errors=True)
|