diff --git a/strix/config/settings.py b/strix/config/settings.py index 9309ac39..1293b4c9 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -112,6 +112,12 @@ class RuntimeSettings(BaseSettings): backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") # Max screenshot/image tool outputs kept live per agent context (0 = none). max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") + # Wall-clock budget for the Caido readiness probe (session_manager -> + # bootstrap_caido -> _login_as_guest). The sandbox entrypoint chowns a + # large toolchain tree before starting caido-cli, which can take minutes + # on a loaded host or CI runner; a fixed attempt count gives up too early + # there. Raise this on slow/shared hosts instead of patching the retry loop. + caido_boot_wait_s: float = Field(default=180.0, gt=0, alias="STRIX_CAIDO_BOOT_WAIT_S") class TelemetrySettings(BaseSettings): diff --git a/strix/runtime/caido_bootstrap.py b/strix/runtime/caido_bootstrap.py index a9c7c82a..537ef756 100644 --- a/strix/runtime/caido_bootstrap.py +++ b/strix/runtime/caido_bootstrap.py @@ -13,6 +13,7 @@ import asyncio import contextlib import json import logging +import time from typing import TYPE_CHECKING @@ -33,16 +34,26 @@ async def _login_as_guest( session: BaseSandboxSession, *, container_url: str, - attempts: int = 10, + max_wait_s: float = 180.0, ) -> 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. + starts — the sandbox entrypoint chowns a large toolchain tree first, + which can take minutes on a loaded host or CI runner. The retry loop + also doubles as the Caido readiness probe — no separate TCP + healthcheck needed — so it is bounded by wall-clock time rather than + a fixed attempt count, to accommodate slow boots without making fast + hosts wait needlessly. """ + deadline = time.monotonic() + max_wait_s last_err: str | None = None - for i in range(1, attempts + 1): + attempt = 0 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + attempt += 1 result = await session.exec( "curl", "-fsS", @@ -53,7 +64,7 @@ async def _login_as_guest( "-d", _LOGIN_AS_GUEST_BODY, f"{container_url}/graphql", - timeout=15, + timeout=min(15.0, remaining), ) if result.ok(): try: @@ -72,10 +83,16 @@ async def _login_as_guest( 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}") + remaining = deadline - time.monotonic() + if remaining <= 0: + break + logger.debug("loginAsGuest attempt %d failed: %s", attempt, last_err) + await asyncio.sleep(min(2.0 * attempt, 8.0, remaining)) + + raise RuntimeError( + f"loginAsGuest failed after {attempt} attempts over {max_wait_s:.0f}s: {last_err}" + ) async def bootstrap_caido( @@ -83,6 +100,7 @@ async def bootstrap_caido( *, host_url: str, container_url: str, + boot_wait_s: float = 180.0, ) -> Client: """Connect to the in-container Caido sidecar and select a fresh project.""" # The Caido SDK (and its generated GraphQL schema) is slow to import and is @@ -93,7 +111,9 @@ async def bootstrap_caido( logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url) - access_token = await _login_as_guest(session, container_url=container_url) + access_token = await _login_as_guest( + session, container_url=container_url, max_wait_s=boot_wait_s + ) client = Client(host_url, auth=TokenAuthOptions(token=access_token)) try: diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 3c95d31a..019934c8 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -359,6 +359,7 @@ async def create_or_reuse( session, host_url=host_caido_url, container_url=container_caido_url, + boot_wait_s=load_settings().runtime.caido_boot_wait_s, ), name=f"caido-bootstrap-{scan_id}", ) diff --git a/tests/test_caido_bootstrap.py b/tests/test_caido_bootstrap.py index 98e42a94..9e961d35 100644 --- a/tests/test_caido_bootstrap.py +++ b/tests/test_caido_bootstrap.py @@ -1,20 +1,134 @@ -"""A bootstrap that dies mid-setup must not leave its transport behind. +"""Caido bootstrap tests. -The bootstrap now runs concurrently with the scan start, so teardown can -cancel it at any await — including inside ``Client.connect()``, where the -client exists but no caller will ever see it to close it. +Covers two concerns: + +* the guest-login readiness probe is bounded by a configurable wall-clock + budget (not a fixed attempt count), and its per-attempt curl timeout never + runs past that deadline; and +* a bootstrap that dies mid-setup must not leave its transport behind. The + bootstrap now runs concurrently with the scan start, so teardown can cancel + it at any await -- including inside ``Client.connect()``, where the client + exists but no caller will ever see it to close it. """ from __future__ import annotations import asyncio +import json import sys import types +from dataclasses import dataclass, field from typing import Any import pytest -from strix.runtime.caido_bootstrap import bootstrap_caido +from strix.runtime.caido_bootstrap import _login_as_guest, bootstrap_caido + + +@dataclass +class _FakeResult: + exit_code: int + stdout: str = "" + stderr: bytes = b"" + + def ok(self) -> bool: + return self.exit_code == 0 + + +@dataclass +class _FakeSession: + """Stands in for BaseSandboxSession; returns queued exec() results.""" + + results: list[_FakeResult] + sleeps: list[float] = field(default_factory=list) + timeouts: list[float] = field(default_factory=list) + calls: int = 0 + + async def exec(self, *_args: Any, **kwargs: Any) -> _FakeResult: + result = self.results[min(self.calls, len(self.results) - 1)] + self.calls += 1 + self.timeouts.append(kwargs["timeout"]) + return result + + +_FAKE_TOKEN = "guest-token" # noqa: S105 - test fixture, not a real credential + + +def _token_result() -> _FakeResult: + body = {"data": {"loginAsGuest": {"token": {"accessToken": _FAKE_TOKEN}}}} + return _FakeResult(exit_code=0, stdout=json.dumps(body)) + + +def _refused_result() -> _FakeResult: + return _FakeResult(exit_code=7, stderr=b"curl: (7) Failed to connect") + + +async def test_login_as_guest_succeeds_once_caido_is_up(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("strix.runtime.caido_bootstrap.asyncio.sleep", _no_sleep) + session = _FakeSession(results=[_refused_result(), _refused_result(), _token_result()]) + + token = await _login_as_guest(session, container_url="http://127.0.0.1:48080", max_wait_s=30) + + assert token == _FAKE_TOKEN + assert session.calls == 3 + + +async def test_login_as_guest_respects_configurable_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A slow-booting sandbox (connection refused throughout) should be given + the full configured budget rather than giving up after a fixed attempt + count, and the raised error should report elapsed time, not attempts. + """ + monkeypatch.setattr("strix.runtime.caido_bootstrap.asyncio.sleep", _no_sleep) + + # Fake clock: each read advances by 25s, so a 180s budget survives + # ~7 attempts (the old fixed-10-attempt loop only had ~68s total). + fake_now = [0.0] + + def _fake_monotonic() -> float: + fake_now[0] += 25.0 + return fake_now[0] + + monkeypatch.setattr("strix.runtime.caido_bootstrap.time.monotonic", _fake_monotonic) + session = _FakeSession(results=[_refused_result()]) + + with pytest.raises(RuntimeError) as exc_info: + await _login_as_guest(session, container_url="http://127.0.0.1:48080", max_wait_s=180) + + assert "curl exit 7" in str(exc_info.value) + assert "180s" in str(exc_info.value) + assert session.calls >= 2 + + +async def test_login_as_guest_caps_final_attempt_timeout_to_remaining_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-attempt curl timeout must never let a single attempt run past + the overall deadline -- otherwise a request started just before the + deadline can block session creation for up to another 15s beyond the + configured STRIX_CAIDO_BOOT_WAIT_S budget. + """ + monkeypatch.setattr("strix.runtime.caido_bootstrap.asyncio.sleep", _no_sleep) + + # First read establishes the deadline; second leaves 3s remaining for the + # attempt; every read after that is past the deadline so the loop exits. + fake_now = iter([0.0, 7.0]) + + def _fake_monotonic() -> float: + return next(fake_now, 11.0) + + monkeypatch.setattr("strix.runtime.caido_bootstrap.time.monotonic", _fake_monotonic) + session = _FakeSession(results=[_refused_result()]) + + with pytest.raises(RuntimeError): + await _login_as_guest(session, container_url="http://127.0.0.1:48080", max_wait_s=10) + + assert session.timeouts == [3.0] + + +async def _no_sleep(_seconds: float) -> None: + """asyncio.sleep stub so deadline-bound tests run instantly.""" class _FakeExecResult: @@ -28,7 +142,7 @@ class _FakeExecResult: return True -class _FakeSession: +class _FakeLoginSession: async def exec(self, *_args: Any, **_kwargs: Any) -> _FakeExecResult: return _FakeExecResult('{"data":{"loginAsGuest":{"token":{"accessToken":"t"}}}}') @@ -62,7 +176,7 @@ async def _bootstrap_expecting( with pytest.raises(type(error)): await bootstrap_caido( - _FakeSession(), # type: ignore[arg-type] + _FakeLoginSession(), # type: ignore[arg-type] host_url="http://host", container_url="http://container", )