fix: Docker backend fails when docker is not in PATH (macOS gateway)

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.
This commit is contained in:
teknium1
2026-03-10 20:45:13 -07:00
parent 145c57fc01
commit 24479625a2
3 changed files with 103 additions and 5 deletions

View File

@@ -0,0 +1,48 @@
"""Tests for tools.environments.docker.find_docker — Docker CLI discovery."""
import os
from unittest.mock import patch
import pytest
from tools.environments import docker as docker_mod
@pytest.fixture(autouse=True)
def _reset_cache():
"""Clear the module-level docker executable cache between tests."""
docker_mod._docker_executable = None
yield
docker_mod._docker_executable = None
class TestFindDocker:
def test_found_via_shutil_which(self):
with patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"):
result = docker_mod.find_docker()
assert result == "/usr/bin/docker"
def test_not_in_path_falls_back_to_known_locations(self, tmp_path):
# Create a fake docker binary at a known path
fake_docker = tmp_path / "docker"
fake_docker.write_text("#!/bin/sh\n")
fake_docker.chmod(0o755)
with patch("tools.environments.docker.shutil.which", return_value=None), \
patch("tools.environments.docker._DOCKER_SEARCH_PATHS", [str(fake_docker)]):
result = docker_mod.find_docker()
assert result == str(fake_docker)
def test_returns_none_when_not_found(self):
with patch("tools.environments.docker.shutil.which", return_value=None), \
patch("tools.environments.docker._DOCKER_SEARCH_PATHS", ["/nonexistent/docker"]):
result = docker_mod.find_docker()
assert result is None
def test_caches_result(self):
with patch("tools.environments.docker.shutil.which", return_value="/usr/local/bin/docker"):
first = docker_mod.find_docker()
# Second call should use cache, not call shutil.which again
with patch("tools.environments.docker.shutil.which", return_value=None):
second = docker_mod.find_docker()
assert first == second == "/usr/local/bin/docker"

View File

@@ -7,6 +7,7 @@ persistence via bind mounts.
import logging
import os
import shutil
import subprocess
import sys
import threading
@@ -19,6 +20,44 @@ 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).
@@ -145,9 +184,14 @@ class DockerEnvironment(BaseEnvironment):
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
@@ -162,8 +206,9 @@ class DockerEnvironment(BaseEnvironment):
if _storage_opt_ok is not None:
return _storage_opt_ok
try:
docker = find_docker() or "docker"
result = subprocess.run(
["docker", "info", "--format", "{{.Driver}}"],
[docker, "info", "--format", "{{.Driver}}"],
capture_output=True, text=True, timeout=10,
)
driver = result.stdout.strip().lower()
@@ -173,14 +218,14 @@ class DockerEnvironment(BaseEnvironment):
# 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"],
[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],
subprocess.run([docker, "rm", container_id],
capture_output=True, timeout=5)
_storage_opt_ok = True
else:

View File

@@ -1112,9 +1112,14 @@ def check_terminal_requirements() -> bool:
return True
elif env_type == "docker":
from minisweagent.environments.docker import DockerEnvironment
# Check if docker is available
# Check if docker is available (use find_docker for macOS PATH issues)
from tools.environments.docker import find_docker
import subprocess
result = subprocess.run(["docker", "version"], capture_output=True, timeout=5)
docker = find_docker()
if not docker:
logger.error("Docker executable not found in PATH or common install locations")
return False
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
return result.returncode == 0
elif env_type == "singularity":
from minisweagent.environments.singularity import SingularityEnvironment