Files
strix/strix/sandbox/healthcheck.py
T
0xallam 1d86e4506a feat(migration): phase 4 — sandbox capability + healthcheck + session manager
Three modules under strix/sandbox/ that bring the per-scan container
plumbing in line with the SDK's capability model:

- healthcheck.py: wait_for_http_ready (FastAPI tool server /health)
  and wait_for_tcp_ready (Caido proxy port — no /health endpoint).
  Connect/timeout errors continue polling; the timeout error message
  carries the last failure class so a stuck scan tells you whether the
  port refused, hung, or returned a non-2xx.

- caido_capability.py: CaidoCapability subclasses agents.sandbox.
  capabilities.Capability and wires three concerns:
  1. process_manifest injects http_proxy / https_proxy / ALL_PROXY
     env vars pointing at the in-container Caido listener.
  2. tools() returns the seven Caido SDK function tools from Phase 2.5
     so the SDK runtime auto-merges them with each agent's tool list.
  3. bind() schedules an asyncio.gather of both healthcheck probes;
     StrixOrchestrationHooks.on_agent_start awaits the resulting
     task before the first LLM call.
  Pydantic v2 PrivateAttr is used for the underscore-prefixed runtime
  fields (Pydantic forbids underscore-prefixed model fields).

- session_manager.py: per-scan_id cache. create_or_reuse builds the
  StrixDockerSandboxClient with docker.from_env() (the SDK's docker
  client now requires an explicit DockerSDKClient instance at init),
  constructs the Manifest via Environment(value=...) (a flat dict is
  silently dropped by Pydantic), resolves the host-side mapped ports
  via session._resolve_exposed_port, configures the capability with
  those ports *before* binding, and returns a bundle dict the
  per-agent context reads to populate tool_server_host_port /
  caido_host_port / bearer. cleanup is best-effort: a Docker daemon
  error during delete is logged and swallowed so a stranded
  container doesn't block the next scan.

Tests: 21 new tests in tests/sandbox/ — healthcheck happy path /
polling-through-failures / timeout for both HTTP and TCP probes (the
TCP test uses a real local listener, no mocks); CaidoCapability env
injection / tool list / bind scheduling / configure_host_ports;
session_manager full create flow, cache reuse, custom timeout, cleanup
including the Docker-daemon-failure swallow path.

mypy override added for docker.* (no upstream stubs); per-file ruff
TC002 ignore added for caido_capability.py — agents.tool.Tool is used
at runtime for the cached _CAIDO_TOOLS tuple.

Refs: PLAYBOOK.md §3.1-3.3, AUDIT.md §2.5 (C5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:49:26 -07:00

122 lines
4.3 KiB
Python

"""Sandbox port readiness probes used during session bring-up.
The in-container tool server (FastAPI) takes a few seconds to start
listening after the Docker container is created, and Caido's HTTPS
proxy takes a similar window. The session manager waits for both
before returning a session bundle so that the first tool call from
an agent doesn't hit a connection refused.
Two helpers are exposed:
- :func:`wait_for_http_ready` for the FastAPI tool server, whose
``/health`` endpoint returns ``{"status": "healthy"}`` once the
process is up. We don't require the JSON shape exactly — any 2xx
is treated as ready, mirroring the legacy ``_wait_for_tool_server``
but more lenient (the legacy version checked the JSON body too,
which made test images without that handler fail spuriously).
- :func:`wait_for_tcp_ready` for Caido, which 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.
References:
- PLAYBOOK.md §3.1
- HARNESS_WIKI.md §6.4 (legacy ``_wait_for_tool_server`` pattern)
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
logger = logging.getLogger(__name__)
class SandboxNotReadyError(Exception):
"""Raised when a sandbox port doesn't accept connections in time."""
# Default per-attempt HTTP timeout. The legacy harness used 5s; we
# match it so a slow first request (image still warming up) doesn't
# misfire as a hard failure on a single attempt.
_DEFAULT_HTTP_PROBE_TIMEOUT = 5.0
# 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_http_ready(
url: str,
*,
timeout: float = 30.0,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
probe_timeout: float = _DEFAULT_HTTP_PROBE_TIMEOUT,
) -> None:
"""Poll ``url`` until any 2xx response, or raise after ``timeout``.
Network errors (ConnectError / TimeoutException / RequestError)
are treated as "not ready yet" — the loop continues. Any other
exception class will surface immediately so a programmer error
(bad URL, etc.) doesn't get silently retried for 30 seconds.
"""
deadline = asyncio.get_event_loop().time() + timeout
last_error: str | None = None
async with httpx.AsyncClient(timeout=probe_timeout, trust_env=False) as client:
while asyncio.get_event_loop().time() < deadline:
try:
response = await client.get(url)
if 200 <= response.status_code < 300:
return
last_error = f"HTTP {response.status_code}"
except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError) as e:
last_error = type(e).__name__
await asyncio.sleep(poll_interval)
raise SandboxNotReadyError(
f"HTTP probe of {url} did not return 2xx within {timeout}s (last error: {last_error})",
)
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})",
)