Wait for Caido readiness by deadline

This commit is contained in:
maxi-maxima 2026-08-11 10:44:09 +08:00
parent 7cc9fa9faa
commit 18a36520a7
6 changed files with 1027 additions and 880 deletions

View file

@ -1,169 +1,174 @@
---
title: "Configuration"
description: "Environment variables for Strix"
---
Configure Strix using environment variables or a config file.
## LLM Configuration
<ParamField path="STRIX_LLM" type="string" required>
Model name in LiteLLM format (e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4-6`).
</ParamField>
<ParamField path="LLM_API_KEY" type="string">
API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).
</ParamField>
<ParamField path="LLM_API_BASE" type="string">
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
</ParamField>
<ParamField path="LLM_EXTRA_HEADERS" type="string">
Extra HTTP headers sent on every LLM request, as a JSON object (e.g.
`{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible
gateways that require attribution or routing headers in addition to the bearer
token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both
the LiteLLM and native OpenAI routing paths.
</ParamField>
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
Request timeout in seconds for LLM calls.
</ParamField>
<ParamField path="STRIX_LLM_MAX_RETRIES" default="5" type="integer">
Maximum number of retries for LLM API calls on transient failures.
</ParamField>
<ParamField path="STRIX_REASONING_EFFORT" default="high" type="string">
Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium` for quick scan mode.
</ParamField>
<ParamField path="STRIX_MEMORY_COMPRESSOR_TIMEOUT" default="30" type="integer">
Timeout in seconds for memory compression operations (context summarization).
</ParamField>
### Dedicated deduplication model
Finding deduplication is a cheap, structured classification task. By default it
runs on the main model, but you can route it to a smaller/cheaper model without
affecting the agents that do the actual testing.
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
Model used to judge whether a candidate finding duplicates an existing report.
Falls back to `STRIX_LLM` when unset.
</ParamField>
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
Optional provider key for the deduplication model.
</ParamField>
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
Optional custom API base URL for the deduplication model. Use when the dedupe
model runs on a different endpoint than the main model.
</ParamField>
<ParamField path="DEDUPE_LLM_EXTRA_HEADERS" type="string">
Optional JSON object of extra HTTP headers sent on every deduplication-model
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
</ParamField>
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
Reasoning effort for the deduplication model. Defaults to the model's own
baseline when unset.
</ParamField>
## Optional Features
<ParamField path="PERPLEXITY_API_KEY" type="string">
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
</ParamField>
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
</ParamField>
<ParamField path="TRACELOOP_BASE_URL" type="string">
OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
</ParamField>
<ParamField path="TRACELOOP_API_KEY" type="string">
API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.
</ParamField>
<ParamField path="TRACELOOP_HEADERS" type="string">
Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.
</ParamField>
When remote OTEL vars are not set, Strix still writes complete run telemetry locally to:
```bash
strix_runs/<run_name>/events.jsonl
```
When remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.3.0" type="string">
Docker image to use for the sandbox container.
</ParamField>
<ParamField path="DOCKER_HOST" type="string">
Docker daemon socket path. Use for remote Docker hosts or custom configurations.
</ParamField>
---
title: "Configuration"
description: "Environment variables for Strix"
---
Configure Strix using environment variables or a config file.
## LLM Configuration
<ParamField path="STRIX_LLM" type="string" required>
Model name in LiteLLM format (e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4-6`).
</ParamField>
<ParamField path="LLM_API_KEY" type="string">
API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).
</ParamField>
<ParamField path="LLM_API_BASE" type="string">
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
</ParamField>
<ParamField path="LLM_EXTRA_HEADERS" type="string">
Extra HTTP headers sent on every LLM request, as a JSON object (e.g.
`{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible
gateways that require attribution or routing headers in addition to the bearer
token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both
the LiteLLM and native OpenAI routing paths.
</ParamField>
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
Request timeout in seconds for LLM calls.
</ParamField>
<ParamField path="STRIX_LLM_MAX_RETRIES" default="5" type="integer">
Maximum number of retries for LLM API calls on transient failures.
</ParamField>
<ParamField path="STRIX_REASONING_EFFORT" default="high" type="string">
Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium` for quick scan mode.
</ParamField>
<ParamField path="STRIX_MEMORY_COMPRESSOR_TIMEOUT" default="30" type="integer">
Timeout in seconds for memory compression operations (context summarization).
</ParamField>
### Dedicated deduplication model
Finding deduplication is a cheap, structured classification task. By default it
runs on the main model, but you can route it to a smaller/cheaper model without
affecting the agents that do the actual testing.
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
Model used to judge whether a candidate finding duplicates an existing report.
Falls back to `STRIX_LLM` when unset.
</ParamField>
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
Optional provider key for the deduplication model.
</ParamField>
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
Optional custom API base URL for the deduplication model. Use when the dedupe
model runs on a different endpoint than the main model.
</ParamField>
<ParamField path="DEDUPE_LLM_EXTRA_HEADERS" type="string">
Optional JSON object of extra HTTP headers sent on every deduplication-model
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
</ParamField>
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
Reasoning effort for the deduplication model. Defaults to the model's own
baseline when unset.
</ParamField>
## Optional Features
<ParamField path="PERPLEXITY_API_KEY" type="string">
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
</ParamField>
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
</ParamField>
<ParamField path="TRACELOOP_BASE_URL" type="string">
OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
</ParamField>
<ParamField path="TRACELOOP_API_KEY" type="string">
API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.
</ParamField>
<ParamField path="TRACELOOP_HEADERS" type="string">
Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.
</ParamField>
When remote OTEL vars are not set, Strix still writes complete run telemetry locally to:
```bash
strix_runs/<run_name>/events.jsonl
```
When remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.3.0" type="string">
Docker image to use for the sandbox container.
</ParamField>
<ParamField path="DOCKER_HOST" type="string">
Docker daemon socket path. Use for remote Docker hosts or custom configurations.
</ParamField>
<ParamField path="STRIX_RUNTIME_BACKEND" default="docker" type="string">
Runtime backend for the sandbox environment.
</ParamField>
<ParamField path="STRIX_CAIDO_BOOT_WAIT_S" default="300" type="integer">
Maximum time in seconds to wait for the in-sandbox Caido proxy to become ready.
Increase this on slow or heavily loaded container hosts.
</ParamField>
## Sandbox Configuration
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
Maximum execution time in seconds for sandbox operations.
</ParamField>
<ParamField path="STRIX_SANDBOX_CONNECT_TIMEOUT" default="10" type="integer">
Timeout in seconds for connecting to the sandbox container.
</ParamField>
## Config File
Strix stores configuration in `~/.strix/cli-config.json`. You can also specify a custom config file:
```bash
strix --target ./app --config /path/to/config.json
```
**Config file format:**
```json
{
"env": {
"STRIX_LLM": "openai/gpt-5.4",
"LLM_API_KEY": "sk-...",
"STRIX_REASONING_EFFORT": "high"
}
}
```
## Example Setup
```bash
# Required
export STRIX_LLM="openai/gpt-5.4"
export LLM_API_KEY="sk-..."
# Optional: Enable web search
export PERPLEXITY_API_KEY="pplx-..."
# Optional: Custom timeouts
export LLM_TIMEOUT="600"
export STRIX_SANDBOX_EXECUTION_TIMEOUT="300"
```
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
Maximum execution time in seconds for sandbox operations.
</ParamField>
<ParamField path="STRIX_SANDBOX_CONNECT_TIMEOUT" default="10" type="integer">
Timeout in seconds for connecting to the sandbox container.
</ParamField>
## Config File
Strix stores configuration in `~/.strix/cli-config.json`. You can also specify a custom config file:
```bash
strix --target ./app --config /path/to/config.json
```
**Config file format:**
```json
{
"env": {
"STRIX_LLM": "openai/gpt-5.4",
"LLM_API_KEY": "sk-...",
"STRIX_REASONING_EFFORT": "high"
}
}
```
## Example Setup
```bash
# Required
export STRIX_LLM="openai/gpt-5.4"
export LLM_API_KEY="sk-..."
# Optional: Enable web search
export PERPLEXITY_API_KEY="pplx-..."
# Optional: Custom timeouts
export LLM_TIMEOUT="600"
export STRIX_SANDBOX_EXECUTION_TIMEOUT="300"
```

View file

@ -1,156 +1,157 @@
"""Strix application settings — pydantic-settings powered."""
from __future__ import annotations
from typing import Literal
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
DEFAULT_MAX_TURNS = 500
_BASE_CONFIG = SettingsConfigDict(
case_sensitive=False,
populate_by_name=True,
extra="ignore",
)
class LlmSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_LLM")
api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
repr=False,
)
api_base: str | None = Field(
default=None,
validation_alias=AliasChoices(
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
),
)
extra_headers: dict[str, str] | None = Field(
default=None,
alias="LLM_EXTRA_HEADERS",
repr=False,
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
force_required_tool_choice: bool = Field(
default=False,
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
)
prompt_cache: bool = Field(
default=True,
alias="STRIX_PROMPT_CACHE",
)
disable_streaming: bool = Field(
default=False,
alias="LLM_DISABLE_STREAMING",
)
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
max_tool_calls_per_turn: int = Field(
default=32,
ge=0,
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
)
class DedupeSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
reasoning_effort: ReasoningEffort | None = Field(
default=None,
alias="STRIX_DEDUPE_REASONING_EFFORT",
)
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False)
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
extra_headers: dict[str, str] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_HEADERS",
repr=False,
)
class ContextSettings(BaseSettings):
"""Context-window management: per-tool-output caps and history compaction."""
model_config = _BASE_CONFIG
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
fallback_context_tokens: int = Field(
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
)
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
# Floor above the truncation-notice size so a preview always fits.
tool_output_max_bytes: int = Field(
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
)
class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
alias="STRIX_IMAGE",
)
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")
class TelemetrySettings(BaseSettings):
model_config = _BASE_CONFIG
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
class IntegrationSettings(BaseSettings):
model_config = _BASE_CONFIG
perplexity_api_key: str | None = Field(
default=None,
alias="PERPLEXITY_API_KEY",
repr=False,
)
postman_api_key: str | None = Field(
default=None,
alias="POSTMAN_API_KEY",
repr=False,
)
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG
# Base URL of the Strix relay the local viewer proxies to for email
# verification and encrypted report delivery. The browser never talks to
# the relay directly; the local server is the only caller.
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
class Settings(BaseSettings):
model_config = _BASE_CONFIG
llm: LlmSettings = Field(default_factory=LlmSettings)
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
context: ContextSettings = Field(default_factory=ContextSettings)
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
"""Strix application settings — pydantic-settings powered."""
from __future__ import annotations
from typing import Literal
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
DEFAULT_MAX_TURNS = 500
_BASE_CONFIG = SettingsConfigDict(
case_sensitive=False,
populate_by_name=True,
extra="ignore",
)
class LlmSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_LLM")
api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
repr=False,
)
api_base: str | None = Field(
default=None,
validation_alias=AliasChoices(
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
),
)
extra_headers: dict[str, str] | None = Field(
default=None,
alias="LLM_EXTRA_HEADERS",
repr=False,
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
force_required_tool_choice: bool = Field(
default=False,
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
)
prompt_cache: bool = Field(
default=True,
alias="STRIX_PROMPT_CACHE",
)
disable_streaming: bool = Field(
default=False,
alias="LLM_DISABLE_STREAMING",
)
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
max_tool_calls_per_turn: int = Field(
default=32,
ge=0,
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
)
class DedupeSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
reasoning_effort: ReasoningEffort | None = Field(
default=None,
alias="STRIX_DEDUPE_REASONING_EFFORT",
)
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False)
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
extra_headers: dict[str, str] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_HEADERS",
repr=False,
)
class ContextSettings(BaseSettings):
"""Context-window management: per-tool-output caps and history compaction."""
model_config = _BASE_CONFIG
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
fallback_context_tokens: int = Field(
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
)
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
# Floor above the truncation-notice size so a preview always fits.
tool_output_max_bytes: int = Field(
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
)
class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
caido_boot_wait_s: int = Field(default=300, gt=0, alias="STRIX_CAIDO_BOOT_WAIT_S")
# 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")
class TelemetrySettings(BaseSettings):
model_config = _BASE_CONFIG
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
class IntegrationSettings(BaseSettings):
model_config = _BASE_CONFIG
perplexity_api_key: str | None = Field(
default=None,
alias="PERPLEXITY_API_KEY",
repr=False,
)
postman_api_key: str | None = Field(
default=None,
alias="POSTMAN_API_KEY",
repr=False,
)
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG
# Base URL of the Strix relay the local viewer proxies to for email
# verification and encrypted report delivery. The browser never talks to
# the relay directly; the local server is the only caller.
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
class Settings(BaseSettings):
model_config = _BASE_CONFIG
llm: LlmSettings = Field(default_factory=LlmSettings)
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
context: ContextSettings = Field(default_factory=ContextSettings)
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
viewer: ViewerSettings = Field(default_factory=ViewerSettings)

View file

@ -1,109 +1,137 @@
"""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.
"""
from __future__ import annotations
import asyncio
import contextlib
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."""
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()
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# The connected client never reaches the session bundle if project
# setup fails, so close it here to avoid leaking the transport.
with contextlib.suppress(Exception):
await client.aclose()
raise
logger.info("Caido project selected: %s", project.id)
return client
"""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.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import math
import time
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,
wait_timeout_s: float,
) -> str:
"""``session.exec`` curl to fetch a guest token until the deadline.
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.
"""
started_at = time.monotonic()
deadline = started_at + wait_timeout_s
last_err: str | None = None
attempts = 0
while attempts == 0 or time.monotonic() < deadline:
attempts += 1
remaining = deadline - time.monotonic()
exec_timeout = max(1, min(15, math.ceil(remaining)))
result = await session.exec(
"curl",
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
_LOGIN_AS_GUEST_BODY,
f"{container_url}/graphql",
timeout=exec_timeout,
)
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}"
remaining = deadline - time.monotonic()
if remaining <= 0:
break
retry_delay = min(2.0 * attempts, 8.0, remaining)
logger.debug(
"loginAsGuest attempt %d failed after %.1fs; retrying in %.1fs (%.1fs remaining): %s",
attempts,
time.monotonic() - started_at,
retry_delay,
remaining,
last_err,
)
await asyncio.sleep(retry_delay)
raise RuntimeError(
f"loginAsGuest not ready after {float(wait_timeout_s):g} seconds "
f"({attempts} attempts): {last_err}"
)
async def bootstrap_caido(
session: BaseSandboxSession,
*,
host_url: str,
container_url: str,
wait_timeout_s: float,
) -> Client:
"""Connect to the in-container Caido sidecar and select a fresh project."""
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
access_token = await _login_as_guest(
session,
container_url=container_url,
wait_timeout_s=wait_timeout_s,
)
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# The connected client never reaches the session bundle if project
# setup fails, so close it here to avoid leaking the transport.
with contextlib.suppress(Exception):
await client.aclose()
raise
logger.info("Caido project selected: %s", project.id)
return client

View file

@ -1,233 +1,235 @@
"""Per-scan sandbox session lifecycle."""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
if TYPE_CHECKING:
from strix.runtime.status import StatusSink
logger = logging.getLogger(__name__)
# In-container Caido sidecar port (matches the image's caido-cli bind).
_CONTAINER_CAIDO_PORT = 48080
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
# Manifest root inside the container; entry keys hang off this path.
_WORKSPACE_ROOT = "/workspace"
_PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex")
def _host_identity_env() -> dict[str, str]:
# Read the platform through a local so it is not narrowed to whichever OS is
# type-checking: comparing sys.platform directly makes one of these branches
# statically dead, and which one flips between Linux and macOS.
platform_name: str = sys.platform
if platform_name != "linux":
return {}
# Bind-mount ownership only needs mapping on Linux, where the container uid
# must match the host's.
return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())}
def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
bind_mounts: list[dict[str, Any]] = []
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
resolved = Path(host_path).expanduser().resolve()
target = f"{_WORKSPACE_ROOT}/{ws_subdir}"
bind_mounts.append({"source": str(resolved), "target": target, "read_only": False})
if src.get("protect_metadata"):
bind_mounts.extend(_metadata_mounts(resolved, target))
return bind_mounts
def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Path, BaseEntry]:
entries: dict[str | Path, BaseEntry] = {}
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve())
return entries
def _metadata_mounts(tree: Path, target: str) -> list[dict[str, Any]]:
mounts: list[dict[str, Any]] = []
for name in _PROTECTED_METADATA_NAMES:
metadata = tree / name
if not metadata.is_dir() and not metadata.is_file():
continue
if not metadata.resolve().is_relative_to(tree):
continue
mounts.append({"source": str(metadata), "target": f"{target}/{name}", "read_only": True})
gitdir = _gitdir_from_pointer(metadata) if metadata.is_file() else None
if gitdir is not None and gitdir.exists() and gitdir.is_relative_to(tree):
relative = gitdir.relative_to(tree).as_posix()
mounts.append(
{"source": str(gitdir), "target": f"{target}/{relative}", "read_only": True}
)
return mounts
def _gitdir_from_pointer(git_file: Path) -> Path | None:
try:
content = git_file.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in content.splitlines():
prefix, _, value = line.partition(":")
if prefix.strip() == "gitdir" and value.strip():
candidate = Path(value.strip()).expanduser()
if not candidate.is_absolute():
candidate = git_file.parent / candidate
return candidate.resolve()
return None
async def create_or_reuse(
scan_id: str,
*,
image: str,
local_sources: list[dict[str, Any]],
status_sink: StatusSink | None = None,
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
Each ``local_sources`` entry exposes its host ``source_path`` at
``/workspace/<workspace_subdir>`` inside the container.
"""
def report(phase: str) -> None:
if status_sink is not None:
status_sink(phase)
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
logger.info("Reusing existing sandbox session for scan %s", scan_id)
return cached
backend_name = load_settings().runtime.backend
backend = get_backend(backend_name)
if backend_supports_bind_mounts(backend_name):
bind_mounts = build_bind_mounts(local_sources)
entries: dict[str | Path, BaseEntry] = {}
else:
bind_mounts = []
entries = build_manifest_entries(local_sources)
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
# picks up these env vars automatically. ``NO_PROXY`` keeps the
# agent-browser CDP daemon's localhost traffic from looping back
# through Caido.
container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest(
entries=entries,
environment=Environment(
value={
"PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal",
**_host_identity_env(),
"http_proxy": container_caido_url,
"https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url,
"NO_PROXY": "localhost,127.0.0.1",
},
),
)
logger.info(
"Creating sandbox session for scan %s (backend=%s, image=%s)",
scan_id,
backend_name,
image,
)
report("Starting sandbox container")
client, session = await backend(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
report("Setting up the proxy")
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
scheme = "https" if caido_endpoint.tls else "http"
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
caido_client = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s ready and cached", scan_id)
return bundle
async def cleanup(scan_id: str) -> None:
"""Tear down ``scan_id``'s container and drop its cache entry.
Best-effort: any error during ``client.delete`` is logged and
swallowed. We never want a cleanup failure to prevent the next
scan from starting; the worst case is a stranded container that
Docker's normal reaping will catch on next ``docker prune``.
"""
bundle = _SESSION_CACHE.pop(scan_id, None)
if bundle is None:
logger.debug("cleanup(%s): no cached session", scan_id)
return
caido_client = bundle.get("caido_client")
if caido_client is not None:
try:
await caido_client.aclose()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
client = bundle["client"]
try:
await client.delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
docker_client = getattr(client, "docker_client", None)
if docker_client is not None:
try:
docker_client.close()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): docker_client.close() raised", scan_id, exc_info=True)
"""Per-scan sandbox session lifecycle."""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
if TYPE_CHECKING:
from strix.runtime.status import StatusSink
logger = logging.getLogger(__name__)
# In-container Caido sidecar port (matches the image's caido-cli bind).
_CONTAINER_CAIDO_PORT = 48080
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
# Manifest root inside the container; entry keys hang off this path.
_WORKSPACE_ROOT = "/workspace"
_PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex")
def _host_identity_env() -> dict[str, str]:
# Read the platform through a local so it is not narrowed to whichever OS is
# type-checking: comparing sys.platform directly makes one of these branches
# statically dead, and which one flips between Linux and macOS.
platform_name: str = sys.platform
if platform_name != "linux":
return {}
# Bind-mount ownership only needs mapping on Linux, where the container uid
# must match the host's.
return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())}
def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
bind_mounts: list[dict[str, Any]] = []
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
resolved = Path(host_path).expanduser().resolve()
target = f"{_WORKSPACE_ROOT}/{ws_subdir}"
bind_mounts.append({"source": str(resolved), "target": target, "read_only": False})
if src.get("protect_metadata"):
bind_mounts.extend(_metadata_mounts(resolved, target))
return bind_mounts
def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Path, BaseEntry]:
entries: dict[str | Path, BaseEntry] = {}
for src in local_sources:
ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or ""
if not ws_subdir or not host_path:
continue
entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve())
return entries
def _metadata_mounts(tree: Path, target: str) -> list[dict[str, Any]]:
mounts: list[dict[str, Any]] = []
for name in _PROTECTED_METADATA_NAMES:
metadata = tree / name
if not metadata.is_dir() and not metadata.is_file():
continue
if not metadata.resolve().is_relative_to(tree):
continue
mounts.append({"source": str(metadata), "target": f"{target}/{name}", "read_only": True})
gitdir = _gitdir_from_pointer(metadata) if metadata.is_file() else None
if gitdir is not None and gitdir.exists() and gitdir.is_relative_to(tree):
relative = gitdir.relative_to(tree).as_posix()
mounts.append(
{"source": str(gitdir), "target": f"{target}/{relative}", "read_only": True}
)
return mounts
def _gitdir_from_pointer(git_file: Path) -> Path | None:
try:
content = git_file.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in content.splitlines():
prefix, _, value = line.partition(":")
if prefix.strip() == "gitdir" and value.strip():
candidate = Path(value.strip()).expanduser()
if not candidate.is_absolute():
candidate = git_file.parent / candidate
return candidate.resolve()
return None
async def create_or_reuse(
scan_id: str,
*,
image: str,
local_sources: list[dict[str, Any]],
status_sink: StatusSink | None = None,
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
Each ``local_sources`` entry exposes its host ``source_path`` at
``/workspace/<workspace_subdir>`` inside the container.
"""
def report(phase: str) -> None:
if status_sink is not None:
status_sink(phase)
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
logger.info("Reusing existing sandbox session for scan %s", scan_id)
return cached
runtime_settings = load_settings().runtime
backend_name = runtime_settings.backend
backend = get_backend(backend_name)
if backend_supports_bind_mounts(backend_name):
bind_mounts = build_bind_mounts(local_sources)
entries: dict[str | Path, BaseEntry] = {}
else:
bind_mounts = []
entries = build_manifest_entries(local_sources)
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
# picks up these env vars automatically. ``NO_PROXY`` keeps the
# agent-browser CDP daemon's localhost traffic from looping back
# through Caido.
container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest(
entries=entries,
environment=Environment(
value={
"PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal",
**_host_identity_env(),
"http_proxy": container_caido_url,
"https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url,
"NO_PROXY": "localhost,127.0.0.1",
},
),
)
logger.info(
"Creating sandbox session for scan %s (backend=%s, image=%s)",
scan_id,
backend_name,
image,
)
report("Starting sandbox container")
client, session = await backend(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
report("Setting up the proxy")
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
scheme = "https" if caido_endpoint.tls else "http"
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
caido_client = await bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
wait_timeout_s=runtime_settings.caido_boot_wait_s,
)
bundle = {
"client": client,
"session": session,
"caido_client": caido_client,
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s ready and cached", scan_id)
return bundle
async def cleanup(scan_id: str) -> None:
"""Tear down ``scan_id``'s container and drop its cache entry.
Best-effort: any error during ``client.delete`` is logged and
swallowed. We never want a cleanup failure to prevent the next
scan from starting; the worst case is a stranded container that
Docker's normal reaping will catch on next ``docker prune``.
"""
bundle = _SESSION_CACHE.pop(scan_id, None)
if bundle is None:
logger.debug("cleanup(%s): no cached session", scan_id)
return
caido_client = bundle.get("caido_client")
if caido_client is not None:
try:
await caido_client.aclose()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
client = bundle["client"]
try:
await client.delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
docker_client = getattr(client, "docker_client", None)
if docker_client is not None:
try:
docker_client.close()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): docker_client.close() raised", scan_id, exc_info=True)

View file

@ -0,0 +1,104 @@
"""Tests for the Caido readiness deadline."""
# pyright: reportPrivateUsage=false
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
import pytest
from strix.runtime import caido_bootstrap
if TYPE_CHECKING:
from agents.sandbox.session import BaseSandboxSession
@dataclass
class _ExecResult:
success: bool
stdout: str = ""
stderr: bytes = b"connection refused"
exit_code: int = 7
def ok(self) -> bool:
return self.success
class _Session:
def __init__(self, results: list[_ExecResult]) -> None:
self._results = iter(results)
self.calls: list[tuple[tuple[str, ...], dict[str, Any]]] = []
async def exec(self, *args: str, **kwargs: Any) -> _ExecResult:
self.calls.append((args, kwargs))
return next(self._results)
class _Clock:
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.now
async def sleep(self, delay: float) -> None:
self.sleeps.append(delay)
self.now += delay
def _install_clock(monkeypatch: pytest.MonkeyPatch) -> _Clock:
clock = _Clock()
monkeypatch.setattr("strix.runtime.caido_bootstrap.time.monotonic", clock.monotonic)
monkeypatch.setattr("strix.runtime.caido_bootstrap.asyncio.sleep", clock.sleep)
return clock
@pytest.mark.asyncio
async def test_login_as_guest_retries_until_ready(
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = _install_clock(monkeypatch)
session = _Session(
[
_ExecResult(success=False),
_ExecResult(
success=True,
stdout='{"data":{"loginAsGuest":{"token":{"accessToken":"token"}}}}',
),
]
)
token = await caido_bootstrap._login_as_guest(
cast("BaseSandboxSession", session),
container_url="http://127.0.0.1:48080",
wait_timeout_s=10,
)
assert token == "token" # noqa: S105 - synthetic token fixture
assert clock.sleeps == [2.0]
assert [call[1]["timeout"] for call in session.calls] == [10, 8]
@pytest.mark.asyncio
async def test_login_as_guest_stops_at_deadline(
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = _install_clock(monkeypatch)
session = _Session([_ExecResult(success=False), _ExecResult(success=False)])
with pytest.raises(
RuntimeError,
match=r"loginAsGuest not ready after 5 seconds \(2 attempts\)",
):
await caido_bootstrap._login_as_guest(
cast("BaseSandboxSession", session),
container_url="http://127.0.0.1:48080",
wait_timeout_s=5,
)
assert clock.sleeps == [2.0, 3.0]
assert [call[1]["timeout"] for call in session.calls] == [5, 3]

View file

@ -1,218 +1,225 @@
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from pydantic import AliasChoices, Field, ValidationError
from pydantic.fields import FieldInfo
from strix.config import loader
from strix.config.settings import ContextSettings
if TYPE_CHECKING:
from pathlib import Path
_LLM_ENV_KEYS = [
"STRIX_LLM",
"LLM_API_KEY",
"OPENAI_API_KEY",
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
"STRIX_REASONING_EFFORT",
"STRIX_FORCE_REQUIRED_TOOL_CHOICE",
"LLM_TIMEOUT",
"PERPLEXITY_API_KEY",
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
# TelemetrySettings
"STRIX_TELEMETRY",
]
@pytest.fixture(autouse=True)
def _reset_loader_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset module globals and clear known env vars for deterministic runs."""
for key in _LLM_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
# --------------------------------------------------------------------------- #
# _read_json_overrides
# --------------------------------------------------------------------------- #
def test_read_json_overrides_missing_file(tmp_path: Path) -> None:
assert loader._read_json_overrides(tmp_path / "nope.json") == {}
def test_read_json_overrides_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text("{not valid json", encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_non_dict_env(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": ["not", "a", "dict"]}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "my-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
assert loader._read_json_overrides(path) == {
"llm": {"model": "my-model"},
"integrations": {"perplexity_api_key": "pk"},
}
def test_read_json_overrides_skips_keys_already_in_environ(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("STRIX_LLM", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
# env wins -> the JSON value is not surfaced as an init kwarg.
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_across_field_aliases(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# api_key resolves from AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"). The env
# sets one alias while the persisted file holds another. Env must still win, so
# the stale file value must not be surfaced as an init kwarg (which outranks env).
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"LLM_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_case_insensitively(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Settings use case_sensitive=False, so a lowercase env var also counts as set.
monkeypatch.setenv("strix_llm", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_uses_json_when_no_alias_in_environ(tmp_path: Path) -> None:
# No alias of api_key is set in the environment -> the file value is used, even
# when it is stored under a non-first alias.
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"OPENAI_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {"llm": {"api_key": "sk-file"}}
def test_tool_output_max_bytes_rejects_sub_notice_values() -> None:
with pytest.raises(ValidationError):
ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=64)
def test_tool_output_max_bytes_accepts_floor() -> None:
assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
def test_aliases_for_simple_alias() -> None:
finfo = FieldInfo(alias="SIMPLE_ALIAS")
assert loader._aliases_for(finfo) == ["SIMPLE_ALIAS"]
def test_aliases_for_alias_choices() -> None:
finfo: FieldInfo = Field( # type: ignore[assignment]
default=None,
validation_alias=AliasChoices("FIRST", "SECOND"),
)
assert loader._aliases_for(finfo) == ["FIRST", "SECOND"]
def test_aliases_for_string_validation_alias() -> None:
finfo: FieldInfo = Field(default=None, validation_alias="STR_ALIAS") # type: ignore[assignment]
assert loader._aliases_for(finfo) == ["STR_ALIAS"]
def test_aliases_for_no_alias() -> None:
assert loader._aliases_for(FieldInfo()) == []
# --------------------------------------------------------------------------- #
# apply_config_override + load_settings round-trip
# --------------------------------------------------------------------------- #
def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "round-trip-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
loader.apply_config_override(path)
settings = loader.load_settings()
assert settings.llm.model == "round-trip-model"
assert settings.integrations.perplexity_api_key == "pk"
# Second call is memoized -> same object.
assert loader.load_settings() is settings
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
first = tmp_path / "first.json"
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
second = tmp_path / "second.json"
second.write_text(json.dumps({"env": {"STRIX_LLM": "second-model"}}), encoding="utf-8")
loader.apply_config_override(first)
assert loader.load_settings().llm.model == "first-model"
loader.apply_config_override(second)
assert loader.load_settings().llm.model == "second-model"
# --------------------------------------------------------------------------- #
# persist_current
# --------------------------------------------------------------------------- #
def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "sub" / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.exists()
assert json.loads(target.read_text(encoding="utf-8")) == {
"env": {"STRIX_LLM": "persisted-model"}
}
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.stat().st_mode & 0o777 == 0o600
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from pydantic import AliasChoices, Field, ValidationError
from pydantic.fields import FieldInfo
from strix.config import loader
from strix.config.settings import ContextSettings
if TYPE_CHECKING:
from pathlib import Path
_LLM_ENV_KEYS = [
"STRIX_LLM",
"LLM_API_KEY",
"OPENAI_API_KEY",
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
"STRIX_REASONING_EFFORT",
"STRIX_FORCE_REQUIRED_TOOL_CHOICE",
"LLM_TIMEOUT",
"PERPLEXITY_API_KEY",
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
"STRIX_CAIDO_BOOT_WAIT_S",
# TelemetrySettings
"STRIX_TELEMETRY",
]
@pytest.fixture(autouse=True)
def _reset_loader_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset module globals and clear known env vars for deterministic runs."""
for key in _LLM_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
# --------------------------------------------------------------------------- #
# _read_json_overrides
# --------------------------------------------------------------------------- #
def test_read_json_overrides_missing_file(tmp_path: Path) -> None:
assert loader._read_json_overrides(tmp_path / "nope.json") == {}
def test_read_json_overrides_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text("{not valid json", encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_non_dict_env(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": ["not", "a", "dict"]}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "my-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
assert loader._read_json_overrides(path) == {
"llm": {"model": "my-model"},
"integrations": {"perplexity_api_key": "pk"},
}
def test_read_json_overrides_skips_keys_already_in_environ(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("STRIX_LLM", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
# env wins -> the JSON value is not surfaced as an init kwarg.
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_across_field_aliases(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# api_key resolves from AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"). The env
# sets one alias while the persisted file holds another. Env must still win, so
# the stale file value must not be surfaced as an init kwarg (which outranks env).
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"LLM_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_case_insensitively(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Settings use case_sensitive=False, so a lowercase env var also counts as set.
monkeypatch.setenv("strix_llm", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_uses_json_when_no_alias_in_environ(tmp_path: Path) -> None:
# No alias of api_key is set in the environment -> the file value is used, even
# when it is stored under a non-first alias.
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"OPENAI_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {"llm": {"api_key": "sk-file"}}
def test_tool_output_max_bytes_rejects_sub_notice_values() -> None:
with pytest.raises(ValidationError):
ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=64)
def test_tool_output_max_bytes_accepts_floor() -> None:
assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024
def test_runtime_caido_boot_wait_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_CAIDO_BOOT_WAIT_S", "900")
assert loader.load_settings().runtime.caido_boot_wait_s == 900
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
def test_aliases_for_simple_alias() -> None:
finfo = FieldInfo(alias="SIMPLE_ALIAS")
assert loader._aliases_for(finfo) == ["SIMPLE_ALIAS"]
def test_aliases_for_alias_choices() -> None:
finfo: FieldInfo = Field( # type: ignore[assignment]
default=None,
validation_alias=AliasChoices("FIRST", "SECOND"),
)
assert loader._aliases_for(finfo) == ["FIRST", "SECOND"]
def test_aliases_for_string_validation_alias() -> None:
finfo: FieldInfo = Field(default=None, validation_alias="STR_ALIAS") # type: ignore[assignment]
assert loader._aliases_for(finfo) == ["STR_ALIAS"]
def test_aliases_for_no_alias() -> None:
assert loader._aliases_for(FieldInfo()) == []
# --------------------------------------------------------------------------- #
# apply_config_override + load_settings round-trip
# --------------------------------------------------------------------------- #
def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "round-trip-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
loader.apply_config_override(path)
settings = loader.load_settings()
assert settings.llm.model == "round-trip-model"
assert settings.integrations.perplexity_api_key == "pk"
# Second call is memoized -> same object.
assert loader.load_settings() is settings
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
first = tmp_path / "first.json"
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
second = tmp_path / "second.json"
second.write_text(json.dumps({"env": {"STRIX_LLM": "second-model"}}), encoding="utf-8")
loader.apply_config_override(first)
assert loader.load_settings().llm.model == "first-model"
loader.apply_config_override(second)
assert loader.load_settings().llm.model == "second-model"
# --------------------------------------------------------------------------- #
# persist_current
# --------------------------------------------------------------------------- #
def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "sub" / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.exists()
assert json.loads(target.read_text(encoding="utf-8")) == {
"env": {"STRIX_LLM": "persisted-model"}
}
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.stat().st_mode & 0o777 == 0o600