From b46fc2fb19a5856d2c92185e39bc87ed0f4fe728 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sun, 4 Oct 2026 21:52:44 +0000 Subject: [PATCH] fix(docker): connect like the docker CLI and explain why the daemon is unreachable docker.from_env() ignores the current docker context, so Docker Desktop on macOS (socket under ~/.docker/run unless the default-socket option is on), OrbStack and Colima reported DOCKER NOT AVAILABLE while `docker ps` worked. Every failure also printed the same "ensure Docker Desktop is running" text followed by a RuntimeError traceback. - resolve the endpoint as the CLI does: DOCKER_HOST, then DOCKER_CONTEXT or the current context, then the default socket; the sandbox backend uses the same resolution so startup and scan talk to the same daemon - classify the SDK error (socket missing, permission denied, connection refused, Windows named pipe) and print the fix for the current platform, the endpoint that was tried and the underlying error - exit 1 cleanly instead of raising after the panel - telemetry reports docker_unavailable_ --- docs/advanced/configuration.mdx | 6 +- strix/interface/utils.py | 25 +-- strix/runtime/backends.py | 11 +- strix/runtime/docker_connection.py | 187 +++++++++++++++++++++ tests/test_docker_connection.py | 251 +++++++++++++++++++++++++++++ 5 files changed, 463 insertions(+), 17 deletions(-) create mode 100644 strix/runtime/docker_connection.py create mode 100644 tests/test_docker_connection.py diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 61128d6b..9c5a7131 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -150,7 +150,11 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th - Docker daemon socket path. Use for remote Docker hosts or custom configurations. + Docker daemon address, for example `unix:///var/run/docker.sock` or `tcp://10.0.0.5:2375`. When set, Strix uses it and ignores the docker context. + + + + Docker context to use. Strix connects to the same daemon as the `docker` CLI: `DOCKER_HOST` first, then this context, then the current context from `docker context use`, then the default socket. Docker Desktop, OrbStack and Colima register their sockets as contexts, so no extra configuration is needed for them. diff --git a/strix/interface/utils.py b/strix/interface/utils.py index a61f90a6..092ed691 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1599,22 +1599,25 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) def check_docker_connection() -> Any: - import docker - from docker.errors import DockerException + from strix.runtime.docker_connection import ( + DockerConnectionError, + connect_docker, + explain_failure, + ) try: - return docker.from_env() - except DockerException as exc: - report_error("docker_unavailable", exc) + return connect_docker() + except DockerConnectionError as exc: + report_error(f"docker_unavailable_{exc.reason}", exc.cause) console = Console() + cause, fix = explain_failure(exc) error_text = Text() error_text.append("DOCKER NOT AVAILABLE", style="bold red") error_text.append("\n\n", style="white") - error_text.append("Cannot connect to Docker daemon.\n", style="white") - error_text.append( - "Please ensure Docker Desktop is installed and running, and try running strix again.\n", - style="white", - ) + error_text.append(f"{cause}\n", style="white") + error_text.append(f"{fix}\n\n", style="white") + error_text.append(f"Tried: {exc.endpoint.label}\n", style="dim") + error_text.append(exc.detail, style="dim red") panel = Panel( error_text, @@ -1624,7 +1627,7 @@ def check_docker_connection() -> Any: padding=(1, 2), ) console.print("\n", panel, "\n") - raise RuntimeError("Docker not available") from None + sys.exit(1) def image_exists(client: Any, image_name: str) -> bool: diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index ec49f7a7..0cb2218f 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -27,9 +27,10 @@ async def _docker_backend( """Bring up a session backed by the local Docker daemon. Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN / - NET_RAW caps + ``host.docker.internal`` host-gateway. Imports - ``docker`` lazily so deployments that target a non-Docker - backend don't need the docker-py library installed. + NET_RAW caps + ``host.docker.internal`` host-gateway, on the same + endpoint the startup check resolved (``DOCKER_HOST``, docker context, + default socket). Imports ``docker`` lazily so deployments that target + a non-Docker backend don't need the docker-py library installed. ``session.start()`` is what materializes the manifest into the running container — the SDK's ``client.create()`` only builds the inner session @@ -37,12 +38,12 @@ async def _docker_backend( Strix manages session lifetime explicitly via ``client.delete()`` so we trigger ``start()`` ourselves. """ - import docker from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions from strix.runtime.docker_client import StrixDockerSandboxClient + from strix.runtime.docker_connection import connect_docker - client = StrixDockerSandboxClient(docker.from_env()) + client = StrixDockerSandboxClient(connect_docker()) client.strix_bind_mounts = bind_mounts or [] options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) session = await client.create(options=options, manifest=manifest) diff --git a/strix/runtime/docker_connection.py b/strix/runtime/docker_connection.py new file mode 100644 index 00000000..ab6af27c --- /dev/null +++ b/strix/runtime/docker_connection.py @@ -0,0 +1,187 @@ +"""Connect to the Docker daemon the way the ``docker`` CLI does, and say why it failed. + +``docker.from_env()`` only looks at ``DOCKER_HOST`` and otherwise assumes +``/var/run/docker.sock`` (a named pipe on Windows). The CLI also honours the +current docker context, which is where Docker Desktop on macOS (without the +"default socket" option), OrbStack, Colima and Rancher Desktop register their +sockets. Resolving the endpoint the same way means ``docker ps`` working +implies strix works. +""" + +from __future__ import annotations + +import errno +import os +import sys +from dataclasses import dataclass +from typing import Any + +import docker # type: ignore[import-untyped, unused-ignore] +from docker.context import ContextAPI # type: ignore[import-untyped, unused-ignore] +from docker.context.config import ( # type: ignore[import-untyped, unused-ignore] + get_current_context_name, +) +from docker.errors import DockerException # type: ignore[import-untyped, unused-ignore] + + +DEFAULT_CONTEXT = "default" +DEFAULT_SOURCE = "default socket" + +SOCKET_MISSING = "socket_missing" +PERMISSION_DENIED = "permission_denied" +CONNECTION_REFUSED = "connection_refused" +UNKNOWN = "unknown" + +_ERRNO_REASONS = { + errno.EACCES: PERMISSION_DENIED, + errno.EPERM: PERMISSION_DENIED, + errno.ENOENT: SOCKET_MISSING, + errno.ECONNREFUSED: CONNECTION_REFUSED, +} +# pywintypes.error from the npipe transport: ERROR_FILE_NOT_FOUND, ERROR_ACCESS_DENIED +_WINERROR_REASONS = {2: SOCKET_MISSING, 5: PERMISSION_DENIED} +_TEXT_REASONS = ( + (("permission denied", "operation not permitted", "access is denied"), PERMISSION_DENIED), + (("no such file or directory", "cannot find the file specified"), SOCKET_MISSING), + (("connection refused", "max retries exceeded"), CONNECTION_REFUSED), +) + + +@dataclass(frozen=True) +class DockerEndpoint: + """Where strix will talk to Docker and how that address was chosen.""" + + host: str | None + source: str + tls: Any = None + + @property + def label(self) -> str: + return f"{self.host or DEFAULT_SOURCE} ({self.source})" + + +class DockerConnectionError(RuntimeError): + """The daemon at ``endpoint`` could not be reached; ``reason`` says why.""" + + def __init__(self, endpoint: DockerEndpoint, reason: str, cause: BaseException) -> None: + super().__init__(f"Cannot connect to Docker at {endpoint.label}: {root_cause_line(cause)}") + self.endpoint = endpoint + self.reason = reason + self.cause = cause + + @property + def detail(self) -> str: + return root_cause_line(self.cause) + + +def resolve_docker_endpoint(environ: dict[str, str] | None = None) -> DockerEndpoint: + """``DOCKER_HOST``, else the current docker context, else the SDK default.""" + env = os.environ if environ is None else environ + host = env.get("DOCKER_HOST", "").strip() + if host: + return DockerEndpoint(host, "DOCKER_HOST") + + name = env.get("DOCKER_CONTEXT", "").strip() or get_current_context_name() + if name != DEFAULT_CONTEXT: + try: + context = ContextAPI.get_context(name) + except Exception: # noqa: BLE001 - a broken context file must not hide Docker itself + context = None + if context is not None and context.Host: + return DockerEndpoint(context.Host, f"docker context '{name}'", context.TLSConfig) + return DockerEndpoint(None, DEFAULT_SOURCE) + + +def connect_docker() -> Any: + """Return a ``docker.DockerClient`` for the resolved endpoint or raise DockerConnectionError.""" + endpoint = resolve_docker_endpoint() + try: + if endpoint.host is None: + return docker.from_env() + return docker.DockerClient(base_url=endpoint.host, tls=endpoint.tls or False) + except DockerException as exc: + raise DockerConnectionError(endpoint, classify_failure(exc), exc) from exc + + +def classify_failure(exc: BaseException) -> str: + """Map the SDK's wrapped exception onto one of the reasons above.""" + for inner in walk_exceptions(exc): + code = getattr(inner, "errno", None) + if isinstance(code, int) and code in _ERRNO_REASONS: + return _ERRNO_REASONS[code] + winerror = getattr(inner, "winerror", None) + if isinstance(winerror, int) and winerror in _WINERROR_REASONS: + return _WINERROR_REASONS[winerror] + text = str(exc).lower() + return next( + (reason for needles, reason in _TEXT_REASONS if any(n in text for n in needles)), + UNKNOWN, + ) + + +def explain_failure(error: DockerConnectionError, platform: str | None = None) -> tuple[str, str]: + """Plain-language cause and the fix for this platform.""" + platform = platform or sys.platform + host = error.endpoint.host + source = error.endpoint.source + reason = error.reason + + if reason == PERMISSION_DENIED: + cause = "Your user is not allowed to use the Docker socket." + fix = ( + "sudo usermod -aG docker $USER, then log out and back in (or run: newgrp docker)." + if platform == "linux" + else "Run strix as the user who installed Docker, or check the socket permissions." + ) + elif reason == CONNECTION_REFUSED: + cause = f"Nothing is listening at {host}." + fix = f"Start the Docker daemon there, or fix {source}." + elif reason == SOCKET_MISSING and source != DEFAULT_SOURCE: + cause = f"{source} points at a socket that does not exist." + fix = "Start that Docker daemon, or run: docker context ls and pick one that is running." + elif reason == SOCKET_MISSING: + cause, fix = _NOT_RUNNING[platform if platform in _NOT_RUNNING else "linux"] + else: + cause = "Cannot connect to the Docker daemon." + fix = "Run: docker info in this shell. If it works, set DOCKER_HOST to the host it prints." + return cause, f"Fix: {fix}" + + +_NOT_RUNNING = { + "darwin": ( + "Docker is not running, or it only listens on its own socket.", + "Start Docker Desktop, OrbStack or Colima. If it is already running, select its " + "context: docker context use desktop-linux (or orbstack, colima).", + ), + "win32": ( + "Docker Desktop is not running.", + "Start Docker Desktop and wait until it reports running. From WSL, enable " + "Settings > Resources > WSL integration for this distro.", + ), + "linux": ( + "The Docker daemon is not running.", + "sudo systemctl start docker (Docker Desktop for Linux: " + "systemctl --user start docker-desktop).", + ), +} + + +def walk_exceptions(exc: BaseException) -> list[BaseException]: + """Every exception reachable through causes, contexts and args, outermost first.""" + seen: list[BaseException] = [] + stack = [exc] + while stack: + current = stack.pop(0) + if any(current is s for s in seen): + continue + seen.append(current) + stack.extend(link for link in (current.__cause__, current.__context__) if link is not None) + stack.extend(arg for arg in current.args if isinstance(arg, BaseException)) + return seen + + +def root_cause_line(exc: BaseException) -> str: + """The innermost exception as one line, e.g. ``FileNotFoundError: [Errno 2] ...``.""" + root = walk_exceptions(exc)[-1] + text = str(root).strip() or type(root).__name__ + return text if type(root).__name__ in text else f"{type(root).__name__}: {text}" diff --git a/tests/test_docker_connection.py b/tests/test_docker_connection.py new file mode 100644 index 00000000..c6abd09c --- /dev/null +++ b/tests/test_docker_connection.py @@ -0,0 +1,251 @@ +"""Docker endpoint resolution and the diagnostics printed when the daemon is unreachable.""" + +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from docker.errors import DockerException +from requests.exceptions import ConnectionError as RequestsConnectionError +from urllib3.exceptions import ProtocolError + +from strix.runtime import backends, docker_connection +from strix.runtime.docker_connection import ( + CONNECTION_REFUSED, + PERMISSION_DENIED, + SOCKET_MISSING, + UNKNOWN, + DockerConnectionError, + DockerEndpoint, + classify_failure, + explain_failure, + resolve_docker_endpoint, +) + + +cli_utils: Any = importlib.import_module("strix.interface.utils") + + +def _sdk_error(inner: BaseException) -> DockerException: + """Build the exception exactly as docker-py raises it for a dead daemon.""" + protocol = ProtocolError("Connection aborted.", inner) + requests_exc = RequestsConnectionError(protocol) + requests_exc.__cause__ = protocol + sdk_exc = DockerException(f"Error while fetching server API version: {requests_exc}") + sdk_exc.__cause__ = requests_exc + return sdk_exc + + +def test_docker_host_wins_over_context(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "desktop-linux") + endpoint = resolve_docker_endpoint({"DOCKER_HOST": "tcp://10.0.0.5:2375"}) + assert endpoint == DockerEndpoint("tcp://10.0.0.5:2375", "DOCKER_HOST") + + +def test_current_context_is_used_like_the_cli(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "desktop-linux") + monkeypatch.setattr( + "strix.runtime.docker_connection.ContextAPI.get_context", + lambda _name: SimpleNamespace( + Host="unix:///Users/me/.docker/run/docker.sock", TLSConfig=None + ), + ) + endpoint = resolve_docker_endpoint({}) + assert endpoint.host == "unix:///Users/me/.docker/run/docker.sock" + assert endpoint.source == "docker context 'desktop-linux'" + + +def test_docker_context_env_overrides_the_config_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default") + seen: list[str] = [] + + def get_context(name: str) -> SimpleNamespace: + seen.append(name) + return SimpleNamespace(Host="unix:///run/user/1000/orbstack.sock", TLSConfig=None) + + monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", get_context) + endpoint = resolve_docker_endpoint({"DOCKER_CONTEXT": "orbstack"}) + assert seen == ["orbstack"] + assert endpoint.source == "docker context 'orbstack'" + + +def test_default_context_and_broken_context_fall_back_to_the_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default") + assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket") + + monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "gone") + + def boom(_name: str) -> SimpleNamespace: + raise ValueError("bad meta.json") + + monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", boom) + assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket") + + +@pytest.mark.parametrize( + ("inner", "reason"), + [ + (FileNotFoundError(2, "No such file or directory"), SOCKET_MISSING), + (PermissionError(13, "Permission denied"), PERMISSION_DENIED), + (ConnectionRefusedError(111, "Connection refused"), CONNECTION_REFUSED), + ], +) +def test_classifies_the_wrapped_os_error(inner: OSError, reason: str) -> None: + exc = _sdk_error(inner) + assert classify_failure(exc) == reason + assert docker_connection.root_cause_line(exc).startswith(type(inner).__name__) + + +def test_classifies_windows_named_pipe_errors() -> None: + class PyWinError(Exception): + def __init__(self, winerror: int) -> None: + super().__init__(winerror, "CreateFile", "The system cannot find the file specified.") + self.winerror = winerror + + assert classify_failure(_sdk_error(PyWinError(2))) == SOCKET_MISSING + assert classify_failure(_sdk_error(PyWinError(5))) == PERMISSION_DENIED + + +def test_falls_back_to_the_message_text_then_unknown() -> None: + assert ( + classify_failure( + DockerException("Error while fetching server API version: Permission denied") + ) + == PERMISSION_DENIED + ) + assert classify_failure(DockerException("something else entirely")) == UNKNOWN + + +@pytest.mark.parametrize( + ("platform", "needle"), + [ + ("darwin", "docker context use desktop-linux"), + ("win32", "Start Docker Desktop"), + ("linux", "systemctl start docker"), + ], +) +def test_not_running_fix_is_per_platform(platform: str, needle: str) -> None: + error = DockerConnectionError( + DockerEndpoint(None, "default socket"), SOCKET_MISSING, FileNotFoundError(2, "x") + ) + cause, fix = explain_failure(error, platform=platform) + assert "not running" in cause + assert needle in fix + + +def test_permission_fix_names_the_docker_group_on_linux() -> None: + error = DockerConnectionError( + DockerEndpoint(None, "default socket"), PERMISSION_DENIED, PermissionError(13, "x") + ) + assert "usermod -aG docker" in explain_failure(error, platform="linux")[1] + assert "usermod" not in explain_failure(error, platform="darwin")[1] + + +def test_explicit_endpoint_failures_name_their_source() -> None: + missing = DockerConnectionError( + DockerEndpoint("unix:///x.sock", "docker context 'colima'"), + SOCKET_MISSING, + FileNotFoundError(2, "x"), + ) + assert explain_failure(missing, platform="darwin")[0].startswith("docker context 'colima'") + + refused = DockerConnectionError( + DockerEndpoint("tcp://127.0.0.1:1", "DOCKER_HOST"), + CONNECTION_REFUSED, + ConnectionRefusedError(111, "x"), + ) + cause, fix = explain_failure(refused, platform="linux") + assert "tcp://127.0.0.1:1" in cause + assert "DOCKER_HOST" in fix + + +def test_connect_docker_raises_a_classified_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + docker_connection, + "resolve_docker_endpoint", + lambda _environ=None: DockerEndpoint("unix:///nope.sock", "DOCKER_HOST"), + ) + + def dead_client(**_kwargs: Any) -> None: + raise _sdk_error(FileNotFoundError(2, "No such file or directory")) + + monkeypatch.setattr("strix.runtime.docker_connection.docker.DockerClient", dead_client) + + with pytest.raises(DockerConnectionError) as info: + docker_connection.connect_docker() + + assert info.value.reason == SOCKET_MISSING + assert info.value.endpoint.host == "unix:///nope.sock" + assert "FileNotFoundError" in info.value.detail + + +def test_check_docker_connection_prints_the_fix_and_exits( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + reported: list[tuple[str, type | None]] = [] + monkeypatch.setattr( + cli_utils, "report_error", lambda name, exc=None: reported.append((name, type(exc))) + ) + error = DockerConnectionError( + DockerEndpoint(None, "default socket"), + PERMISSION_DENIED, + _sdk_error(PermissionError(13, "Permission denied")), + ) + + def failing_connect() -> None: + raise error + + monkeypatch.setattr(docker_connection, "connect_docker", failing_connect) + monkeypatch.setattr("strix.runtime.docker_connection.sys.platform", "linux") + + with pytest.raises(SystemExit) as exit_info: + cli_utils.check_docker_connection() + + out = capsys.readouterr().out + assert exit_info.value.code == 1 + assert reported == [("docker_unavailable_permission_denied", DockerException)] + assert "DOCKER NOT AVAILABLE" in out + assert "usermod -aG docker" in out + assert "Tried: default socket (default socket)" in out + assert "PermissionError" in out + + +def test_check_docker_connection_returns_the_client(monkeypatch: pytest.MonkeyPatch) -> None: + client = object() + monkeypatch.setattr(docker_connection, "connect_docker", lambda: client) + assert cli_utils.check_docker_connection() is client + + +@pytest.mark.asyncio +async def test_sandbox_backend_uses_the_same_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + resolved = object() + monkeypatch.setattr(docker_connection, "connect_docker", lambda: resolved) + captured: dict[str, Any] = {} + + class FakeSession: + async def start(self) -> None: + captured["started"] = True + + class FakeClient: + def __init__(self, docker_client: Any) -> None: + captured["docker_client"] = docker_client + + async def create(self, *, options: Any, **_kwargs: Any) -> FakeSession: + captured["image"] = options.image + return FakeSession() + + monkeypatch.setattr("strix.runtime.docker_client.StrixDockerSandboxClient", FakeClient) + + client, session = await backends._docker_backend( + image="img:1", manifest=cast("Any", SimpleNamespace()), exposed_ports=(8080,) + ) + + assert captured["docker_client"] is resolved + assert captured["image"] == "img:1" + assert captured["started"] is True + assert isinstance(client, FakeClient) + assert isinstance(session, FakeSession)