| """Docker execution environment for sandboxed command execution. |
| |
| Security hardened (cap-drop ALL, no-new-privileges, PID limits), |
| configurable resource limits (CPU, memory, disk), and optional filesystem |
| persistence via bind mounts. |
| """ |
|
|
| import json |
| import logging |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import uuid |
| from pathlib import Path |
| from typing import Optional |
|
|
| from tools.environments.base import BaseEnvironment, _popen_bash |
| from tools.environments.local import ( |
| _HERMES_PROVIDER_ENV_BLOCKLIST, |
| _is_hermes_internal_secret, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
| _DOCKER_SEARCH_PATHS = [ |
| "/usr/local/bin/docker", |
| "/opt/homebrew/bin/docker", |
| "/Applications/Docker.app/Contents/Resources/bin/docker", |
| ] |
|
|
| _docker_executable: Optional[str] = None |
| _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
|
|
|
|
| def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]: |
| """Return a deduplicated list of valid environment variable names.""" |
| normalized: list[str] = [] |
| seen: set[str] = set() |
|
|
| for item in forward_env or []: |
| if not isinstance(item, str): |
| logger.warning("Ignoring non-string docker_forward_env entry: %r", item) |
| continue |
|
|
| key = item.strip() |
| if not key: |
| continue |
| if not _ENV_VAR_NAME_RE.match(key): |
| logger.warning("Ignoring invalid docker_forward_env entry: %r", item) |
| continue |
| if key in seen: |
| continue |
|
|
| seen.add(key) |
| normalized.append(key) |
|
|
| return normalized |
|
|
|
|
| def _normalize_env_dict(env: dict | None) -> dict[str, str]: |
| """Validate and normalize a docker_env dict to {str: str}. |
| |
| Filters out entries with invalid variable names or non-string values. |
| """ |
| if not env: |
| return {} |
| if not isinstance(env, dict): |
| logger.warning("docker_env is not a dict: %r", env) |
| return {} |
|
|
| normalized: dict[str, str] = {} |
| for key, value in env.items(): |
| if not isinstance(key, str) or not _ENV_VAR_NAME_RE.match(key.strip()): |
| logger.warning("Ignoring invalid docker_env key: %r", key) |
| continue |
| key = key.strip() |
| if not isinstance(value, str): |
| |
| |
| if isinstance(value, (int, float, bool)): |
| value = str(value) |
| else: |
| logger.warning("Ignoring non-string docker_env value for %r: %r", key, value) |
| continue |
| normalized[key] = value |
|
|
| return normalized |
|
|
|
|
| def _load_hermes_env_vars() -> dict[str, str]: |
| """Load ~/.hermes/.env values without failing Docker command execution.""" |
| try: |
| from hermes_cli.config import load_env |
|
|
| return load_env() or {} |
| except Exception: |
| return {} |
|
|
|
|
| |
| |
| |
| _LABEL_VALUE_OK_RE = re.compile(r"[^A-Za-z0-9_.-]") |
|
|
|
|
| def _sanitize_label_value(value: str) -> str: |
| """Coerce *value* into a Docker label-safe form (alnum + ``_.-``, ≤63 chars). |
| |
| Empty or all-invalid inputs collapse to ``"unknown"`` so the resulting |
| label is always queryable. Used at container-create time; never round-trip |
| a sanitized value back into application logic. |
| """ |
| if not isinstance(value, str) or not value: |
| return "unknown" |
| cleaned = _LABEL_VALUE_OK_RE.sub("_", value) |
| cleaned = cleaned[:63] or "unknown" |
| return cleaned |
|
|
|
|
| def _get_active_profile_name() -> str: |
| """Return the active Hermes profile name, or ``"default"`` on any error. |
| |
| Resolved at container-create time so a single container is permanently |
| tagged with the profile that created it. Profile switches inside the |
| same process don't retroactively relabel running containers. |
| """ |
| try: |
| from hermes_cli.profiles import get_active_profile_name |
|
|
| return get_active_profile_name() or "default" |
| except Exception: |
| return "default" |
|
|
|
|
| def reap_orphan_containers( |
| *, |
| max_age_seconds: int = 600, |
| profile_filter: str | None = None, |
| docker_exe: str | None = None, |
| ) -> int: |
| """Remove stale hermes-tagged containers left behind by prior processes. |
| |
| Targets containers that match all of: |
| |
| * ``label=hermes-agent=1`` (created by this codebase) |
| * ``status=exited`` (running containers are NEVER reaped — they may |
| belong to a sibling Hermes process whose reuse path will pick them |
| up; killing them would crash the sibling mid-command) |
| * (optional) ``label=hermes-profile=<profile_filter>`` (sweep only the |
| caller's profile by default; a hermes process in profile A must not |
| tear down profile B's containers) |
| * ``State.FinishedAt`` older than *max_age_seconds* ago (so a sibling |
| process that just exited and is about to be replaced doesn't get |
| its container yanked out from under it) |
| |
| Returns the number of containers removed. Best-effort: any failure |
| (docker daemon unreachable, slow inspect, parse error) is logged at |
| debug level and the function returns whatever it managed before the |
| failure. Safe to call repeatedly; idempotent. |
| |
| Issue #20561 — this is the safety net for SIGKILL / OOM / crashed |
| terminal exits that bypass the ``atexit`` cleanup hook. Without it, |
| even with the cleanup-fix in the prior commit, a hard-killed Hermes |
| process leaves its container behind permanently because there's no |
| subsequent Hermes process scheduled to reuse that exact (task, profile) |
| pair. |
| """ |
| docker = docker_exe or find_docker() or "docker" |
| filters = ["--filter", "label=hermes-agent=1", "--filter", "status=exited"] |
| if profile_filter: |
| filters.extend(["--filter", f"label=hermes-profile={_sanitize_label_value(profile_filter)}"]) |
|
|
| try: |
| listing = subprocess.run( |
| [docker, "ps", "-a", *filters, "--format", "{{.ID}}"], |
| capture_output=True, text=True, timeout=15, check=False, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.debug("orphan reaper docker ps failed: %s", e) |
| return 0 |
| if listing.returncode != 0: |
| logger.debug( |
| "orphan reaper docker ps returned %d: %s", |
| listing.returncode, listing.stderr.strip(), |
| ) |
| return 0 |
|
|
| candidate_ids = [ln.strip() for ln in listing.stdout.splitlines() if ln.strip()] |
| if not candidate_ids: |
| return 0 |
|
|
| |
| |
| |
| import datetime |
| now = datetime.datetime.now(datetime.timezone.utc) |
| removed = 0 |
| for cid in candidate_ids: |
| finished_at = _container_finished_at(docker, cid) |
| if finished_at is None: |
| |
| continue |
| age = (now - finished_at).total_seconds() |
| if age < max_age_seconds: |
| continue |
| try: |
| result = subprocess.run( |
| [docker, "rm", "-f", cid], |
| capture_output=True, text=True, timeout=30, |
| stdin=subprocess.DEVNULL, |
| ) |
| if result.returncode == 0: |
| removed += 1 |
| logger.info( |
| "Reaped orphan container %s (exited %d seconds ago)", |
| cid[:12], int(age), |
| ) |
| else: |
| logger.debug( |
| "docker rm -f %s failed: %s", |
| cid[:12], result.stderr.strip(), |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.debug("orphan reaper docker rm %s failed: %s", cid[:12], e) |
| return removed |
|
|
|
|
| def _container_finished_at(docker_exe: str, container_id: str): |
| """Parse ``docker inspect`` FinishedAt for *container_id*. |
| |
| Returns a timezone-aware datetime, or ``None`` if the field is missing, |
| unparseable, or the zero-value ``0001-01-01T00:00:00Z`` Docker emits |
| for never-finished containers. ``None`` means "don't reap" — the caller |
| leaves the container alone. |
| """ |
| try: |
| result = subprocess.run( |
| [docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id], |
| capture_output=True, text=True, timeout=10, check=False, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e) |
| return None |
| if result.returncode != 0: |
| return None |
| raw = result.stdout.strip() |
| if not raw or raw.startswith("0001-01-01"): |
| return None |
| |
| |
| import re as _re |
| raw = _re.sub(r"(\.\d{6})\d+", r"\1", raw) |
| raw = raw.replace("Z", "+00:00") |
| try: |
| import datetime |
| return datetime.datetime.fromisoformat(raw) |
| except ValueError as e: |
| logger.debug("could not parse FinishedAt %r for %s: %s", raw, container_id[:12], e) |
| return None |
|
|
|
|
| def find_docker() -> Optional[str]: |
| """Locate the docker (or podman) CLI binary. |
| |
| Resolution order: |
| 1. ``HERMES_DOCKER_BINARY`` env var — explicit override (e.g. ``/usr/bin/podman``) |
| 2. ``docker`` on PATH via ``shutil.which`` |
| 3. ``podman`` on PATH via ``shutil.which`` |
| 4. Well-known macOS Docker Desktop install locations |
| |
| Returns the absolute path, or ``None`` if neither runtime can be found. |
| """ |
| global _docker_executable |
| if _docker_executable is not None: |
| return _docker_executable |
|
|
| |
| override = os.getenv("HERMES_DOCKER_BINARY") |
| if override and os.path.isfile(override) and os.access(override, os.X_OK): |
| _docker_executable = override |
| logger.info("Using HERMES_DOCKER_BINARY override: %s", override) |
| return override |
|
|
| |
| found = shutil.which("docker") |
| if found: |
| _docker_executable = found |
| return found |
|
|
| |
| found = shutil.which("podman") |
| if found: |
| _docker_executable = found |
| logger.info("Using podman as container runtime: %s", 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _BASE_SECURITY_ARGS = [ |
| "--cap-drop", "ALL", |
| "--cap-add", "DAC_OVERRIDE", |
| "--cap-add", "CHOWN", |
| "--cap-add", "FOWNER", |
| "--security-opt", "no-new-privileges", |
| "--tmpfs", "/tmp:rw,nosuid,size=512m", |
| "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", |
| ] |
|
|
| |
| |
| _DEFAULT_PIDS_LIMIT = "256" |
|
|
| |
| |
| |
| |
| _RUN_TMPFS_NOEXEC = "--tmpfs", "/run:rw,noexec,nosuid,size=64m" |
| _RUN_TMPFS_EXEC = "--tmpfs", "/run:rw,exec,nosuid,size=64m" |
|
|
| |
| |
| |
| |
| _PRIVDROP_CAP_ARGS = [ |
| "--cap-add", "SETUID", |
| "--cap-add", "SETGID", |
| ] |
|
|
|
|
| def _build_security_args(run_as_host_user: bool, run_exec: bool = False) -> list[str]: |
| """Return the security/cap/tmpfs args tailored to the privilege mode. |
| |
| ``run_exec`` mounts ``/run`` with ``exec`` instead of the hardened |
| ``noexec`` default. This is required for s6-overlay images whose ``/init`` |
| entrypoint execs ``/run/s6/basedir/bin/init`` during startup; see |
| ``_image_uses_init_entrypoint``. |
| """ |
| run_tmpfs = list(_RUN_TMPFS_EXEC if run_exec else _RUN_TMPFS_NOEXEC) |
| args = list(_BASE_SECURITY_ARGS) + run_tmpfs |
| if run_as_host_user: |
| return args |
| return args + list(_PRIVDROP_CAP_ARGS) |
|
|
|
|
| def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool: |
| """Return True if ``image``'s entrypoint is the s6-overlay ``/init``. |
| |
| Such images (e.g. anything built on ``s6-overlay``, including |
| ``hermes-agent:latest``) already provide their own PID-1 init and execute |
| ``/run/s6/basedir/bin/init`` during stage0 startup. They are incompatible |
| with Docker's ``--init`` (two competing PID-1 inits) and with a ``noexec`` |
| ``/run`` mount. Detection is best-effort: on any inspection failure we |
| return False and keep the hardened defaults. |
| """ |
| try: |
| result = subprocess.run( |
| [docker_exe, "image", "inspect", image, |
| "--format", "{{json .Config.Entrypoint}}"], |
| capture_output=True, |
| text=True, |
| timeout=15, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.SubprocessError, OSError) as e: |
| logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e) |
| return False |
| if result.returncode != 0: |
| |
| |
| logger.debug( |
| "Docker: image inspect for %s returned %d (stderr=%s)", |
| image, result.returncode, result.stderr.strip(), |
| ) |
| return False |
| raw = (result.stdout or "").strip() |
| if not raw or raw == "null": |
| return False |
| try: |
| entrypoint = json.loads(raw) |
| except (ValueError, TypeError): |
| return False |
| if isinstance(entrypoint, str): |
| entrypoint = [entrypoint] |
| if not isinstance(entrypoint, list) or not entrypoint: |
| return False |
| first = str(entrypoint[0]).strip() |
| return first in ("/init", "/package/admin/s6-overlay/command/init") |
|
|
|
|
| def _resolve_host_user_spec() -> Optional[str]: |
| """Return ``<uid>:<gid>`` for the current host user, or ``None`` on platforms |
| where this is not meaningful (e.g. Windows without posix ids). |
| |
| We intentionally read ``os.getuid()``/``os.getgid()`` directly rather than |
| going through ``getpass``/``pwd`` so this stays cheap and never raises on |
| nameless UIDs (nss lookups can fail inside sandboxed launchers). |
| """ |
| get_uid = getattr(os, "getuid", None) |
| get_gid = getattr(os, "getgid", None) |
| if get_uid is None or get_gid is None: |
| return None |
| try: |
| return f"{get_uid()}:{get_gid()}" |
| except Exception: |
| return None |
|
|
|
|
| _storage_opt_ok: Optional[bool] = None |
| _cgroup_limits_ok: Optional[bool] = None |
|
|
|
|
| def _cgroup_limits_available(image: str) -> bool: |
| """Probe whether cgroup resource limits work in this environment. |
| |
| Tests ``--cpus``, ``--memory`` and ``--pids-limit`` together by spawning |
| a throwaway container from *image* (the same sandbox image we are about |
| to use for real, so no extra pull and no dependency on a public |
| registry). The container runs ``sleep 0`` — sleep is guaranteed to be |
| present because the sandbox itself uses ``sleep 2h`` as its long-lived |
| entrypoint. |
| |
| On hosts where the corresponding cgroup controllers are not delegated |
| to this process (typical inside unprivileged LXCs and some rootless |
| setups) these flags cause every container start to fail with ``OCI |
| runtime error`` / exit 126. The probe runs once per process and the |
| result — which is host-wide, not image-specific — is cached. |
| """ |
| global _cgroup_limits_ok |
| if _cgroup_limits_ok is not None: |
| return _cgroup_limits_ok |
|
|
| docker_exe = find_docker() |
| if not docker_exe or not image: |
| _cgroup_limits_ok = False |
| return False |
|
|
| try: |
| result = subprocess.run( |
| [docker_exe, "run", "--rm", |
| "--cpus", "0.5", "--memory", "64m", "--pids-limit", "32", |
| image, "sleep", "0"], |
| capture_output=True, |
| text=True, |
| timeout=60, |
| stdin=subprocess.DEVNULL, |
| ) |
| _cgroup_limits_ok = result.returncode == 0 |
| if not _cgroup_limits_ok: |
| logger.warning( |
| "Cgroup resource limits (--cpus/--memory/--pids-limit) not " |
| "available in this environment. Containers will run without " |
| "CPU, memory or PID limits. To enable, delegate the cpu, " |
| "memory and pids cgroup controllers to this container. " |
| "Probe stderr: %s", |
| (result.stderr or "").strip()[:500], |
| ) |
| except Exception as e: |
| _cgroup_limits_ok = False |
| logger.warning("Cgroup limit probe failed; disabling resource limits: %s", e) |
|
|
| return _cgroup_limits_ok |
|
|
|
|
| def _ensure_docker_available() -> None: |
| """Best-effort check that the docker CLI is available before use. |
| |
| Reuses ``find_docker()`` so this preflight stays consistent with the rest of |
| the Docker backend, including known non-PATH Docker Desktop locations. |
| """ |
| docker_exe = find_docker() |
| if not docker_exe: |
| logger.error( |
| "Docker backend selected but no docker executable was found in PATH " |
| "or known install locations. Install Docker Desktop and ensure the " |
| "CLI is available." |
| ) |
| raise RuntimeError( |
| "Docker executable not found in PATH or known install locations. " |
| "Install Docker and ensure the 'docker' command is available." |
| ) |
|
|
| try: |
| result = subprocess.run( |
| [docker_exe, "version"], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| stdin=subprocess.DEVNULL, |
| ) |
| except FileNotFoundError: |
| logger.error( |
| "Docker backend selected but the resolved docker executable '%s' could " |
| "not be executed.", |
| docker_exe, |
| exc_info=True, |
| ) |
| raise RuntimeError( |
| "Docker executable could not be executed. Check your Docker installation." |
| ) |
| except subprocess.TimeoutExpired: |
| logger.error( |
| "Docker backend selected but '%s version' timed out. " |
| "The Docker daemon may not be running.", |
| docker_exe, |
| exc_info=True, |
| ) |
| raise RuntimeError( |
| "Docker daemon is not responding. Ensure Docker is running and try again." |
| ) |
| except Exception: |
| logger.error( |
| "Unexpected error while checking Docker availability.", |
| exc_info=True, |
| ) |
| raise |
| else: |
| if result.returncode != 0: |
| logger.error( |
| "Docker backend selected but '%s version' failed " |
| "(exit code %d, stderr=%s)", |
| docker_exe, |
| result.returncode, |
| result.stderr.strip(), |
| ) |
| raise RuntimeError( |
| "Docker command is available but 'docker version' failed. " |
| "Check your Docker installation." |
| ) |
|
|
|
|
| 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, |
| forward_env: list[str] | None = None, |
| env: dict | None = None, |
| network: bool = True, |
| host_cwd: str = None, |
| auto_mount_cwd: bool = False, |
| run_as_host_user: bool = False, |
| extra_args: list = None, |
| persist_across_processes: bool = True, |
| ): |
| if cwd == "~": |
| cwd = "/root" |
| super().__init__(cwd=cwd, timeout=timeout) |
| self._persistent = persistent_filesystem |
| self._persist_across_processes = persist_across_processes |
| self._task_id = task_id |
| self._forward_env = _normalize_forward_env_names(forward_env) |
| self._env = _normalize_env_dict(env) |
| self._container_id: Optional[str] = None |
| self._labels: dict[str, str] = {} |
| self._image: str = "" |
| self._container_name: str = "" |
| self._image_uses_s6_init: bool = False |
| self._all_run_args: list[str] = [] |
| logger.info(f"DockerEnvironment volumes: {volumes}") |
| |
| if volumes is not None and not isinstance(volumes, list): |
| logger.warning(f"docker_volumes config is not a list: {volumes!r}") |
| volumes = [] |
|
|
| |
| _ensure_docker_available() |
|
|
| |
| |
| |
| |
| resource_args = [] |
| if cpu > 0 and _cgroup_limits_available(image): |
| resource_args.extend(["--cpus", str(cpu)]) |
| if memory > 0 and _cgroup_limits_available(image): |
| resource_args.extend(["--memory", f"{memory}m"]) |
| if _cgroup_limits_available(image): |
| resource_args.extend(["--pids-limit", _DEFAULT_PIDS_LIMIT]) |
| 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") |
|
|
| |
| |
| |
| from tools.environments.base import get_sandbox_dir |
|
|
| |
| volume_args = [] |
| workspace_explicitly_mounted = False |
| 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]) |
| if ":/workspace" in vol: |
| workspace_explicitly_mounted = True |
| else: |
| logger.warning(f"Docker volume '{vol}' missing colon, skipping") |
|
|
| host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else "" |
| bind_host_cwd = ( |
| auto_mount_cwd |
| and bool(host_cwd_abs) |
| and os.path.isdir(host_cwd_abs) |
| and not workspace_explicitly_mounted |
| ) |
| if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs): |
| logger.debug(f"Skipping docker cwd mount: host_cwd is not a valid directory: {host_cwd}") |
|
|
| self._workspace_dir: Optional[str] = None |
| self._home_dir: Optional[str] = None |
| writable_args = [] |
| if self._persistent: |
| sandbox = get_sandbox_dir() / "docker" / task_id |
| self._home_dir = str(sandbox / "home") |
| os.makedirs(self._home_dir, exist_ok=True) |
| writable_args.extend([ |
| "-v", f"{self._home_dir}:/root", |
| ]) |
| if not bind_host_cwd and not workspace_explicitly_mounted: |
| self._workspace_dir = str(sandbox / "workspace") |
| os.makedirs(self._workspace_dir, exist_ok=True) |
| writable_args.extend([ |
| "-v", f"{self._workspace_dir}:/workspace", |
| ]) |
| else: |
| if not bind_host_cwd and not workspace_explicitly_mounted: |
| writable_args.extend([ |
| "--tmpfs", "/workspace:rw,exec,size=10g", |
| ]) |
| writable_args.extend([ |
| "--tmpfs", "/home:rw,exec,size=1g", |
| "--tmpfs", "/root:rw,exec,size=1g", |
| ]) |
|
|
| if bind_host_cwd: |
| logger.info(f"Mounting configured host cwd to /workspace: {host_cwd_abs}") |
| volume_args = ["-v", f"{host_cwd_abs}:/workspace", *volume_args] |
| elif workspace_explicitly_mounted: |
| logger.debug("Skipping docker cwd mount: /workspace already mounted by user config") |
|
|
| |
| |
| try: |
| from tools.credential_files import ( |
| get_credential_file_mounts, |
| get_skills_directory_mount, |
| get_cache_directory_mounts, |
| ) |
|
|
| for mount_entry in get_credential_file_mounts(): |
| src = Path(mount_entry["host_path"]) |
| if src.is_dir(): |
| |
| |
| |
| logger.warning( |
| "Docker: skipping credential mount — source is a directory " |
| "(likely Docker-in-Docker auto-creation): %s", |
| src, |
| ) |
| continue |
| if not src.is_file(): |
| logger.warning( |
| "Docker: skipping credential mount — source not found: %s", src, |
| ) |
| continue |
| volume_args.extend([ |
| "-v", |
| f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro", |
| ]) |
| logger.info( |
| "Docker: mounting credential %s -> %s", |
| mount_entry["host_path"], |
| mount_entry["container_path"], |
| ) |
|
|
| |
| |
| for skills_mount in get_skills_directory_mount(): |
| src = Path(skills_mount["host_path"]) |
| if not src.is_dir(): |
| logger.warning( |
| "Docker: skipping skills mount — source is not a directory: %s", |
| src, |
| ) |
| continue |
| volume_args.extend([ |
| "-v", |
| f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro", |
| ]) |
| logger.info( |
| "Docker: mounting skills dir %s -> %s", |
| skills_mount["host_path"], |
| skills_mount["container_path"], |
| ) |
|
|
| |
| |
| |
| |
| for cache_mount in get_cache_directory_mounts(): |
| src = Path(cache_mount["host_path"]) |
| if not src.is_dir(): |
| logger.warning( |
| "Docker: skipping cache mount — source is not a directory: %s", |
| src, |
| ) |
| continue |
| volume_args.extend([ |
| "-v", |
| f"{cache_mount['host_path']}:{cache_mount['container_path']}:ro", |
| ]) |
| logger.info( |
| "Docker: mounting cache dir %s -> %s", |
| cache_mount["host_path"], |
| cache_mount["container_path"], |
| ) |
| except Exception as e: |
| logger.debug("Docker: could not load credential file mounts: %s", e) |
|
|
| |
| |
| env_args = [] |
| for key in sorted(self._env): |
| env_args.extend(["-e", f"{key}={self._env[key]}"]) |
|
|
| |
| |
| |
| |
| user_args: list[str] = [] |
| if run_as_host_user: |
| user_spec = _resolve_host_user_spec() |
| if user_spec is not None: |
| user_args = ["--user", user_spec] |
| logger.info("Docker: running container as host user %s", user_spec) |
| else: |
| logger.warning( |
| "docker_run_as_host_user is enabled but this platform does " |
| "not expose POSIX uid/gid; container will start as its " |
| "image default user." |
| ) |
| |
| |
|
|
| |
| |
| self._docker_exe = find_docker() or "docker" |
|
|
| |
| |
| |
| |
| |
| |
| image_uses_s6_init = _image_uses_init_entrypoint(self._docker_exe, image) |
| if image_uses_s6_init: |
| logger.info( |
| "Docker: image %s uses /init (s6-overlay) as entrypoint — " |
| "skipping --init and mounting /run with exec.", |
| image, |
| ) |
| security_args = _build_security_args( |
| run_as_host_user and bool(user_args), |
| run_exec=image_uses_s6_init, |
| ) |
|
|
| logger.info(f"Docker volume_args: {volume_args}") |
| |
| |
| validated_extra = [] |
| for arg in (extra_args or []): |
| if not isinstance(arg, str): |
| logger.warning("Ignoring non-string docker_extra_args entry: %r", arg) |
| continue |
| validated_extra.append(arg) |
|
|
| all_run_args = ( |
| security_args |
| + user_args |
| + writable_args |
| + resource_args |
| + volume_args |
| + env_args |
| + validated_extra |
| ) |
| logger.info(f"Docker run_args: {all_run_args}") |
|
|
| |
| container_name = f"hermes-{uuid.uuid4().hex[:8]}" |
| |
| |
| |
| |
| |
| |
| |
| profile_name = _sanitize_label_value(_get_active_profile_name()) |
| task_label = _sanitize_label_value(task_id) |
| label_args = [ |
| "--label", "hermes-agent=1", |
| "--label", f"hermes-task-id={task_label}", |
| "--label", f"hermes-profile={profile_name}", |
| ] |
| |
| self._image = image |
| self._container_name = container_name |
| self._image_uses_s6_init = image_uses_s6_init |
| self._all_run_args = all_run_args |
|
|
| self._labels = { |
| "hermes-agent": "1", |
| "hermes-task-id": task_label, |
| "hermes-profile": profile_name, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| reused = False |
| if persist_across_processes: |
| existing = self._find_reusable_container(task_label, profile_name) |
| if existing is not None: |
| container_id, state = existing |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| mode_mismatch = False |
| actual_mode = None |
| if not network: |
| actual_mode = self._container_network_mode(container_id) |
| mode_mismatch = actual_mode != "none" |
| if mode_mismatch: |
| logger.warning( |
| "Existing container %s has NetworkMode=%s but " |
| "docker_network=false requests an air-gapped " |
| "container — removing it and starting fresh " |
| "(task=%s, profile=%s).", |
| container_id[:12], actual_mode or "unknown", |
| task_label, profile_name, |
| ) |
| try: |
| subprocess.run( |
| [self._docker_exe, "rm", "-f", container_id], |
| capture_output=True, |
| text=True, |
| timeout=30, |
| check=False, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.warning("Failed to remove mismatched container %s: %s", container_id[:12], e) |
| existing = None |
| if existing is not None: |
| container_id, state = existing |
| self._container_id = container_id |
| if state != "running": |
| try: |
| subprocess.run( |
| [self._docker_exe, "start", container_id], |
| capture_output=True, |
| text=True, |
| timeout=30, |
| check=True, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: |
| logger.warning( |
| "Failed to start existing container %s (state=%s): " |
| "%s — falling back to a fresh container.", |
| container_id[:12], state, e, |
| ) |
| self._container_id = None |
| if self._container_id: |
| logger.info( |
| "Reusing container %s (task=%s, profile=%s, prior state=%s)", |
| container_id[:12], task_label, profile_name, state, |
| ) |
| reused = True |
|
|
| if not reused: |
| |
| |
| |
| init_args = [] if image_uses_s6_init else ["--init"] |
| run_cmd = [ |
| self._docker_exe, "run", "-d", |
| *init_args, |
| "--name", container_name, |
| *label_args, |
| "-w", cwd, |
| *all_run_args, |
| image, |
| "sleep", "infinity", |
| ] |
| logger.debug(f"Starting container: {' '.join(run_cmd)}") |
| try: |
| result = subprocess.run( |
| run_cmd, |
| capture_output=True, |
| text=True, |
| timeout=120, |
| check=True, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: |
| |
| |
| |
| |
| |
| |
| |
| logger.warning( |
| "docker run failed for %s, cleaning up orphaned container: %s", |
| container_name, e, |
| ) |
| subprocess.run( |
| [self._docker_exe, "rm", "-f", container_name], |
| capture_output=True, timeout=10, |
| stdin=subprocess.DEVNULL, |
| ) |
| raise |
| self._container_id = result.stdout.strip() |
| logger.info(f"Started container {container_name} ({self._container_id[:12]})") |
|
|
| |
| |
| |
| self._init_env_args = self._build_init_env_args() |
|
|
| |
| self.init_session() |
|
|
| def _build_init_env_args(self) -> list[str]: |
| """Build -e KEY=VALUE args for injecting host env vars into init_session. |
| |
| These are used once during init_session() so that export -p captures |
| them into the snapshot. Subsequent execute() calls don't need -e flags. |
| """ |
| exec_env: dict[str, str] = dict(self._env) |
|
|
| explicit_forward_keys = set(self._forward_env) |
| passthrough_keys: set[str] = set() |
| try: |
| from tools.env_passthrough import get_all_passthrough |
| passthrough_keys = set(get_all_passthrough()) |
| except Exception: |
| pass |
| |
| |
| |
| |
| |
| _implicit_forward = { |
| k for k in passthrough_keys if not _is_hermes_internal_secret(k) |
| } |
| forward_keys = explicit_forward_keys | (_implicit_forward - _HERMES_PROVIDER_ENV_BLOCKLIST) |
| hermes_env = _load_hermes_env_vars() if forward_keys else {} |
| for key in sorted(forward_keys): |
| value = os.getenv(key) |
| if not value: |
| value = hermes_env.get(key) |
| if value: |
| exec_env[key] = value |
|
|
| args = [] |
| for key in sorted(exec_env): |
| args.extend(["-e", f"{key}={exec_env[key]}"]) |
| return args |
|
|
| def _run_bash(self, cmd_string: str, *, login: bool = False, |
| timeout: int = 120, |
| stdin_data: str | None = None) -> subprocess.Popen: |
| """Spawn a bash process inside the Docker container.""" |
| assert self._container_id, "Container not started" |
| cmd = [self._docker_exe, "exec"] |
| if stdin_data is not None: |
| cmd.append("-i") |
|
|
| |
| |
| if login: |
| cmd.extend(self._init_env_args) |
|
|
| cmd.extend([self._container_id]) |
|
|
| if login: |
| cmd.extend(["bash", "-l", "-c", cmd_string]) |
| else: |
| cmd.extend(["bash", "-c", cmd_string]) |
|
|
| return _popen_bash(cmd, stdin_data) |
|
|
| |
| |
| |
|
|
| _NO_CONTAINER_PATTERNS = ( |
| "No such container", |
| "is not running", |
| "no such container", |
| ) |
|
|
| def _is_container_gone(self, output: str) -> bool: |
| """Return True if the output indicates the container no longer exists.""" |
| return any(p in output for p in self._NO_CONTAINER_PATTERNS) |
|
|
| def _recreate_container(self) -> bool: |
| """Recreate the container after it was removed out-of-band. |
| |
| Tries label-based reuse first; if no existing container is found, |
| starts a fresh one with the same image and run-args. Returns True |
| on success, False if recreation fails (caller should surface the |
| original error). |
| """ |
| old_id = (self._container_id or "")[:12] |
| logger.warning( |
| "Container %s appears to be gone — attempting recovery", old_id, |
| ) |
| self._container_id = None |
|
|
| |
| task_label = self._labels.get("hermes-task-id", "") |
| profile_label = self._labels.get("hermes-profile", "") |
| existing = self._find_reusable_container(task_label, profile_label) |
| if existing is not None: |
| cid, state = existing |
| if state == "running": |
| self._container_id = cid |
| logger.info("Recovery: reusing running container %s", cid[:12]) |
| else: |
| try: |
| subprocess.run( |
| [self._docker_exe, "start", cid], |
| capture_output=True, text=True, timeout=30, check=True, |
| stdin=subprocess.DEVNULL, |
| ) |
| self._container_id = cid |
| logger.info("Recovery: restarted container %s", cid[:12]) |
| except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: |
| logger.warning("Recovery: failed to start container %s: %s", cid[:12], e) |
|
|
| |
| if not self._container_id: |
| if not self._image: |
| logger.error("Recovery: no saved image name, cannot recreate container") |
| return False |
| try: |
| import uuid as _uuid |
| new_name = f"hermes-{_uuid.uuid4().hex[:8]}" |
| init_args = [] if self._image_uses_s6_init else ["--init"] |
| label_args = [] |
| for k, v in self._labels.items(): |
| label_args.extend(["--label", f"{k}={v}"]) |
| run_cmd = [ |
| self._docker_exe, "run", "-d", |
| *init_args, |
| "--name", new_name, |
| *label_args, |
| "-w", self.cwd, |
| *self._all_run_args, |
| self._image, |
| "sleep", "infinity", |
| ] |
| result = subprocess.run( |
| run_cmd, capture_output=True, text=True, timeout=120, check=True, |
| stdin=subprocess.DEVNULL, |
| ) |
| self._container_id = result.stdout.strip() |
| self._container_name = new_name |
| logger.info( |
| "Recovery: created fresh container %s (%s)", |
| new_name, self._container_id[:12], |
| ) |
| except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: |
| logger.error("Recovery: failed to create new container: %s", e) |
| return False |
|
|
| |
| try: |
| self._snapshot_ready = False |
| self.init_session() |
| except Exception as e: |
| logger.error("Recovery: init_session failed in new container: %s", e) |
| return False |
|
|
| logger.info("Recovery successful — new container %s", (self._container_id or "")[:12]) |
| return True |
|
|
| def execute(self, command: str, cwd: str = "", **kwargs) -> dict: |
| """Execute a command, auto-recovering from dead containers. |
| |
| If the container was removed out-of-band (idle reaper, docker prune, |
| OOM kill, daemon restart), detect the error and recreate the container |
| transparently before retrying once. |
| """ |
| result = super().execute(command, cwd, **kwargs) |
| if ( |
| result.get("returncode", 0) != 0 |
| and self._is_container_gone(result.get("output", "")) |
| and self._persist_across_processes |
| ): |
| if self._recreate_container(): |
| result = super().execute(command, cwd, **kwargs) |
| return result |
|
|
| @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, |
| stdin=subprocess.DEVNULL, |
| ) |
| driver = result.stdout.strip().lower() |
| if driver != "overlay2": |
| _storage_opt_ok = False |
| return False |
| |
| |
| probe = subprocess.run( |
| [docker, "create", "--storage-opt", "size=1m", "hello-world"], |
| capture_output=True, text=True, timeout=15, |
| stdin=subprocess.DEVNULL, |
| ) |
| if probe.returncode == 0: |
| |
| container_id = probe.stdout.strip() |
| if container_id: |
| subprocess.run([docker, "rm", container_id], |
| capture_output=True, timeout=5, |
| stdin=subprocess.DEVNULL) |
| _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 _container_network_mode(self, container_id: str) -> Optional[str]: |
| """Return the container's ``HostConfig.NetworkMode`` (e.g. ``bridge``, |
| ``none``, ``host``), or ``None`` when inspection fails. |
| |
| Used by the reuse path to make sure a persisted container's network |
| mode still matches the operator's ``docker_network`` setting; callers |
| treat ``None`` (unknown) as a mismatch when lockdown was requested, |
| so a failed inspect fails closed rather than open. |
| """ |
| try: |
| result = subprocess.run( |
| [ |
| self._docker_exe, "inspect", |
| "--format", "{{.HostConfig.NetworkMode}}", |
| container_id, |
| ], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| check=False, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.debug("docker inspect NetworkMode failed: %s", e) |
| return None |
| if result.returncode != 0: |
| logger.debug( |
| "docker inspect NetworkMode returned %d: %s", |
| result.returncode, result.stderr.strip(), |
| ) |
| return None |
| mode = result.stdout.strip() |
| return mode or None |
|
|
| def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]: |
| """Look for an existing container labeled for this (task, profile). |
| |
| Returns ``(container_id, state)`` on hit, ``None`` on miss / on any |
| failure (including ``docker ps`` itself failing). State is one of the |
| values Docker reports via ``{{.State}}`` — e.g. ``running``, ``exited``, |
| ``created``, ``paused``, ``restarting``, ``dead``. The caller decides |
| whether the state warrants ``docker start`` before reuse. |
| |
| Restricted to the docker-stored label set this class creates; never |
| matches containers that happened to be named ``hermes-*`` but were |
| started by some other tool. |
| """ |
| try: |
| result = subprocess.run( |
| [ |
| self._docker_exe, "ps", "-a", |
| "--filter", "label=hermes-agent=1", |
| "--filter", f"label=hermes-task-id={task_label}", |
| "--filter", f"label=hermes-profile={profile_label}", |
| "--format", "{{.ID}}\t{{.State}}", |
| ], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| check=False, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.debug("docker ps probe failed: %s — will start a fresh container", e) |
| return None |
| if result.returncode != 0: |
| logger.debug( |
| "docker ps probe returned %d: %s — will start a fresh container", |
| result.returncode, result.stderr.strip(), |
| ) |
| return None |
| lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()] |
| if not lines: |
| return None |
| |
| |
| |
| |
| |
| running = None |
| first = None |
| for ln in lines: |
| parts = ln.split("\t", 1) |
| if len(parts) != 2: |
| continue |
| cid, state = parts[0], parts[1].lower() |
| if first is None: |
| first = (cid, state) |
| if state == "running" and running is None: |
| running = (cid, state) |
| return running or first |
|
|
| def cleanup(self, *, force_remove: bool = False): |
| """Tear down the container according to persist mode and *force_remove*. |
| |
| Persist-mode (``persist_across_processes=True``, the default) leaves the |
| container **running** untouched. The docs promise "ONE long-lived |
| container shared across sessions" and stopping it on every Hermes exit |
| breaks that promise: |
| |
| * Background processes inside the container (``npm run dev``, watchers, |
| long-running pytest) get killed every time the user runs ``/quit``. |
| * Every reuse requires ``docker start`` + waiting for the container to |
| come back up, adding 1–2s to the first tool call of the new session. |
| * The user-visible difference between "ONE long-lived container" and |
| "a new container that happens to share state" is exactly this: |
| processes survive in the former, die in the latter. |
| |
| Resource reclamation for the persist-mode case lives in the |
| ``reap_orphan_containers()`` path (see issue #20561 commit 3): if no |
| Hermes process touches a labeled container for ``2 × lifetime_seconds`` |
| it gets ``docker rm -f``'d at the next Hermes startup. That covers the |
| SIGKILL / OOM / abandoned-laptop cases without us needing to stop the |
| container on every graceful exit. |
| |
| Opt-out mode (``persist_across_processes=False``) still does |
| ``docker stop`` + ``docker rm -f`` on every cleanup, matching the |
| pre-PR behavior for users who explicitly want per-process isolation. |
| |
| ``force_remove=True`` overrides persist mode and always tears the |
| container down (``docker stop`` + ``docker rm -f``). This is the |
| explicit-teardown path for ``/reset``, ``cleanup_vm(task_id)``-driven |
| resets, or any caller that wants a guaranteed fresh container on next |
| ``DockerEnvironment(task_id=...)``. No current caller passes |
| ``force_remove=True``; the parameter is here so the explicit-teardown |
| semantics can be wired up later without changing this method's |
| signature. |
| |
| Cleanup runs on a daemon thread with bounded ``subprocess.run`` calls |
| (not the racy ``Popen(... &)`` pattern from before PR #33645). The |
| atexit hook in ``tools/terminal_tool.py`` waits up to 15s for the |
| thread to finish before the interpreter exits, so ``docker stop`` / |
| ``docker rm`` actually completes when we do trigger it. |
| """ |
| container_id = self._container_id |
| if not container_id: |
| |
| |
| if not self._persistent: |
| for d in (self._workspace_dir, self._home_dir): |
| if d: |
| shutil.rmtree(d, ignore_errors=True) |
| return |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if force_remove: |
| should_stop = True |
| should_remove = True |
| elif self._persist_across_processes: |
| |
| |
| |
| self._container_id = None |
| return |
| else: |
| should_stop = True |
| should_remove = True |
|
|
| |
| |
| docker_exe = self._docker_exe |
| log_id = container_id[:12] |
|
|
| def _do_cleanup() -> None: |
| if should_stop: |
| try: |
| subprocess.run( |
| [docker_exe, "stop", "-t", "10", container_id], |
| capture_output=True, timeout=30, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.warning("docker stop %s timed out / failed: %s", log_id, e) |
| if should_remove: |
| try: |
| subprocess.run( |
| [docker_exe, "rm", "-f", container_id], |
| capture_output=True, timeout=30, |
| stdin=subprocess.DEVNULL, |
| ) |
| except (subprocess.TimeoutExpired, OSError) as e: |
| logger.warning("docker rm -f %s failed: %s", log_id, e) |
|
|
| |
| |
| |
| |
| |
| |
| import threading |
| t = threading.Thread(target=_do_cleanup, daemon=True, name=f"hermes-cleanup-{log_id}") |
| t.start() |
| self._cleanup_thread = t |
| self._container_id = None |
|
|
| |
| |
| |
| if should_remove and not self._persistent: |
| for d in (self._workspace_dir, self._home_dir): |
| if d: |
| shutil.rmtree(d, ignore_errors=True) |
|
|
| def wait_for_cleanup(self, timeout: float = 30.0) -> bool: |
| """Block up to *timeout* seconds for the cleanup worker thread. |
| |
| Returns ``True`` if the thread finished (or no thread was started), |
| ``False`` on timeout. The atexit hook in terminal_tool.py calls this |
| on every active environment so docker stop/rm actually completes |
| before the Python process exits — without this, ``hermes /quit`` |
| races the interpreter shutdown and leaves stopped containers behind. |
| """ |
| thread = getattr(self, "_cleanup_thread", None) |
| if thread is None or not thread.is_alive(): |
| return True |
| thread.join(timeout=timeout) |
| return not thread.is_alive() |
|
|