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_<reason>
This commit is contained in:
Ahmed Allam 2026-10-04 21:52:44 +00:00 • committed by Ahmed Allam
parent fc85e0061e
commit b46fc2fb19
5 changed files with 463 additions and 17 deletions

View file

@ -150,7 +150,11 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
</ParamField>
<ParamField path="DOCKER_HOST" type="string">
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.
</ParamField>
<ParamField path="DOCKER_CONTEXT" type="string">
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.
</ParamField>
<ParamField path="STRIX_RUNTIME_BACKEND" default="docker" type="string">

View file

@ -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:

View file

@ -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)

View file

@ -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}"

View file

@ -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)