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

View file

@ -41,7 +41,6 @@ dependencies = [
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
"aiohttp>=3.10.0",
]
[project.scripts]
@ -99,7 +98,6 @@ module = [
"cvss.*",
"docker.*",
"caido_sdk_client.*",
"aiohttp.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]

View file

@ -2,7 +2,7 @@
1. Build the per-scan ``AgentMessageBus``.
2. Bring up (or reuse) a sandbox session for ``scan_id`` via the
:mod:`strix.sandbox.session_manager`.
:mod:`strix.runtime.session_manager`.
3. Build the root ``Agent`` via :func:`build_strix_agent` and a
matching child factory via :func:`make_child_factory`.
4. Build the root context dict (bus + sandbox bundle + agent_factory).
@ -32,9 +32,7 @@ from strix.run_config_factory import (
make_agent_context,
make_run_config,
)
from strix.sandbox import session_manager
from strix.sandbox.caido_bootstrap import bootstrap_caido_client
from strix.sandbox.healthcheck import wait_for_tcp_ready
from strix.runtime import session_manager
if TYPE_CHECKING:
@ -207,16 +205,6 @@ async def run_strix_scan(
sources_path=sources_path,
)
# Wait for the Caido sidecar to come up before any agent fires its
# first request, then bootstrap the host-side Caido client.
await wait_for_tcp_ready(
"127.0.0.1",
int(bundle["caido_host_port"]),
timeout=60.0,
)
caido_client = await bootstrap_caido_client(int(bundle["caido_host_port"]))
bundle["caido_client"] = caido_client
try:
scan_mode = str(scan_config.get("scan_mode") or "deep")
is_whitebox = bool(scan_config.get("is_whitebox", False))
@ -249,8 +237,7 @@ async def run_strix_scan(
bus=bus,
sandbox_session=bundle["session"],
sandbox_client=bundle["client"],
caido_host_port=bundle["caido_host_port"],
caido_client=caido_client,
caido_client=bundle["caido_client"],
agent_id=root_id,
parent_id=None,
tracer=tracer,

View file

@ -15,7 +15,7 @@ from rich.text import Text
from strix.config import Config
from strix.entry import run_strix_scan
from strix.sandbox import session_manager
from strix.runtime import session_manager
from strix.telemetry.tracer import Tracer, set_global_tracer
from .utils import (

View file

@ -37,7 +37,7 @@ from strix.interface.tool_components.agent_message_renderer import AgentMessageR
from strix.interface.tool_components.registry import get_tool_renderer
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
from strix.interface.utils import build_tui_stats_text
from strix.sandbox import session_manager
from strix.runtime import session_manager
from strix.telemetry.tracer import Tracer, set_global_tracer

View file

@ -67,7 +67,7 @@ def make_run_config(
Args:
sandbox_session: Live sandbox session shared by every agent in
this scan (one container per scan; see
:mod:`strix.sandbox.session_manager`). ``None`` is allowed
:mod:`strix.runtime.session_manager`). ``None`` is allowed
for unit tests and dry runs.
model: Model alias passed to ``MultiProvider``. Defaults to the
production Anthropic alias.
@ -112,7 +112,6 @@ def make_agent_context(
*,
bus: AgentMessageBus,
sandbox_session: BaseSandboxSession | None,
caido_host_port: int | None,
agent_id: str,
parent_id: str | None,
tracer: Any | None,
@ -141,7 +140,6 @@ def make_agent_context(
"bus": bus,
"sandbox_session": sandbox_session,
"sandbox_client": sandbox_client,
"caido_host_port": caido_host_port,
"caido_client": caido_client,
"agent_id": agent_id,
"parent_id": parent_id,

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``.
"""

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

View file

@ -2,9 +2,9 @@
One session per scan, reused across every agent in that scan's tree.
The bundle returned by :func:`create_or_reuse` is what the per-agent
context dict reads from in ``run_config_factory.make_agent_context``
``client``, ``session``, and ``caido_host_port``.
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
@ -23,6 +23,7 @@ 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
@ -59,7 +60,7 @@ async def create_or_reuse(
``/workspace/sources`` so the agent can read user code.
Returns the bundle dict containing ``client``, ``session``, and
``caido_host_port``.
``caido_client``.
"""
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
@ -67,18 +68,18 @@ async def create_or_reuse(
return cached
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``docker exec`` (the SDK's Shell tool, etc.)
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
# picks up these env vars automatically.
caido_proxy_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
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": caido_proxy_url,
"https_proxy": caido_proxy_url,
"ALL_PROXY": caido_proxy_url,
"http_proxy": container_caido_url,
"https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url,
},
),
)
@ -92,12 +93,19 @@ async def create_or_reuse(
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)
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_host_port": caido_endpoint.port,
"caido_client": caido_client,
}
_SESSION_CACHE[scan_id] = bundle
return bundle

View file

@ -1,6 +0,0 @@
"""Strix sandbox layer on top of OpenAI Agents SDK SandboxAgent / Manifest.
- :mod:`.healthcheck` ``wait_for_tcp_ready`` for Caido bring-up.
- :mod:`.session_manager` ``create_or_reuse`` / ``cleanup`` keyed
by scan id.
"""

View file

@ -1,84 +0,0 @@
"""Caido client bootstrap.
Caido CLI runs as an in-container sidecar. We connect from the host to
its mapped port, fetch a guest token (the CLI runs with
``--allow-guests``), then create + select a temporary project so the
SDK has a project context to operate on.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from caido_sdk_client import Client, TokenAuthOptions
from caido_sdk_client.types import CreateProjectOptions
logger = logging.getLogger(__name__)
_LOGIN_AS_GUEST_QUERY = "mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"
async def _login_as_guest(url: str, *, attempts: int = 5) -> str:
"""POST ``loginAsGuest`` mutation; return the access token.
Retries up to ``attempts`` times with exponential-ish backoff, mirroring
what the legacy bash entrypoint did. The Caido sidecar may not be ready
on the first poke even after its TCP port accepts connections.
"""
last_err: Exception | None = None
async with aiohttp.ClientSession() as session:
for i in range(1, attempts + 1):
try:
async with session.post(
f"{url}/graphql",
json={"query": _LOGIN_AS_GUEST_QUERY},
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=15),
) as response:
response.raise_for_status()
payload: dict[str, Any] = await response.json()
token = (
payload.get("data", {})
.get("loginAsGuest", {})
.get("token", {})
.get("accessToken")
)
if token:
return str(token)
last_err = RuntimeError(f"loginAsGuest returned no token: {payload}")
except (aiohttp.ClientError, TimeoutError, RuntimeError) as exc:
last_err = exc
logger.debug("loginAsGuest attempt %d/%d failed: %s", i, attempts, exc)
await asyncio.sleep(min(2.0 * i, 8.0))
raise RuntimeError(f"loginAsGuest failed after {attempts} attempts: {last_err}")
async def bootstrap_caido_client(host_port: int) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project.
Args:
host_port: Resolved host port that maps to the container's Caido
GraphQL listener.
Returns:
A connected :class:`caido_sdk_client.Client` ready to use.
"""
url = f"http://127.0.0.1:{host_port}"
logger.info("Bootstrapping Caido client at %s", url)
access_token = await _login_as_guest(url)
client = Client(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

View file

@ -1,64 +0,0 @@
"""Sandbox port readiness probe used during session bring-up.
Caido's HTTPS proxy takes a few seconds to start listening after the
Docker container is created. The session manager waits for it before
returning a session bundle so that the first tool call from an agent
doesn't hit a connection refused.
:func:`wait_for_tcp_ready` is the only probe Caido serves an HTTP
forward proxy on its port and does *not* expose ``/health``. A TCP
connect is the most we can probe without sending real proxy traffic.
"""
from __future__ import annotations
import asyncio
import contextlib
class SandboxNotReadyError(Exception):
"""Raised when a sandbox port doesn't accept connections in time."""
# Default polling cadence between attempts. Balanced for CI-style
# fast bring-up (sub-second) without burning CPU when the port is
# legitimately taking a few seconds.
_DEFAULT_POLL_INTERVAL = 0.5
async def wait_for_tcp_ready(
host: str,
port: int,
*,
timeout: float = 30.0,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
) -> None:
"""Poll ``host:port`` until a TCP connect succeeds, or raise after ``timeout``.
Used for ports that don't expose an HTTP health endpoint (Caido's
forward proxy). We open the socket and immediately close it the
handshake completing is enough to confirm readiness.
"""
deadline = asyncio.get_event_loop().time() + timeout
last_error: str | None = None
while asyncio.get_event_loop().time() < deadline:
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=poll_interval * 4,
)
except (TimeoutError, OSError) as e:
last_error = type(e).__name__
else:
writer.close()
# Some servers close hard immediately after accept; we only
# care that the connect itself succeeded.
with contextlib.suppress(OSError):
await writer.wait_closed()
del reader
return
await asyncio.sleep(poll_interval)
raise SandboxNotReadyError(
f"TCP probe of {host}:{port} did not connect within {timeout}s (last error: {last_error})",
)

View file

@ -403,7 +403,6 @@ async def create_agent(
bus=bus,
sandbox_session=inner.get("sandbox_session"),
sandbox_client=inner.get("sandbox_client"),
caido_host_port=inner.get("caido_host_port"),
caido_client=inner.get("caido_client"),
agent_id=child_id,
parent_id=parent_id,

2
uv.lock generated
View file

@ -2038,7 +2038,6 @@ name = "strix-agent"
version = "0.8.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "caido-sdk-client" },
{ name = "cvss" },
{ name = "docker" },
@ -2061,7 +2060,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.10.0" },
{ name = "caido-sdk-client", specifier = ">=0.2.0" },
{ name = "cvss", specifier = ">=3.2" },
{ name = "docker", specifier = ">=7.1.0" },