mirror of
https://github.com/usestrix/strix.git
synced 2026-09-07 08:25:56 +00:00
Merge 0107bd5387 into ff5c8cc8e4
This commit is contained in:
commit
9af294a4d4
4 changed files with 157 additions and 16 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue