diff --git a/pyproject.toml b/pyproject.toml index 0f2388f..c3a9b15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/strix/entry.py b/strix/entry.py index 4bbcc32..26e40c3 100644 --- a/strix/entry.py +++ b/strix/entry.py @@ -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, diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 0da8d23..3c6418c 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -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 ( diff --git a/strix/interface/tui.py b/strix/interface/tui.py index afd7e65..b3c0e73 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -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 diff --git a/strix/run_config_factory.py b/strix/run_config_factory.py index 1c74087..a6c9916 100644 --- a/strix/run_config_factory.py +++ b/strix/run_config_factory.py @@ -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, diff --git a/strix/runtime/__init__.py b/strix/runtime/__init__.py index 9c35b40..bf46f4d 100644 --- a/strix/runtime/__init__.py +++ b/strix/runtime/__init__.py @@ -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``. """ diff --git a/strix/runtime/caido_bootstrap.py b/strix/runtime/caido_bootstrap.py new file mode 100644 index 0000000..59f5a72 --- /dev/null +++ b/strix/runtime/caido_bootstrap.py @@ -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 diff --git a/strix/sandbox/session_manager.py b/strix/runtime/session_manager.py similarity index 82% rename from strix/sandbox/session_manager.py rename to strix/runtime/session_manager.py index 6be5bf8..603a28f 100644 --- a/strix/sandbox/session_manager.py +++ b/strix/runtime/session_manager.py @@ -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 diff --git a/strix/sandbox/__init__.py b/strix/sandbox/__init__.py deleted file mode 100644 index ee39017..0000000 --- a/strix/sandbox/__init__.py +++ /dev/null @@ -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. -""" diff --git a/strix/sandbox/caido_bootstrap.py b/strix/sandbox/caido_bootstrap.py deleted file mode 100644 index 7e41d6a..0000000 --- a/strix/sandbox/caido_bootstrap.py +++ /dev/null @@ -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 diff --git a/strix/sandbox/healthcheck.py b/strix/sandbox/healthcheck.py deleted file mode 100644 index 162d0d0..0000000 --- a/strix/sandbox/healthcheck.py +++ /dev/null @@ -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})", - ) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 8deb06b..c919a0e 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -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, diff --git a/uv.lock b/uv.lock index 2bd4570..da4442a 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },