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)