diff --git a/tests/tools/test_docker_find.py b/tests/tools/test_docker_find.py new file mode 100644 index 00000000..c1fb58a3 --- /dev/null +++ b/tests/tools/test_docker_find.py @@ -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" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 6b380b31..496b41d3 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -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: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index f6399fca..b8c3a66f 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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