mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #889 from NousResearch/hermes/hermes-b0162f8d
fix: Docker backend fails when docker is not in PATH (macOS gateway)
This commit is contained in:
commit
b76cae94d4
3 changed files with 103 additions and 5 deletions
48
tests/tools/test_docker_find.py
Normal file
48
tests/tools/test_docker_find.py
Normal 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"
|
||||||
|
|
@ -7,6 +7,7 @@ persistence via bind mounts.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -19,6 +20,44 @@ from tools.interrupt import is_interrupted
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
# Security flags applied to every container.
|
||||||
# The container itself is the security boundary (isolated from host).
|
# 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
|
all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args
|
||||||
logger.info(f"Docker run_args: {all_run_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(
|
self._inner = _Docker(
|
||||||
image=image, cwd=cwd, timeout=timeout,
|
image=image, cwd=cwd, timeout=timeout,
|
||||||
run_args=all_run_args,
|
run_args=all_run_args,
|
||||||
|
executable=docker_exe,
|
||||||
)
|
)
|
||||||
self._container_id = self._inner.container_id
|
self._container_id = self._inner.container_id
|
||||||
|
|
||||||
|
|
@ -162,8 +206,9 @@ class DockerEnvironment(BaseEnvironment):
|
||||||
if _storage_opt_ok is not None:
|
if _storage_opt_ok is not None:
|
||||||
return _storage_opt_ok
|
return _storage_opt_ok
|
||||||
try:
|
try:
|
||||||
|
docker = find_docker() or "docker"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "info", "--format", "{{.Driver}}"],
|
[docker, "info", "--format", "{{.Driver}}"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
driver = result.stdout.strip().lower()
|
driver = result.stdout.strip().lower()
|
||||||
|
|
@ -173,14 +218,14 @@ class DockerEnvironment(BaseEnvironment):
|
||||||
# overlay2 only supports storage-opt on XFS with pquota.
|
# overlay2 only supports storage-opt on XFS with pquota.
|
||||||
# Probe by attempting a dry-ish run — the fastest reliable check.
|
# Probe by attempting a dry-ish run — the fastest reliable check.
|
||||||
probe = subprocess.run(
|
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,
|
capture_output=True, text=True, timeout=15,
|
||||||
)
|
)
|
||||||
if probe.returncode == 0:
|
if probe.returncode == 0:
|
||||||
# Clean up the created container
|
# Clean up the created container
|
||||||
container_id = probe.stdout.strip()
|
container_id = probe.stdout.strip()
|
||||||
if container_id:
|
if container_id:
|
||||||
subprocess.run(["docker", "rm", container_id],
|
subprocess.run([docker, "rm", container_id],
|
||||||
capture_output=True, timeout=5)
|
capture_output=True, timeout=5)
|
||||||
_storage_opt_ok = True
|
_storage_opt_ok = True
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1112,9 +1112,14 @@ def check_terminal_requirements() -> bool:
|
||||||
return True
|
return True
|
||||||
elif env_type == "docker":
|
elif env_type == "docker":
|
||||||
from minisweagent.environments.docker import DockerEnvironment
|
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
|
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
|
return result.returncode == 0
|
||||||
elif env_type == "singularity":
|
elif env_type == "singularity":
|
||||||
from minisweagent.environments.singularity import SingularityEnvironment
|
from minisweagent.environments.singularity import SingularityEnvironment
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue