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
-2
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"]
+3 -16
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,
+1 -1
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 (
+1 -1
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
+1 -3
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,
+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
@@ -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
-6
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.
"""
-84
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
-64
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})",
)
-1
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,
Generated
-2
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" },