refactor: collapse strix/sandbox into strix/runtime; in-sandbox Caido bootstrap

The split between ``strix/sandbox/`` and ``strix/runtime/`` was
artificial — both were managing the same backend. ``strix/sandbox/``
also collided uncomfortably with the SDK's ``agents.sandbox.*``
namespace. ``runtime/`` (which matches ``STRIX_RUNTIME_BACKEND``) is
the canonical home for everything Docker / Daytona / K8s lifecycle.

While merging, also rip out two pieces of Docker-specific coupling:

- ``caido_bootstrap`` was POSTing ``loginAsGuest`` from the host via
  ``aiohttp`` to ``http://127.0.0.1:{forwarded_port}``. That assumed
  Docker port forwarding; Daytona / K8s expose ports differently.
  Now we ``session.exec`` curl from *inside* the container — the
  SDK's runtime-agnostic exec primitive — so any backend works as
  long as it implements ``exec``. The host-side Caido ``Client``
  still uses the runtime's exposed-port URL for post-bootstrap calls,
  but that goes through the SDK's own ``resolve_exposed_port``
  abstraction (also runtime-agnostic).

- The bootstrap retry loop now doubles as the readiness probe, so
  ``healthcheck.wait_for_tcp_ready`` (and the entire
  ``healthcheck.py`` module) goes away.

Drive-by simplification: drop ``caido_host_port`` plumbing entirely.
It was only piped through ``make_agent_context`` → child contexts
without ever being read; only ``caido_client`` is consumed.

Drops ``aiohttp`` runtime dep (it stays only as a transitive of the
Caido SDK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 14:55:44 -07:00
parent 5d8436cbbb
commit 295d43b3ab
13 changed files with 152 additions and 196 deletions
+7 -5
View File
@@ -1,8 +1,10 @@
"""Strix runtime package.
"""Strix runtime — Docker-backed sandbox lifecycle on top of the Agents SDK.
- :class:`strix.runtime.strix_docker_client.StrixDockerSandboxClient` —
host-side ``DockerSandboxClient`` subclass that injects
``NET_ADMIN`` / ``NET_RAW`` capabilities and ``host.docker.internal``
extra-hosts, used by the per-scan session manager
(:mod:`strix.sandbox.session_manager`).
``DockerSandboxClient`` subclass that injects ``NET_ADMIN`` /
``NET_RAW`` capabilities and ``host.docker.internal`` extra-hosts.
- :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``.
"""
+120
View File
@@ -0,0 +1,120 @@
"""Caido client bootstrap.
The Caido CLI runs as an in-container sidecar listening on
``127.0.0.1:48080`` *inside* the sandbox. We grab a guest token by
``session.exec()``-ing curl from inside the container, then construct
a host-side :class:`caido_sdk_client.Client` against the runtime's
exposed-port URL for all subsequent SDK calls.
Running the auth dance through ``session.exec`` keeps this module
runtime-agnostic — Docker / Daytona / K8s sessions all implement
``exec`` even when their port-exposure semantics differ.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import TYPE_CHECKING
from caido_sdk_client import Client, TokenAuthOptions
from caido_sdk_client.types import CreateProjectOptions
if TYPE_CHECKING:
from agents.sandbox.session import BaseSandboxSession
logger = logging.getLogger(__name__)
_LOGIN_AS_GUEST_BODY = (
'{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}'
)
async def _login_as_guest(
session: BaseSandboxSession,
*,
container_url: str,
attempts: int = 10,
) -> str:
"""``session.exec`` curl to fetch a guest token; retry until ready.
Caido's GraphQL listener may not be up the instant the container
starts. The retry loop also doubles as the Caido readiness probe —
no separate TCP healthcheck needed.
"""
last_err: str | None = None
for i in range(1, attempts + 1):
result = await session.exec(
"curl",
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
_LOGIN_AS_GUEST_BODY,
f"{container_url}/graphql",
timeout=15,
)
if result.ok():
try:
payload = json.loads(result.stdout)
token = (
payload.get("data", {})
.get("loginAsGuest", {})
.get("token", {})
.get("accessToken")
)
if token:
return str(token)
last_err = f"loginAsGuest returned no token: {payload}"
except json.JSONDecodeError as exc:
last_err = f"unparseable response: {exc}: {result.stdout!r}"
else:
stderr = result.stderr.decode("utf-8", errors="replace")[:200]
last_err = f"curl exit {result.exit_code}: {stderr}"
logger.debug("loginAsGuest attempt %d/%d failed: %s", i, attempts, last_err)
await asyncio.sleep(min(2.0 * i, 8.0))
raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}")
async def bootstrap_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project.
Args:
session: Bound sandbox session — used for ``exec`` to call into
the in-container Caido API for the guest-login dance.
host_url: Host-reachable URL for Caido's GraphQL endpoint
(e.g. ``http://127.0.0.1:{exposed_port}``). Used by the
host-side :class:`Client` for all post-bootstrap calls.
container_url: In-container URL for Caido's GraphQL endpoint
(e.g. ``http://127.0.0.1:48080``). Used by the in-sandbox
curl for the guest-login dance.
Returns:
A connected :class:`caido_sdk_client.Client` with a temporary
``"sandbox"`` project selected.
"""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
access_token = await _login_as_guest(session, container_url=container_url)
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
logger.info("Caido project selected: %s", project.id)
return client
+151
View File
@@ -0,0 +1,151 @@
"""Per-scan sandbox session lifecycle.
One session per scan, reused across every agent in that scan's tree.
The bundle returned by :func:`create_or_reuse` carries the SDK
``client`` + ``session`` plus a ready-to-use Caido client (already
authenticated and pointing at a temporary sandbox project).
Cache strategy: a module-level dict keyed by ``scan_id``. The same scan
issuing multiple ``create_or_reuse`` calls (e.g., resume after a crash
on the host side) gets the same bundle back. ``cleanup`` is best-effort
— a leaked container is preferable to a stuck cleanup that prevents the
next scan from starting.
"""
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.runtime.caido_bootstrap import bootstrap_caido
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
# In-container Caido sidecar port (matches the image's caido-cli bind).
_CONTAINER_CAIDO_PORT = 48080
# Per-scan session cache. Module-level so a scan that bounces through
# multiple host-side processes (e.g., re-imports the module) doesn't
# spin up a second container — though in practice we expect one
# Strix process per scan.
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
async def create_or_reuse(
scan_id: str,
*,
image: str,
sources_path: Path,
) -> dict[str, Any]:
"""Return the existing bundle for ``scan_id`` or create a new one.
Args:
scan_id: Caller-provided scan identifier (used as cache key).
image: Docker image tag (e.g. ``"strix-sandbox:0.1.13"``).
sources_path: Host directory mounted into the container's
``/workspace/sources`` so the agent can read user code.
Returns the bundle dict containing ``client``, ``session``, and
``caido_client``.
"""
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
logger.info("Reusing existing sandbox session for scan %s", scan_id)
return cached
# 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.
container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest(
entries={"sources": LocalDir(src=sources_path)},
environment=Environment(
value={
"PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal",
"http_proxy": container_caido_url,
"https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url,
},
),
)
client = StrixDockerSandboxClient(docker.from_env())
options = DockerSandboxClientOptions(
image=image,
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}"
caido_client = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
}
_SESSION_CACHE[scan_id] = bundle
return bundle
async def cleanup(scan_id: str) -> None:
"""Tear down ``scan_id``'s container and drop its cache entry.
Best-effort: any error during ``client.delete`` is logged and
swallowed. We never want a cleanup failure to prevent the next
scan from starting; the worst case is a stranded container that
Docker's normal reaping will catch on next ``docker prune``.
"""
bundle = _SESSION_CACHE.pop(scan_id, None)
if bundle is None:
logger.debug("cleanup(%s): no cached session", scan_id)
return
caido_client = bundle.get("caido_client")
if caido_client is not None:
try:
await caido_client.aclose()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
try:
await bundle["client"].delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
def cached_scan_ids() -> list[str]:
"""Snapshot of currently-cached scan ids. Used by the TUI / CLI."""
return list(_SESSION_CACHE.keys())
def _reset_cache_for_tests() -> None:
"""Test helper — clears the module cache between unit tests."""
_SESSION_CACHE.clear()