On macOS, Docker Desktop installs the CLI to /usr/local/bin/docker, but when Hermes runs as a gateway service (launchd) or in other non-login contexts, /usr/local/bin is often not in PATH. This causes the Docker requirements check to fail with 'No such file or directory: docker' even though docker works fine from the user's terminal. Add find_docker() helper that uses shutil.which() first, then probes common Docker Desktop install paths on macOS (/usr/local/bin, /opt/homebrew/bin, Docker.app bundle). The resolved path is cached and passed to mini-swe-agent via its 'executable' parameter. - tools/environments/docker.py: add find_docker(), use it in _storage_opt_supported() and pass to _Docker(executable=...) - tools/terminal_tool.py: use find_docker() in requirements check - tests/tools/test_docker_find.py: 4 tests (PATH, fallback, not found, cache) 2877 tests pass.
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Docker execution environment wrapping mini-swe-agent's DockerEnvironment.
|
|
|
|
Adds security hardening (cap-drop ALL, no-new-privileges, PID limits),
|
|
configurable resource limits (CPU, memory, disk), and optional filesystem
|
|
persistence via bind mounts.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
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__)
|
|
|
|
|
|
# Common Docker Desktop install paths checked when 'docker' is not in PATH.
|
|
# macOS Intel: /usr/local/bin, macOS Apple Silicon (Homebrew): /opt/homebrew/bin,
|
|
# Docker Desktop app bundle: /Applications/Docker.app/Contents/Resources/bin
|
|
_DOCKER_SEARCH_PATHS = [
|
|
"/usr/local/bin/docker",
|
|
"/opt/homebrew/bin/docker",
|
|
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
|
]
|
|
|
|
_docker_executable: Optional[str] = None # resolved once, cached
|
|
|
|
|
|
def find_docker() -> Optional[str]:
|
|
"""Locate the docker CLI binary.
|
|
|
|
Checks ``shutil.which`` first (respects PATH), then probes well-known
|
|
install locations on macOS where Docker Desktop may not be in PATH
|
|
(e.g. when running as a gateway service via launchd).
|
|
|
|
Returns the absolute path, or ``None`` if docker cannot be found.
|
|
"""
|
|
global _docker_executable
|
|
if _docker_executable is not None:
|
|
return _docker_executable
|
|
|
|
found = shutil.which("docker")
|
|
if found:
|
|
_docker_executable = found
|
|
return found
|
|
|
|
for path in _DOCKER_SEARCH_PATHS:
|
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
_docker_executable = path
|
|
logger.info("Found docker at non-PATH location: %s", path)
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
# Security flags applied to every container.
|
|
# The container itself is the security boundary (isolated from host).
|
|
# We drop all capabilities then add back the minimum needed:
|
|
# DAC_OVERRIDE - root can write to bind-mounted dirs owned by host user
|
|
# CHOWN/FOWNER - package managers (pip, npm, apt) need to set file ownership
|
|
# Block privilege escalation and limit PIDs.
|
|
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
|
|
_SECURITY_ARGS = [
|
|
"--cap-drop", "ALL",
|
|
"--cap-add", "DAC_OVERRIDE",
|
|
"--cap-add", "CHOWN",
|
|
"--cap-add", "FOWNER",
|
|
"--security-opt", "no-new-privileges",
|
|
"--pids-limit", "256",
|
|
"--tmpfs", "/tmp:rw,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: all capabilities dropped, no privilege escalation, PID limits,
|
|
size-limited tmpfs for scratch dirs. The container itself is the security
|
|
boundary — the filesystem inside is writable so agents can install packages
|
|
(pip, npm, apt) as needed. Writable workspace via tmpfs or bind mounts.
|
|
|
|
Persistence: when enabled, bind mounts preserve /workspace and /root
|
|
across container restarts.
|
|
"""
|
|
|
|
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",
|
|
volumes: list = None,
|
|
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
|
|
logger.info(f"DockerEnvironment volumes: {volumes}")
|
|
# Ensure volumes is a list (config.yaml could be malformed)
|
|
if volumes is not None and not isinstance(volumes, list):
|
|
logger.warning(f"docker_volumes config is not a list: {volumes!r}")
|
|
volumes = []
|
|
|
|
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":
|
|
if self._storage_opt_supported():
|
|
resource_args.extend(["--storage-opt", f"size={disk}m"])
|
|
else:
|
|
logger.warning(
|
|
"Docker storage driver does not support per-container disk limits "
|
|
"(requires overlay2 on XFS with pquota). Container will run without disk quota."
|
|
)
|
|
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 security hardening (capabilities dropped, no privilege
|
|
# escalation, PID limits). The container filesystem is writable so agents
|
|
# can install packages as needed.
|
|
# User-configured volume mounts (from config.yaml docker_volumes)
|
|
volume_args = []
|
|
for vol in (volumes or []):
|
|
if not isinstance(vol, str):
|
|
logger.warning(f"Docker volume entry is not a string: {vol!r}")
|
|
continue
|
|
vol = vol.strip()
|
|
if not vol:
|
|
continue
|
|
if ":" in vol:
|
|
volume_args.extend(["-v", vol])
|
|
else:
|
|
logger.warning(f"Docker volume '{vol}' missing colon, skipping")
|
|
|
|
logger.info(f"Docker volume_args: {volume_args}")
|
|
all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args
|
|
logger.info(f"Docker run_args: {all_run_args}")
|
|
|
|
# Resolve the docker executable once so it works even when
|
|
# /usr/local/bin is not in PATH (common on macOS gateway/service).
|
|
docker_exe = find_docker() or "docker"
|
|
|
|
self._inner = _Docker(
|
|
image=image, cwd=cwd, timeout=timeout,
|
|
run_args=all_run_args,
|
|
executable=docker_exe,
|
|
)
|
|
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:
|
|
docker = find_docker() or "docker"
|
|
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, sudo_stdin = self._prepare_command(command)
|
|
work_dir = cwd or self.cwd
|
|
effective_timeout = timeout or self.timeout
|
|
|
|
# Merge sudo password (if any) with caller-supplied stdin_data.
|
|
if sudo_stdin is not None and stdin_data is not None:
|
|
effective_stdin = sudo_stdin + stdin_data
|
|
elif sudo_stdin is not None:
|
|
effective_stdin = sudo_stdin
|
|
else:
|
|
effective_stdin = stdin_data
|
|
|
|
# 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 effective_stdin 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 effective_stdin else subprocess.DEVNULL,
|
|
text=True,
|
|
)
|
|
if effective_stdin:
|
|
try:
|
|
proc.stdin.write(effective_stdin)
|
|
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)
|