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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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})",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user