diff --git a/pyproject.toml b/pyproject.toml index f30c8f1..00fc2e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -204,6 +204,9 @@ ignore = [ "TC003", # collections.abc.AsyncIterator imported for return type ] # Custom Docker subclass duplicates parent body; some imports are for annotations. +# Backend factories import their backend's deps lazily so deployments +# that pick a different backend don't need every backend's libs installed. +"strix/runtime/backends.py" = ["PLC0415"] "strix/runtime/docker_client.py" = [ "TC002", # Manifest, Container imported for annotations "TC003", # uuid imported for annotation diff --git a/strix/runtime/__init__.py b/strix/runtime/__init__.py index d82ec7a..a468625 100644 --- a/strix/runtime/__init__.py +++ b/strix/runtime/__init__.py @@ -1,10 +1,15 @@ -"""Strix runtime — Docker-backed sandbox lifecycle on top of the Agents SDK. +"""Strix runtime — pluggable sandbox lifecycle on top of the Agents SDK. -- :class:`strix.runtime.docker_client.StrixDockerSandboxClient` — - ``DockerSandboxClient`` subclass that injects ``NET_ADMIN`` / - ``NET_RAW`` capabilities and ``host.docker.internal`` extra-hosts. +- :mod:`.backends` — registry mapping ``STRIX_RUNTIME_BACKEND`` values + to async factories that bring up a ``(client, session)`` pair. Ships + with ``"docker"`` out of the box; ``register_backend`` lets downstream + users add Daytona / K8s / Modal / etc. without forking. - :mod:`.session_manager` — ``create_or_reuse`` / ``cleanup`` keyed by scan id; bundles the SDK session with a ready Caido client. - :mod:`.caido_bootstrap` — runtime-agnostic Caido auth dance via ``session.exec``. +- :class:`strix.runtime.docker_client.StrixDockerSandboxClient` — + ``DockerSandboxClient`` subclass that injects ``NET_ADMIN`` / + ``NET_RAW`` capabilities and ``host.docker.internal`` extra-hosts + (used only by the Docker backend). """ diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py new file mode 100644 index 0000000..8cb02bb --- /dev/null +++ b/strix/runtime/backends.py @@ -0,0 +1,93 @@ +"""Sandbox backend registry — runtime-agnostic session bring-up. + +A *backend* is an async callable that takes an image tag + an SDK +:class:`Manifest` + the ports to expose, and returns the matching +``(client, session)`` pair. The caller owns lifecycle from there +(``await client.delete(session)``). + +This keeps :mod:`strix.runtime.session_manager` free of any +backend-specific imports — switching to Daytona / K8s / Modal / +whatever is one new factory function plus one registry entry. + +Selection is driven by ``STRIX_RUNTIME_BACKEND`` (default: ``"docker"``). +Unknown values raise :class:`ValueError` rather than silently falling +back, so typos fail loudly. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from agents.sandbox.manifest import Manifest + + +# A backend brings up a fresh session and returns the (client, session) +# pair. The client is whatever object exposes ``await client.delete(session)`` +# for cleanup — typically an ``agents.sandbox.client.BaseSandboxClient`` +# subclass, but the protocol is duck-typed so non-SDK backends could +# also plug in if they implement the same interface. +SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]] + + +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. + + 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. + """ + import docker + from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions + + from strix.runtime.docker_client import StrixDockerSandboxClient + + client = StrixDockerSandboxClient(docker.from_env()) + options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) + session = await client.create(options=options, manifest=manifest) + return client, session + + +_BACKENDS: dict[str, SandboxBackend] = { + "docker": _docker_backend, +} + + +def get_backend(name: str) -> SandboxBackend: + """Return the backend factory for ``name`` or raise. + + Args: + name: Backend identifier (e.g. ``"docker"``). Match is exact; + no fallback. Unknown values raise so config typos surface + immediately instead of silently picking a default. + """ + backend = _BACKENDS.get(name) + if backend is None: + supported = ", ".join(sorted(_BACKENDS)) + raise ValueError( + f"Unknown STRIX_RUNTIME_BACKEND: {name!r} (supported: {supported})", + ) + return backend + + +def register_backend(name: str, backend: SandboxBackend) -> None: + """Register a custom backend under ``name``. + + Intended for downstream users who ship their own runtime — register + before any ``session_manager.create_or_reuse`` call. Re-registering + an existing name overwrites the prior entry. + """ + _BACKENDS[name] = backend + + +def supported_backends() -> list[str]: + """Snapshot of registered backend names. Useful for ``--help`` text.""" + return sorted(_BACKENDS) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index ea3a13b..326da68 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -18,13 +18,12 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -import docker from agents.sandbox.entries import LocalDir from agents.sandbox.manifest import Environment, Manifest -from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions +from strix.config.config import Config +from strix.runtime.backends import get_backend from strix.runtime.caido_bootstrap import bootstrap_caido -from strix.runtime.docker_client import StrixDockerSandboxClient if TYPE_CHECKING: @@ -84,15 +83,21 @@ async def create_or_reuse( ), ) - client = StrixDockerSandboxClient(docker.from_env()) - options = DockerSandboxClientOptions( + backend_name = Config.get("strix_runtime_backend") or "docker" + backend = get_backend(backend_name) + + logger.info( + "Creating sandbox session for scan %s (backend=%s, image=%s)", + scan_id, + backend_name, + image, + ) + client, session = await backend( image=image, + manifest=manifest, exposed_ports=(_CONTAINER_CAIDO_PORT,), ) - logger.info("Creating sandbox session for scan %s (image=%s)", scan_id, image) - session = await client.create(options=options, manifest=manifest) - caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT) host_caido_url = f"http://{caido_endpoint.host}:{caido_endpoint.port}"