diff --git a/strix/config/settings.py b/strix/config/settings.py index 1458e1ff8..55db9222d 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -47,6 +47,7 @@ class RuntimeSettings(BaseSettings): alias="STRIX_IMAGE", ) backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") + socket_path: str | None = Field(default=None, alias="STRIX_RUNTIME_SOCKET") class TelemetrySettings(BaseSettings): diff --git a/strix/interface/main.py b/strix/interface/main.py index 76d36cf5e..a052e635a 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -48,12 +48,10 @@ from strix.telemetry import posthog, scarf from strix.telemetry.logging import configure_dependency_logging -HOST_GATEWAY_HOSTNAME = "host.docker.internal" - +from strix.runtime.backends import get_host_gateway import logging # noqa: E402 - logger = logging.getLogger(__name__) @@ -183,6 +181,33 @@ def validate_environment() -> None: def check_docker_installed() -> None: + settings = load_settings() + backend = settings.runtime.backend + + if backend == "podman": + if shutil.which("podman") is None: + logger.error("Podman CLI not found in PATH") + console = Console() + error_text = Text() + error_text.append("PODMAN NOT INSTALLED", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("The 'podman' CLI was not found in your PATH.\n", style="white") + error_text.append( + "Please install Podman and ensure the 'podman' command is available.\n\n", + style="white", + ) + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + console.print("\n", panel, "\n") + sys.exit(1) + logger.debug("Podman CLI present") + return + if shutil.which("docker") is None: logger.error("Docker CLI not found in PATH") console = Console() @@ -456,7 +481,8 @@ Examples: parser.error(f"Invalid target '{target}'") assign_workspace_subdirs(args.targets_info) - rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) + host_gateway = get_host_gateway(load_settings().runtime.backend) + rewrite_localhost_targets(args.targets_info, host_gateway) return args diff --git a/strix/interface/utils.py b/strix/interface/utils.py index ff53a6cd8..53f83f19a 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1329,18 +1329,38 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) def check_docker_connection() -> Any: + from strix.config import load_settings + from strix.runtime.backends import create_docker_client + + settings = load_settings() + backend = settings.runtime.backend + try: - return docker.from_env() + return create_docker_client(backend) except DockerException: console = Console() 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", - ) + + if backend == "podman": + error_text.append("PODMAN NOT AVAILABLE", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("Cannot connect to Podman daemon.\n", style="white") + error_text.append( + "Please ensure Podman is installed and running, and try running strix again.\n\n", + style="white", + ) + error_text.append( + "Tip: set STRIX_RUNTIME_SOCKET to your Podman socket path if auto-detection fails.\n", + style="dim", + ) + else: + 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", + ) panel = Panel( error_text, diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index 9f241a3ae..32e0b765f 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -3,9 +3,12 @@ from __future__ import annotations import logging +import os from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any +from strix.config import load_settings + if TYPE_CHECKING: from agents.sandbox.manifest import Manifest @@ -17,40 +20,131 @@ logger = logging.getLogger(__name__) SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]] -async def _docker_backend( +def get_host_gateway(backend_name: str) -> str: + """Return the host-gateway hostname for *backend_name*. + + Docker uses ``host.docker.internal``; Podman uses + ``host.containers.internal`` (resolved automatically by Podman's + built-in DNS, no ``--add-host`` needed). + """ + if backend_name == "podman": + return "host.containers.internal" + return "host.docker.internal" + + +def create_docker_client(backend_name: str) -> Any: + """Create a ``docker.DockerClient`` pointed at the right daemon. + + Resolution order: + 1. ``STRIX_RUNTIME_SOCKET`` env var / config (explicit) + 2. ``DOCKER_HOST`` env var (standard docker-py mechanism) + 3. Per-backend auto-detection (e.g. Podman socket probing) + 4. ``docker.from_env()`` default + """ + import docker + + settings = load_settings() + socket_path = settings.runtime.socket_path + if socket_path: + logger.debug("Using explicit runtime socket: %s", socket_path) + return docker.DockerClient(base_url=socket_path) + + if os.environ.get("DOCKER_HOST"): + return docker.from_env() + + if backend_name == "podman": + for candidate in _podman_socket_candidates(): + path = candidate.replace("unix://", "") + if os.path.exists(path): + logger.debug("Auto-detected podman socket: %s", candidate) + return docker.DockerClient(base_url=candidate) + + return docker.from_env() + + +def _podman_socket_candidates() -> list[str]: + """Return Podman socket URI candidates (rootless first, then rootful).""" + candidates: list[str] = [] + xdg_runtime = os.environ.get("XDG_RUNTIME_DIR") + if xdg_runtime: + candidates.append(f"unix://{xdg_runtime}/podman/podman.sock") + else: + try: + candidates.append(f"unix:///run/user/{os.getuid()}/podman/podman.sock") + except (AttributeError, OSError): + pass + candidates.append("unix:///run/podman/podman.sock") + return candidates + + +# -- backend factories -------------------------------------------------- + + +async def _create_sandbox( *, image: str, manifest: Manifest, exposed_ports: tuple[int, ...], + docker_client: Any, + host_gateway_hostname: str, ) -> tuple[Any, Any]: - """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. - - ``session.start()`` is what materializes the manifest entries - (LocalDir copies, mount setup, etc.) into the running container — - the SDK's ``client.create()`` only builds the inner session object - without applying the manifest. ``async with session:`` would call it - too, but 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 - client = StrixDockerSandboxClient(docker.from_env()) + client = StrixDockerSandboxClient( + docker_client, host_gateway_hostname=host_gateway_hostname + ) options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) session = await client.create(options=options, manifest=manifest) await session.start() return client, session +async def _docker_backend( + *, + image: str, + manifest: Manifest, + exposed_ports: tuple[int, ...], +) -> tuple[Any, Any]: + """Bring up a session backed by the local Docker daemon.""" + docker_client = create_docker_client("docker") + return await _create_sandbox( + image=image, + manifest=manifest, + exposed_ports=exposed_ports, + docker_client=docker_client, + host_gateway_hostname=get_host_gateway("docker"), + ) + + +async def _podman_backend( + *, + image: str, + manifest: Manifest, + exposed_ports: tuple[int, ...], +) -> tuple[Any, Any]: + """Bring up a session backed by a local Podman daemon. + + Uses the Docker-compatible API socket — the same ``docker-py`` + library drives it, just pointed at the Podman socket. + """ + docker_client = create_docker_client("podman") + return await _create_sandbox( + image=image, + manifest=manifest, + exposed_ports=exposed_ports, + docker_client=docker_client, + host_gateway_hostname=get_host_gateway("podman"), + ) + + +# -- registry ----------------------------------------------------------- + + _BACKENDS: dict[str, SandboxBackend] = { "docker": _docker_backend, + "podman": _podman_backend, } diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index fb6f68086..cde5bf776 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -42,6 +42,15 @@ logger = logging.getLogger(__name__) class StrixDockerSandboxClient(DockerSandboxClient): + def __init__( + self, + docker_client: Any, + *, + host_gateway_hostname: str = "host.docker.internal", + ) -> None: + super().__init__(docker_client) + self._host_gateway_hostname = host_gateway_hostname + async def _create_container( self, image: str, @@ -106,7 +115,7 @@ class StrixDockerSandboxClient(DockerSandboxClient): cap_add.append(cap) extra_hosts = create_kwargs.setdefault("extra_hosts", {}) - extra_hosts["host.docker.internal"] = "host-gateway" + extra_hosts[self._host_gateway_hostname] = "host-gateway" logger.debug( "Creating sandbox container: image=%s caps=%s exposed_ports=%s", diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 6f3e27338..5aa106953 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -10,7 +10,7 @@ from agents.sandbox.entries import BaseEntry, LocalDir from agents.sandbox.manifest import Environment, Manifest from strix.config import load_settings -from strix.runtime.backends import get_backend +from strix.runtime.backends import get_backend, get_host_gateway from strix.runtime.caido_bootstrap import bootstrap_caido @@ -48,6 +48,9 @@ async def create_or_reuse( continue entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve()) + backend_name = load_settings().runtime.backend + host_gateway = get_host_gateway(backend_name) + # Caido runs as an in-container sidecar; HTTP(S) traffic from any # process started via ``session.exec`` (the SDK's Shell tool, etc.) # picks up these env vars automatically. ``NO_PROXY`` keeps the @@ -59,7 +62,7 @@ async def create_or_reuse( environment=Environment( value={ "PYTHONUNBUFFERED": "1", - "HOST_GATEWAY": "host.docker.internal", + "HOST_GATEWAY": host_gateway, "http_proxy": container_caido_url, "https_proxy": container_caido_url, "ALL_PROXY": container_caido_url, @@ -68,7 +71,6 @@ async def create_or_reuse( ), ) - backend_name = load_settings().runtime.backend backend = get_backend(backend_name) logger.info(