feat(runtime): support pull-never sandbox images

This commit is contained in:
maxi-maxima 2026-08-12 10:18:51 +08:00
parent 7cc9fa9faa
commit c428874108
7 changed files with 379 additions and 871 deletions

View file

@ -1,169 +1,17 @@
---
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>
## 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"
```
­r‡^Ñf¥–ئ{[r‰Ý°ë­¦ëKKKB<4B>]Nˆ<08>ÛÛ™šYÝ\˜][Ûˆ‚™\ØÜš\[ÛŽˆ‘[<5B>š\›Û›Y[<5B>˜\šXX›\ț܈ݚ^‚‹KKB‚<42>ÛÛ™šYÝ\™HÝš^\Ú[™È[<5B>š\›Û›Y[<5B>˜\šXX›\È܈HÛÛ™šYÈš[K‚‚ˆÈÈHÛÛ™šYÝ\˜][Û‚‚<E2809A>\˜[QšY[]H”Õ’VÓHˆ\OHœÝš[™Èˆ™\]Z\™Y‚ˆ[Ù[˜[YH[ˆ]SH›Ü›X]
K™Ë‹Ü[˜ZKÙÜ MK<4D> [<5B>›ÜXËØÛ]YK\ÛÛ›™] M M˜
K‚<EFBFBD> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H“WÐTWÒÑVHˆ\OHœÝš[™È<E284A2>‚ˆTHÙ^H›Üˆ[Ý\ˆH›ÝšY\‹ˆ›Ý™\]Z\™Y›ÜˆØØ[[Ù[È܈ÛÝY›ÝšY\ˆ]]
™\<5C>^RKUÔÈ™Y›ØÚÊK‚<4B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H“WÐTWÐ<57>TÑHˆ\OHœÝš[™È<E284A2>‚ˆÝ\ÝÛHTH˜\ÙHT“ ˆ[ÛÈXØÙ\ÈÔS<>RWÐTWÐ<57>TÑX USWÐ<57>TÑWÕT“ ܈ÓSPWÐTWÐ<57>TÑX ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H“WÑV<14>WÒPQT”Ȉ\OHœÝš[™È<E284A2>‚ˆ^˜HXY\œÈÙ[<5B>Ûˆ]™\žHH™\]Y\Ý \ÈH”Óӈؚ™XÝ
K™Ë‚ˆÈ– Q™X]\™KRÙ^HŽˆ<C5BD>˜[YH‹– U[˜[<5B>Žˆ˜XÛYHŸX
Kˆ\ÙY<C399>[›ÜˆÜ[<5B>RKXÛÛ\]X›BˆØ]]Ø^\È]™\]Z\™H]šX<C5A1>][Ûˆ܈›Ý][™ÈXY\œÈ[ˆY][ÛˆÈH™X\™\‚ˆÚÙ[‹ˆH™X\™\ˆÚÙ[ˆ]Ù[ˆÝ[ÛÛY\Èœ›ÛHWÐTWÒÑVX ˆ\Y\Èț݈H]SH[™˜]]™HÜ[<5B>RH›Ý][™È]Ë‚<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H“WÕSQSÕUˆY˜][HŒÌ ˆ\OHš[<5B>YÙ\ˆ<>‚ˆ™\]Y\Ý[Y[Ý][ˆÙXÛۙț܈HØ[Ë‚<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÓWÓPVÔ‘U’QTȈY˜][H<>Hˆ\OHš[<5B>YÙ\ˆ<>‚ˆX^[][H<1B>[X™\ˆÙˆ™]šY\ț܈HTHØ[ÈÛˆ˜[œÚY[<5B>˜Z[\™\Ë‚<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆY˜][HšYÚˆ\OHœÝš[™È<E284A2>‚ˆÛÛ<C39B>›Û[šÚ[™ÈY™›Ü<E280BA>›Üˆ™X\ÛÛš[™È[Ù[ˈ˜[Y˜[Y\Έ›Û™X Z[š[X[ ÝØ YY][X YÚ YÚ X^ ˆY˜][ÈÈYY][X›Üˆ]ZXÚÈØØ[ˆ[ÙK‚<4B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÓQSSÔ–WÐÓÓT‘TÔÓÔ—ÕSQSÕUˆY˜][HŒÌˆ\OHš[<5B>YÙ\ˆ<>‚ˆ[Y[Ý][ˆÙXÛۙț܈Y[[ÜžHÛÛ\™\ÜÚ[ÛˆÜ\˜][ÛœÈ
ÛÛ<C39B>^Ý[[X\š^˜][ÛŠK‚<4B> Ô\˜[QšY[‚‚ˆÈÈÈYXØ]YY\XØ][Ûˆ[Ù[‚‘š[™[™ÈY\XØ][Ûˆ\ÈHÚX\ Ý<1C>XÝ\™YÛ\ÜÚYšXØ][Ûˆ\ÚˈžHY˜][]œ<>[œÈÛˆHXZ[ˆ[Ù[ <18>][ÝHØ[ˆ›Ý]H]ÈHÛX[\‹ØÚX\\ˆ[Ù[Ú]Ý]˜Y™™XÝ[™ÈHYÙ[<5B>È]ÈHXÝX[\Ý[™Ë‚‚<E2809A>\˜[QšY[]H”Õ’VÑQTWÓSÑSˆ\OHœÝš[™È<E284A2>‚ˆ[Ù[\ÙYÈ<1A>YÙHÚ]\ˆHØ[™Y]Hš[™[™È\XØ]\È[ˆ^\Ý[™È™\Ü<> ‚ˆ˜[ȘXÚÈÈÕ’VÓXÚ[ˆ[œÙ] ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H‘QTWÓWÐTWÒÑVHˆ\OHœÝš[™È<E284A2>‚ˆÜ[Û˜[›ÝšY\ˆÙ^H›ÜˆHY\XØ][Ûˆ[Ù[ ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H‘QTWÓWÐTWÐ<57>TÑHˆ\OHœÝš[™È<E284A2>‚ˆÜ[Û˜[Ý\ÝÛHTH˜\ÙHT“›ÜˆHY\XØ][Ûˆ[Ù[ ˆ\ÙHÚ[ˆHY\Bˆ[Ù[<1C>[œÈÛˆHY™™\™[<5B>[™Ú[<5B>[ˆHXZ[ˆ[Ù[ ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H‘QTWÓWÑV<14>WÒPQT”Ȉ\OHœÝš[™È<E284A2>‚ˆÜ[Û˜[”Óӈؚ™XÝÙˆ^˜HXY\œÈÙ[<5B>Ûˆ]™\žHY\XØ][Û‹[[Ù[ˆ™\]Y\Ý K™ËˆÈ– Q™X]\™KRÙ^HŽˆ<C5BD>˜[YHŸX ˆHYXØ]YY\H[Ù[™]™\‚ˆ[š\š]ÈWÑV<14>WÒPQT”ØÈÙ]\ÈÚ[ˆ]È[™Ú[<5B>™YYÈÝ\ÝÛHXY\œË‚<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÑQTWÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆ\OHœÝš[™È<E284A2>‚ˆ™X\ÛÛš[™ÈY™›Ü<E280BA>›ÜˆHY\XØ][Ûˆ[Ù[ ˆY˜][ÈÈH[Ù[ ÜÈÝÛ‚ˆ˜\Ù[[™HÚ[ˆ[œÙ] ‚<> Ô\˜[QšY[‚‚ˆÈÈÜ[Û˜[™X]\™\‚<C382>\˜[QšY[]H”T”VUWÐTWÒÑVHˆ\OHœÝš[™È<E284A2>‚ˆTHÙ^H›Üˆ\œ^]HRKˆ[˜X›\È™X[ ][YHÙXˆÙX\˜Ú\š[™ÈØØ[œÈ›ÜˆÔÒS•[™<1D>[™\˜Xš[]H™\ÙX\˜Ú ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”ÔÕPS—ÐTWÒÑVHˆ\OHœÝš[™È<E284A2>‚ˆÜÝX[ˆTHÙ^H
PRËx )˜
Kˆ[˜X›\È™]Ú[™ÈÜÝX[ˆÛÛXÝ[ۜȞHY\ÈH\™Ù]
ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY˜
K[™ÜÝX[ˆ[<5B>š\›Û›Y[<5B>È
ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY<0F>Ù[<5B><>O[<5B>š\›Û›Y[<5B> ]ZY˜
HÈ™\ÛÛ™HÛÛXÝ[Ûˆ˜\šXX›\ˈ›Ý™YYYÚ[ˆ\ÜÚ[™ÈHØØ[ÛÛXÝ[Ûˆ^Ü<>š[K‚<4B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÕSSQU–HˆY˜][HŒHˆ\OHœÝš[™È<E284A2>‚ˆ[[Y]žHÙÙÛKˆÙ]È  ˜[ÙX ›Ø ܈Ù™˜È\ØX›H[[Y]žH
ÜÝÙËØØ\™‹ÕS
K‚<EFBFBD> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H•<14>PÑSÓÔÐ<>TÑWÕT“ˆ\OHœÝš[™È<E284A2>‚ˆÕ Õ˜XÙ[Ûܘ\ÙHT“›Üˆ™[[ÝHÜ[•[[Y]žH^Ü<> ˆYˆ[œÙ] Ýš^ÙY\ȘXÙ\ÈØØ[Û›K‚<4B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H•<14>PÑSÓÔÐTWÒÑVHˆ\OHœÝš[™È<E284A2>‚ˆTHÙ^H\ÙY›Üˆ™[[ÝH˜XÙH^Ü<> ˆ™[[ÝH^Ü<>\È[˜X›YÛ›HÚ[ˆ›Ý<14>PÑSÓÔÐ<>TÑWÕT“[™<14>PÑSÓÔÐTWÒÑVX\™HÙ] ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H•<14>PÑSÓÔÒPQT”Ȉ\OHœÝš[™È<E284A2>‚ˆÜ[Û˜[Ý\ÝÛHÕSXY\œÈ
”Óӈؚ™XÝ܈Ù^O]˜[YKÙ^L<>]˜[YL˜
Kˆ\ÙY<C399>[›Üˆ[™Ù<E284A2>\ÙH܈Ý\ÝÛKÜÙ[‹ZÜÝYÕØ]]Ø^\Ë‚<C38B> Ô\˜[QšY[‚‚•Ú[ˆ™[[ÝHÕS˜\œÈ\™H›ÝÙ] Ýš^Ý[Üš]\ÈÛÛ\]H<1C>[ˆ[[Y]žHØØ[H΂‚˜˜\ڜݚ^Ü<>[œËÏ<1C>[—Û˜[YO‹Ù]™[<5B>ËšœÛÛ›˜‚•Ú[ˆ™[[ÝH˜\œÈ\™HÙ] Ýš^X[ ]Üš]\È[[Y]žHÈ›ÝØØ[”ÓÓ“[™H™[[ÝHÕS[™Ú[<5B> ‚‚ˆÈÈØÚÙ\ˆÛÛ™šYÝ\˜][Û‚‚<E2809A>\˜[QšY[]H”Õ’VÒSPQÑHˆY˜][H™ÚÜ‹š[ËÝ\Ù\Ýš^ ÜÝš^ \Ø[™›ÞŒKŒËŒˆ\OHœÝš[™È<E284A2>‚ˆØÚÙ\ˆ[XYÙHÈ\ÙH›ÜˆHØ[™›ÞÛÛ<C39B>Z[™\‹‚<E280B9> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÒSPQÑWÔSÔÓPÖHˆY˜][H˜]]Ȉ\OHœÝš[™È<E284A2>‚ˆÛÛ<C39B>›ÛÈØ[™›Þ[XYÙHXÜ]Z\Ú][Û‹ˆ\ÙH™]™\˜È™\]Z\™HH™[ØYY[XYÙBˆ[™˜Z[Ú]Ý][[™ÈÚ[ˆ]\È[˜]˜Z[X›NÈ]]Ø™\Ù\<5C>™\ÈHY˜][ˆš\œÝ \<5C>[ˆ[™Z]š[Ü‹ˆ\ÈÙ\țݙ\ÝšXÝ™]ÛÜšÈXØÙ\ÜÈœ›ÛHH<1C>[›š[™ÂˆØ[™›Þ ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H‘ÐÒÑT—ÒÔÕˆ\OHœÝš[™È<E284A2>‚ˆØÚÙ\ˆY[[ÛˆÛØÚÙ]] ˆ\ÙH›Üˆ™[[ÝHØÚÙ\ˆÜÝÈ܈Ý\ÝÛHÛÛ™šYÝ\˜][ۜ˂<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÔ•S•SQWÐ<57>PÒÑS‘ˆY˜][H™ØÚÙ\ˆˆ\OHœÝš[™È<E284A2>‚ˆ<14>[<5B>[YH˜XÚÙ[™›ÜˆHØ[™›Þ[<5B>š\›Û›Y[<5B> ‚<> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÐÐRQ×ГÓÕÕÐRUÔȈY˜][HŒÌ ˆ\OHš[<5B>YÙ\ˆ<>‚ˆX^[][H[YH[ˆÙXÛÛ™ÈÈØZ]›ÜˆH[‹\Ø[™›ÞØZYÈ›ÞHÈ™XÛÛYH™XYK‚ˆ[˜Ü™X\ÙH\ÈÛˆÛÝÈ܈X]š[HØYYÛÛ<C39B>Z[™\ˆÜÝË‚<C38B> Ô\˜[QšY[‚‚ˆÈÈØ[™›ÞÛÛ™šYÝ\˜][Û‚‚<E2809A>\˜[QšY[]H”Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUˆY˜][HŒLŒˆ\OHš[<5B>YÙ\ˆ<>‚ˆX^[][H^XÝ][Ûˆ[YH[ˆÙXÛۙț܈Ø[™›ÞÜ\˜][ۜ˂<C38B> Ô\˜[QšY[‚‚<E2809A>\˜[QšY[]H”Õ’VÔÐS‘“ÖÐÓÓ“‘PÕÕSQSÕUˆY˜][HŒLˆ\OHš[<5B>YÙ\ˆ<>‚ˆ[Y[Ý][ˆÙXÛۙț܈ÛÛ›™XÝ[™ÈÈHØ[™›ÞÛÛ<C39B>Z[™\‹‚<E280B9> Ô\˜[QšY[‚‚ˆÈÈÛÛ™šYÈš[B‚”Ýš^ÝÜ™\ÈÛÛ™šYÝ\˜][Ûˆ[ˆ‹ËœÝš^ ØÛKXÛÛ™šYËšœÛÛ˜ ˆ[ÝHØ[ˆ[ÛÈÜXÚYžHHÝ\ÝÛHÛÛ™šYÈš[N‚‚˜˜\ڜݚ^ K]\™Ù] ‹Ø\ KXÛÛ™šYÈ Ü] ÝËØÛÛ™šYËšœÛÛ‚˜‚ŠŠ<C5A0>ÛÛ™šYÈš[H›Ü›X]ŠŠ‚‚˜œÛÛ‚žÂˆ™[<5B>ˆŽˆˆ”Õ’VÓHŽˆ›Ü[˜ZKÙÜ MK<4D>‹ˆ“WÐTWÒÑVHŽˆœÚËK‹‹ˆ‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ŽˆšYÚ‚ˆBŸB˜‚ˆÈÈ^[\HÙ]\‚˜˜\ڈș\]Z\™Y™^Ü<>Õ’VÓOH›Ü[˜ZKÙÜ MK<4D>‚™^Ü<>WÐTWÒÑVOHœÚËK‹‹ˆ‚‚ˆÈÜ[Û˜[ˆ[˜X›HÙXˆÙX\˜Ú™^Ü<>T”VUWÐTWÒÑVOHœ K‹‹ˆ‚‚ˆÈÜ[Û˜[ˆÝ\ÝÛH[Y[Ý]™^Ü<>WÕSQSÕUH<>Œ ‚™^Ü<>Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUHŒÌ ‚‚˜

View file

@ -1,4 +1,4 @@
"""Strix application settings — pydantic-settings powered."""
­r‡^Ñf¥–ئ{O,yÊ'vî¶›­"""Strix application settings — pydantic-settings powered."""
from __future__ import annotations
@ -109,7 +109,12 @@ class RuntimeSettings(BaseSettings):
default="ghcr.io/usestrix/strix-sandbox:1.3.0",
alias="STRIX_IMAGE",
)
image_pull_policy: Literal["auto", "never"] = Field(
default="auto",
alias="STRIX_IMAGE_PULL_POLICY",
)
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")

View file

@ -1,217 +1,115 @@
"""Startup environment validation and Docker image management."""
­r‡^Ñf¥–ئ{[r‰Ý°ë­¦ëHˆˆ”Ý\<EFBFBD>\[<EFBFBD>š\›Û›Y[<EFBFBD>˜[Y][Ûˆ[™ØÚÙ\ˆ[XYÙHX[˜YÙ[Y[<EFBFBD> ˆˆˆ‚‚š[\Ü<EFBFBD>ÙÙÚ[™Âš[\Ü<EFBFBD>Ú][š[\Ü<EFBFBD>Þ\‚™œ›ÛHšXÚ ˜ÛÛœÛÛH[\Ü<EFBFBD>ÛÛœÛÛB™œ›ÛHšXÚ œ[™[[\Ü<EFBFBD>[™[™œ›ÛHšXÚ <EFBFBD>^[\Ü<EFBFBD>^‚™œ›ÛHÝš^ ˜ÛÛ™šYÈ[\Ü<EFBFBD>ÛÙ^ ØYÜÙ][™Ü™œ›ÛHÝš^ š[<EFBFBD>\™˜XÙK<EFBFBD>][È[\Ü<EFBFBD>
ˆÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[Û‹ˆ[XYÙWÙ^\Ýˈ›ØÙ\Ü×Ü[Û[™KŠB‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚™Yˆ˜[Y]WÙ[<EFBFBD>š\›Û›Y[<EFBFBD>
import logging
import shutil
import sys
H Oˆ›Û™N‚ˆÙÙÙ\‹š[™›Ê•˜[Y][™È[<EFBFBD>š\›Û›Y[<EFBFBD>ŠBˆÛÛœÛÛHHÛÛœÛÛJ
BˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÈH×BˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈH×B‚ˆÙ][™ÜÈHØYÜÙ][™ÜÊ
B‚ˆYˆÛÙ^ œÝXœØÜš\[Û—Û[Ù[
Ù][™ÜË›K›[Ù[
N‚ˆYˆ›ÝÛÙ^ š\ר]][<EFBFBD>XØ]Y
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
N‚ˆÛÛœÛÛKœš[<EFBFBD>
ˆˆ–Ü™YTÕ’VÓO^ÜÙ][™ÜË›K›[Ù[H\Ù\È[Ý\ˆÚ]ÔÝXœØÜš\[Û‹‚ˆ˜<EFBFBD>][ÝIÜ™H›ÝÚYÛ™Y[‹–Ë×H<EFBFBD>[ˆØÞX[—\Ýš^]]ÙÚ[ˆÚ]ÜË×Hš\œÝ ˆ‚ˆ
BˆÞ\Ë™^]
JBˆÙÙÙ\‹š[™›Ê‘[<EFBFBD>š\›Û›Y[<EFBFBD>ÒÈ
Ú]ÔÝXœØÜš\[ÛŠHŠBˆ™]\›‚‚ˆYˆ›ÝÙ][™ÜË›K›[Ù[‚ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œË˜\[™
”Õ’VÓHŠB‚ˆYˆ›ÝÙ][™ÜË›K˜\WÚÙ^N‚ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œË˜\[™
“WÐTWÒÑVHŠB‚ˆYˆ›ÝÙ][™ÜË›K˜\Wؘ\ÙN‚ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œË˜\[™
“WÐTWÐ<EFBFBD>TÑHŠB‚ˆYˆ›ÝÙ][™ÜËš[<EFBFBD>Yܘ][ۜ˜\œ^]WØ\WÚÙ^N‚ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œË˜\[™
”T”VUWÐTWÒÑVHŠB‚ˆYˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆ\œ›Ü—Ý^H^
from strix.config import codex, load_settings
from strix.interface.utils import (
check_docker_connection,
image_exists,
process_pull_line,
)
Bˆ\œ›Ü—Ý^ ˜\[™
“RTÔÒS‘È‘TURT‘QS•’T“Ó“QS•<EFBFBD>T’PP“TÈ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^ ˜\[™
——ˆ‹Ý[OH<EFBFBD>Ú]HŠB‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆ\œ›Ü—Ý^ ˜\[™
ˆ¸ (ˆݘ\ŸH‹Ý[OH˜›ÛY[ÝÈŠBˆ\œ›Ü—Ý^ ˜\[™
ˆ\È›ÝÙ]ˆ‹Ý[OH<EFBFBD>Ú]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^ ˜\[™
—“Ü[Û˜[[<EFBFBD>š\›Û›Y[<EFBFBD>˜\šXX›\Ηˆ‹Ý[OH™[HÚ]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^ ˜\[™
ˆ¸ (ˆݘ\ŸH‹Ý[OH™[HY[ÝÈŠBˆ\œ›Ü—Ý^ ˜\[™
ˆ\È›ÝÙ]ˆ‹Ý[OH™[HÚ]HŠB‚ˆ\œ›Ü—Ý^ ˜\[™
—”™\]Z\™Y[<EFBFBD>š\›Û›Y[<EFBFBD>˜\šXX›\Ηˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆYˆ˜\ˆOH”Õ’VÓHŽ‚ˆ\œ›Ü—Ý^ ˜\[™
¸ (ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
”Õ’VÓH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^ ˜\[™
ˆˆ H[Ù[˜[YHÈ\ÙH
K™Ë‹ ÛÜ[˜ZKÙÜ MK<EFBFBD> È܈‚ˆ‰Ø[<EFBFBD>›ÜXËØÛ]YK[Ü\ËM MÉÊWˆ‹ˆÝ[OH<EFBFBD>Ú]H‹ˆ
B‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^ ˜\[™
—“Ü[Û˜[[<EFBFBD>š\›Û›Y[<EFBFBD>˜\šXX›\Ηˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ<EFBFBD>TÑHŽ‚ˆ\œ›Ü—Ý^ ˜\[™
¸ (ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
“WÐTWÐ<EFBFBD>TÑH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^ ˜\[™
ˆˆ HÝ\ÝÛHTH˜\ÙHT“Yˆ\Ú[™ÈØØ[[Ù[È
K™Ë‹Û[XKTÝY[ÊWˆ‹ˆÝ[OH<EFBFBD>Ú]H‹ˆ
Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^ ˜\[™
¸ (ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
”T”VUWÐTWÒÑVH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^ ˜\[™
ˆˆ HTHÙ^H›Üˆ\œ^]HRHÙXˆÙX\˜Ú
[˜X›\È™X[ ][YH™\ÙX\˜Ú
Wˆ‹ˆÝ[OH<EFBFBD>Ú]H‹ˆ
Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^ ˜\[™
¸ (ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^ ˜\[™
ˆˆ H™X\ÛÛš[™ÈY™›Ü<EFBFBD>]™[ˆ›Û™KZ[š[X[ ÝËYY][KYÚ YÚ ‚ˆ›X^
Y˜][ˆYÚ
Wˆ‹ˆÝ[OH<EFBFBD>Ú]H‹ˆ
B‚ˆ\œ›Ü—Ý^ ˜\[™
—‘^[\HÙ]\—ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
™^Ü<EFBFBD>Õ’VÓOIÛÜ[˜ZKÙÜ MK<EFBFBD> ׈‹Ý[OH™[HÚ]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ<EFBFBD>TÑHŽ‚ˆ\œ›Ü—Ý^ ˜\[™
ˆ™^Ü<EFBFBD>WÐTWÐ<EFBFBD>TÑOIÚ‹ËÛØØ[ÜÝŒLM Í È‚ˆˆÈ™YYY›ÜˆØØ[[Ù[ÈÛ›Wˆ‹ˆÝ[OH™[HÚ]H‹ˆ
Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^ ˜\[™
ˆ™^Ü<EFBFBD>T”VUWÐTWÒÑVOIÞ[Ý\‹\\œ^]KZÙ^KZ\™I׈‹Ý[OH™[HÚ]H‚ˆ
Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^ ˜\[™
ˆ™^Ü<EFBFBD>Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•IÚYÚ ×ˆ‹ˆÝ[OH™[HÚ]H‹ˆ
B‚ˆ[™[H[™[
ˆ\œ›Ü—Ý^ ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ<EFBFBD>H›Y<EFBFBD>‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJ K ŠKˆ
B‚ˆÙÙÙ\‹™X<EFBFBD>YÊ“Z\ÜÚ[™È™\]Z\™Y[<EFBFBD>ˆ˜\œÎˆ \È‹Z\ÜÚ[™×Ü™\]Z\™Yݘ\œÊBˆÛÛœÛÛKœš[<EFBFBD>
—ˆŠBˆÛÛœÛÛKœš[<EFBFBD>
[™[
BˆÛÛœÛÛKœš[<EFBFBD>
BˆÞ\Ë™^]
JBˆÙÙÙ\‹š[™›Êˆ‘[<EFBFBD>š\›Û›Y[<EFBFBD>ÒÈ
Ü[Û˜[Z\ÜÚ[™Îˆ \ÊH‹ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈ܈››Û™H‹ˆ
B‚‚™YˆÚXÚ×ÙØÚÙ\—Ú[œÝ[Y
logger = logging.getLogger(__name__)
H Oˆ›Û™N‚ˆYˆÚ][ <EFBFBD>ÚXÚ
™ØÚÙ\ˆŠH\țۙN‚ˆÙÙÙ\‹™X<EFBFBD>YÊ‘ØÚÙ\ˆÓH›Ý›Ý[™[ˆUŠBˆÛÛœÛÛHHÛÛœÛÛJ
Bˆ\œ›Ü—Ý^H^
Bˆ\œ›Ü—Ý^ ˜\[™
‘ÐÒÑTˆ“ÕS”ÕSQ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^ ˜\[™
——ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
•H ÙØÚÙ\‰ÈÓHØ\țݛÝ[™[ˆ[Ý\ˆU —ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
ˆ”X\ÙH[œÝ[ØÚÙ\ˆ[™[œÝ\™HH ÙØÚÙ\‰ÈÛÛ[X[™\È]˜Z[X›K——ˆ‹Ý[OH<EFBFBD>Ú]H‚ˆ
B‚ˆ[™[H[™[
ˆ\œ›Ü—Ý^ ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ<EFBFBD>H›Y<EFBFBD>‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJ K ŠKˆ
BˆÛÛœÛÛKœš[<EFBFBD>
—ˆ‹[™[ —ˆŠBˆÞ\Ë™^]
JBˆÙÙÙ\‹™X<EFBFBD>YÊ‘ØÚÙ\ˆÓH™\Ù[<EFBFBD>ŠB‚‚™Yˆ[ÙØÚÙ\—Ú[XYÙJ
H Oˆ›Û™N‚ˆœ›ÛHØÚÙ\‹™\œ›ÜœÈ[\Ü<EFBFBD>ØÚÙ\‘^Ù\[Û‚‚ˆÛÛœÛÛHHÛÛœÛÛJ
BˆÛY[<EFBFBD>HÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[ÛŠ
B‚ˆ<EFBFBD>[<EFBFBD>[YHHØYÜÙ][™ÜÊ
Kœ<EFBFBD>[<EFBFBD>[YBˆ[XYÙHH<EFBFBD>[<EFBFBD>[YKš[XYÙB‚ˆYˆ[XYÙWÙ^\ÝÊÛY[<EFBFBD> [XYÙJN‚ˆÙÙÙ\‹™X<EFBFBD>YÊ‘ØÚÙ\ˆ[XYÙH[™XYH™\Ù[<EFBFBD>ØØ[Nˆ \È‹[XYÙJBˆ™]\›‚‚ˆYˆ<EFBFBD>[<EFBFBD>[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH<EFBFBD>[<EFBFBD>[YQ\œ›ÜŠˆˆ‘ØÚÙ\ˆ[XYÙHÚ[XYÙH\ŸH\È›Ý]˜Z[X›HØØ[H[™‚ˆ”Õ’VÒSPQÑWÔSÔÓPÖO[™]™\ˆ›Ü˜šYÈ[[™È]‚ˆ
B‚ˆÙÙÙ\‹š[™›Ê”[[™ÈØÚÙ\ˆ[XYÙNˆ \È‹[XYÙJBˆÛÛœÛÛKœš[<EFBFBD>
def validate_environment() -> None:
logger.info("Validating environment")
console = Console()
missing_required_vars = []
missing_optional_vars = []
BˆÛÛœÛÛKœš[<EFBFBD>
ˆ–Ù[WT[[™È[XYÙVË×HÚ[XYÙ_HŠBˆÛÛœÛÛKœš[<EFBFBD>
–Ù[HY[Ý×U\ÈÛ›H\[œÈÛˆš\œÝ<EFBFBD>[ˆ[™X^HZÙHH™]ÈZ[<EFBFBD>]\Ë‹‹–Ë×HŠBˆÛÛœÛÛKœš[<EFBFBD>
settings = load_settings()
B‚ˆÚ]ÛÛœÛÛKœÝ]\Ê–Ø›ÛÞX[—QÝÛ›ØY[™È[XYÙH^Y\œË‹‹ˆ‹Ü[›™\<EFBFBD>H™ÝÈŠH\ÈÝ]\΂ˆžN‚ˆ^Y\œ×Ú[™›ÎˆXÝÜ݋ݗHHßBˆ\ÝÝ\]HHˆ‚‚ˆ›Üˆ[™H[ˆÛY[<EFBFBD> ˜\Kœ[
[XYÙKÝ™X[OU<EFBFBD>YKXÛÙOU<EFBFBD>YJN‚ˆ\ÝÝ\]HH›ØÙ\Ü×Ü[Û[™J[™K^Y\œ×Ú[™›ËÝ]\Ë\ÝÝ\]JB‚ˆ^Ù\ØÚÙ\‘^Ù\[Ûˆ\ÈN‚ˆÙÙÙ\‹™X<EFBFBD>YÊ‘˜Z[YÈ[ØÚÙ\ˆ[XYÙH \È‹[XYÙK^×Ú[™›ÏU<EFBFBD>YJBˆÛÛœÛÛKœš[<EFBFBD>
if codex.subscription_model(settings.llm.model):
if not codex.is_authenticated():
console.print(
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
)
sys.exit(1)
logger.info("Environment OK (ChatGPT subscription)")
return
Bˆ\œ›Ü—Ý^H^
if not settings.llm.model:
missing_required_vars.append("STRIX_LLM")
Bˆ\œ›Ü—Ý^ ˜\[™
‘<EFBFBD>RSQÈSSPQÑH‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^ ˜\[™
——ˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
ˆ<EFBFBD>ÛÝ[›ÝÝÛ›ØYˆÚ[XYÙ_Wˆ‹Ý[OH<EFBFBD>Ú]HŠBˆ\œ›Ü—Ý^ ˜\[™
ÝŠJKÝ[OH™[H™YŠB‚ˆ[™[H[™[
ˆ\œ›Ü—Ý^ ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ<EFBFBD>H›Y<EFBFBD>‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJ K ŠKˆ
BˆÛÛœÛÛKœš[<EFBFBD>
[™[ —ˆŠBˆÞ\Ë™^]
JB‚ˆÙÙÙ\‹š[™›Ê‘ØÚÙ\ˆ[XYÙH \È™XYH‹[XYÙJBˆÝXØÙ\Ü×Ý^H^
if not settings.llm.api_key:
missing_optional_vars.append("LLM_API_KEY")
BˆÝXØÙ\Ü×Ý^ ˜\[™
‘ØÚÙ\ˆ[XYÙH™XYH‹Ý[OHˆÌŒ˜ÍMYHŠBˆÛÛœÛÛKœš[<EFBFBD>
ÝXØÙ\Ü×Ý^
BˆÛÛœÛÛKœš[<EFBFBD>
if not settings.llm.api_base:
missing_optional_vars.append("LLM_API_BASE")
if not settings.integrations.perplexity_api_key:
missing_optional_vars.append("PERPLEXITY_API_KEY")
if missing_required_vars:
error_text = Text()
error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red")
error_text.append("\n\n", style="white")
for var in missing_required_vars:
error_text.append(f"• {var}", style="bold yellow")
error_text.append(" is not set\n", style="white")
if missing_optional_vars:
error_text.append("\nOptional environment variables:\n", style="dim white")
for var in missing_optional_vars:
error_text.append(f"• {var}", style="dim yellow")
error_text.append(" is not set\n", style="dim white")
error_text.append("\nRequired environment variables:\n", style="white")
for var in missing_required_vars:
if var == "STRIX_LLM":
error_text.append("• ", style="white")
error_text.append("STRIX_LLM", style="bold cyan")
error_text.append(
" - Model name to use (e.g., 'openai/gpt-5.4' or "
"'anthropic/claude-opus-4-7')\n",
style="white",
)
if missing_optional_vars:
error_text.append("\nOptional environment variables:\n", style="white")
for var in missing_optional_vars:
if var == "LLM_API_BASE":
error_text.append("• ", style="white")
error_text.append("LLM_API_BASE", style="bold cyan")
error_text.append(
" - Custom API base URL if using local models (e.g., Ollama, LMStudio)\n",
style="white",
)
elif var == "PERPLEXITY_API_KEY":
error_text.append("• ", style="white")
error_text.append("PERPLEXITY_API_KEY", style="bold cyan")
error_text.append(
" - API key for Perplexity AI web search (enables real-time research)\n",
style="white",
)
elif var == "STRIX_REASONING_EFFORT":
error_text.append("• ", style="white")
error_text.append("STRIX_REASONING_EFFORT", style="bold cyan")
error_text.append(
" - Reasoning effort level: none, minimal, low, medium, high, xhigh, "
"max (default: high)\n",
style="white",
)
error_text.append("\nExample setup:\n", style="white")
error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white")
if missing_optional_vars:
for var in missing_optional_vars:
if var == "LLM_API_BASE":
error_text.append(
"export LLM_API_BASE='http://localhost:11434' "
"# needed for local models only\n",
style="dim white",
)
elif var == "PERPLEXITY_API_KEY":
error_text.append(
"export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white"
)
elif var == "STRIX_REASONING_EFFORT":
error_text.append(
"export STRIX_REASONING_EFFORT='high'\n",
style="dim white",
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
logger.debug("Missing required env vars: %s", missing_required_vars)
console.print("\n")
console.print(panel)
console.print()
sys.exit(1)
logger.info(
"Environment OK (optional missing: %s)",
missing_optional_vars or "none",
)
def check_docker_installed() -> None:
if shutil.which("docker") is None:
logger.debug("Docker CLI not found in PATH")
console = Console()
error_text = Text()
error_text.append("DOCKER NOT INSTALLED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append("The 'docker' CLI was not found in your PATH.\n", style="white")
error_text.append(
"Please install Docker and ensure the 'docker' command is available.\n\n", style="white"
)
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print("\n", panel, "\n")
sys.exit(1)
logger.debug("Docker CLI present")
def pull_docker_image() -> None:
from docker.errors import DockerException
console = Console()
client = check_docker_connection()
image = load_settings().runtime.image
if image_exists(client, image):
logger.debug("Docker image already present locally: %s", image)
return
logger.info("Pulling docker image: %s", image)
console.print()
console.print(f"[dim]Pulling image[/] {image}")
console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
console.print()
with console.status("[bold cyan]Downloading image layers...", spinner="dots") as status:
try:
layers_info: dict[str, str] = {}
last_update = ""
for line in client.api.pull(image, stream=True, decode=True):
last_update = process_pull_line(line, layers_info, status, last_update)
except DockerException as e:
logger.debug("Failed to pull docker image %s", image, exc_info=True)
console.print()
error_text = Text()
error_text.append("FAILED TO PULL IMAGE", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"Could not download: {image}\n", style="white")
error_text.append(str(e), style="dim red")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print(panel, "\n")
sys.exit(1)
logger.info("Docker image %s ready", image)
success_text = Text()
success_text.append("Docker image ready", style="#22c55e")
console.print(success_text)
console.print()
B

View file

@ -1,4 +1,4 @@
"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
­r‡^Ñf¥–ئ{O,yÊ'vî¶›­"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
from __future__ import annotations
@ -40,10 +40,12 @@ async def _docker_backend(
import docker
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
from strix.config.loader import load_settings
from strix.runtime.docker_client import StrixDockerSandboxClient
client = StrixDockerSandboxClient(docker.from_env())
client.strix_bind_mounts = bind_mounts or []
client.image_pull_policy = load_settings().runtime.image_pull_policy
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await client.create(options=options, manifest=manifest)
await session.start()

View file

@ -1,292 +1,114 @@
"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
NET_ADMIN/NET_RAW capabilities + host-gateway.
­r‡^Ñf¥–ئ{[r‰Ý°ë­¦ëHˆˆ”Ýš^ØÚÙ\”Ø[™›ÞÛY[<EFBFBD>8 %™\Ù\<EFBFBD>™\ÈH[XYÙIÜÈS•–TÒS•[™Y“‘UÐQRS‹Ó‘UÔ<EFBFBD>UÈØ\Xš[]Y\È
ÈÜÝ YØ]]Ø^K‚‚•HÑÉÜÈØÚÙ\”Ø[™›ÞÛY[<EFBFBD> —ØÜ™X]WØÛÛ<EFBFBD>Z[™\˜Ù\È›Ý^ÜÙHHÛÚț܂™^[™[™ÈÜ™X]WÚÝØ\™ÜØ™Y›Ü™HÛÛ<EFBFBD>Z[™\œË˜Ü™X]X\ÈØ[Y ˆÙHÝX˜Û\ܘ[™™Z[\[Y[<EFBFBD>HY]Ù›ÙH™\˜˜][Hœ›ÛHHÑÈÛÝ\˜ÙKÚ]™YB™[\΂‚ŒKˆ›ÜHÑÉÜÈ[<EFBFBD>ž\Ú[<EFBFBD>VÈ<EFBFBD>Z[—XÝ™\œšYNÈÝ\HÈ<EFBFBD>Z[‹‹Yˆ‹ˆ‹Ù]‹Û<EFBFBD>[—X\ÈÛÛ[X[™[œÝXY ˆ\È]ÈÝ\ˆ[XYÙI܈ØÚÙ\‹Y[<EFBFBD>ž\Ú[<EFBFBD> œÚXÝX[H<EFBFBD>[ˆ8 %Ú]Ý]] ØZYËXÛX™]™\‚ˆÝ\<EFBFBD>È[œÚYHHÛÛ<EFBFBD>Z[™\ˆ[™›ÛÝݘ\ØØZYØ™]šY\ÈYØZ[œÝBˆXYÜ<EFBFBD> ‚Œ‹ˆ\[™‘UÐQRS‹Ó‘UÔ<EFBFBD>UÈÈØ\ØY
™\]Z\™YžH›X\ \ÔØ[™ˆÝ\ˆ˜]Ë\ÛØÚÙ]ÛÛÊK‚ŒËˆYÜÝ ™ØÚÙ\‹š[<EFBFBD>\›˜[8¡¤ˆÜÝ YØ]]Ø^HÈ^˜WÚÜÝØÛÈBˆYÙ[<EFBFBD>Ø[ˆ™XXÚÜÝ \Ù\<EFBFBD>™Y\Ë‚‚”[›™YÈÜ[˜ZKXYÙ[<EFBFBD>ÏOL ŒM <EFBFBD>˜ ˆ<EFBFBD>[\[™ÈHÑÈ™\]Z\™\œ™K[Y\™Ú[™ÈH\™[<EFBFBD>›ÙKˆ˜XÚÈ\Ý™X[H›Üˆ[ˆ[š™XÝ[ÛˆÛÚË‚ˆˆˆ‚‚™œ›ÛH×Ù<EFBFBD>]\™W×È[\Ü<EFBFBD>[››Ý][ۜ‚š[\Ü<EFBFBD>ÛÛ<EFBFBD>^X‚š[\Ü<EFBFBD>ÙÙÚ[™Âš[\Ü<EFBFBD>Üš[\Ü<EFBFBD>]ZY™œ›ÛH\[™È[\Ü<EFBFBD>[žK]\˜[ Ø\Ý‚™œ›ÛHYÙ[<EFBFBD>ËœØ[™›Þ ™\œ›ÜœÈ[\Ü<EFBFBD>^ÜÙYÜ<EFBFBD>[˜]˜Z[X›Q\œ›Ü‚™œ›ÛHYÙ[<EFBFBD>ËœØ[™›Þ ›X[šY™\Ý[\Ü<EFBFBD>X[šY™\Ý™œ›ÛHYÙ[<EFBFBD>ËœØ[™›Þ œØ[™›Þ\Ë™ØÚÙ\ˆ[\Ü<EFBFBD>
ˆØÚÙ\”Ø[™›ÞÛY[<EFBFBD> ˆØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‹ˆØ<EFBFBD>Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[<EFBFBD>ËˆÙØÚÙ\—ÜÜ<EFBFBD>ÚÙ^KˆÛX[šY™\ÝÜ™\]Z\™\×Ù<EFBFBD>\ÙKˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[‹ŠB™œ›ÛHYÙ[<EFBFBD>ËœØ[™›Þ œÙ\ÜÚ[Û‹œØ[™›ÞÜÙ\ÜÚ[Ûˆ[\Ü<EFBFBD>Ø[™›ÞÙ\ÜÚ[Û‚™œ›ÛHYÙ[<EFBFBD>ËœØ[™›Þ <EFBFBD>\\È[\Ü<EFBFBD>^ÜÙYÜ<EFBFBD>[™Ú[<EFBFBD>™œ›ÛHØÚÙ\ˆ[\Ü<EFBFBD>\œ›ÜœÈ\ÈØÚÙ\—Ù\œ›ÜœÈÈ\NˆYۛܙVÚ[\Ü<EFBFBD> ][<EFBFBD>\Y [<EFBFBD>\ÙY ZYۛܙWB™œ›ÛHØÚÙ\‹›[Ù[˘ÛÛ<EFBFBD>Z[™\œÈ[\Ü<EFBFBD>ÛÛ<EFBFBD>Z[™\ˆÈ\NˆYۛܙVÚ[\Ü<EFBFBD> ][<EFBFBD>\Y [<EFBFBD>\ÙY ZYۛܙWB™œ›ÛHØÚÙ\‹<EFBFBD>\\È[\Ü<EFBFBD>ÙÐÛÛ™šYÈÈ\NˆYۛܙVÚ[\Ü<EFBFBD> ][<EFBFBD>\Y [<EFBFBD>\ÙY ZYۛܙWB™œ›ÛHØÚÙ\‹<EFBFBD>\\È[\Ü<EFBFBD>[Ý[<EFBFBD>\ÈØÚÙ\”ÑÓ[Ý[<EFBFBD>È\NˆYۛܙVÚ[\Ü<EFBFBD> ][<EFBFBD>\Y [<EFBFBD>\ÙY ZYۛܙWB™œ›ÛHØÚÙ\‹<EFBFBD>][È[\Ü<EFBFBD>\œÙWÜ™\ÜÚ]ÜžWÝYÈÈ\NˆYۛܙVÚ[\Ü<EFBFBD> ][<EFBFBD>\Y [<EFBFBD>\ÙY ZYۛܙWB™œ›ÛH™\]Y\ÝË™^Ù\[ÛœÈ[\Ü<EFBFBD>™\]Y\Ý^Ù\[Û‚‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚—ÔÐS‘“ÖÓ‘UÓÔ’×ÑS•ˆH”Õ’VÑÐÒÑT—ÔÐS‘“ÖÓ‘UÓÔ’È‚‚‚™YˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JˆÛY[<EFBFBD>ˆ[žKˆ[XYÙNˆÝ‹ˆ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—KŠH Oˆ›Û™N‚ˆYˆÛY[<EFBFBD> š[XYÙWÙ^\ÝÊ[XYÙJN‚ˆ™]\›‚ˆYˆ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH<EFBFBD>[<EFBFBD>[YQ\œ›ÜŠˆˆ‘ØÚÙ\ˆ[XYÙHÚ[XYÙH\ŸH\È›Ý]˜Z[X›HØØ[H[™‚ˆ”Õ’VÒSPQÑWÔSÔÓPÖO[™]™\ˆ›Ü˜šYÈ[[™È]‚ˆ
Bˆ™\ËYÈH\œÙWÜ™\ÜÚ]ÜžWÝYÊ[XYÙJBˆÛY[<EFBFBD> ™ØÚÙ\—ØÛY[<EFBFBD> š[XYÙ\Ëœ[
™\ËYÏ]YÈ܈›Û™K[ÝYÜÏQ˜[ÙJB‚‚™YˆÜØ[™›ÞÛ™]ÛÜšÊ
H OˆÝˆ›Û™N‚ˆ˜[YHHÜË™[<EFBFBD>š\›Û‹™Ù]
ÔÐS‘“ÖÓ‘UÓÔ’×ÑS•‹ˆŠKœÝš\
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
extending ``create_kwargs`` before ``containers.create`` is called. We subclass
and reimplement the method body verbatim from the SDK source, with three
deltas:
Bˆ™]\›ˆ˜[YH܈›Û™B‚‚™YˆØ\WÜØ[™›ÞÛ™]ÛÜšÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJH Oˆ›Û™N‚ˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
BˆYˆ™]Ûܚ΂ˆÜ™X]WÚÝØ\™ÜÖÈ›™]ÛܚȗHH™]ÛܚˆÜ™X]WÚÝØ\™ÜËœÜ
œÜ<EFBFBD>È‹›Û™JB‚‚™YˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJH Oˆ›Û™N‚ˆˆˆ<EFBFBD>\HÜ[Û˜[ÙÜ›Ý\™\ÛÝ\˜ÙHØ\Èœ›ÛHH[<EFBFBD>š\›Û›Y[<EFBFBD> ˆ[œÙ] Ø›[šÂˆ˜[Y\ÈX]™HØÚÙ\‰ÜÈY˜][
[˜›Ý[™Y
KÛÈ\È\ÈÜ Z[ˆ\ˆÜÝ ˆˆˆ‚ˆY[WÛ[Z]HÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÓQSWÓSRU‹ˆŠKœÝš\
1. Drop the SDK's ``entrypoint=["tail"]`` override; supply ``["tail", "-f",
"/dev/null"]`` as ``command`` instead. This lets our image's
``docker-entrypoint.sh`` actually run — without it, ``caido-cli`` never
starts inside the container and ``bootstrap_caido`` retries against a
dead port.
2. Append NET_ADMIN/NET_RAW to ``cap_add`` (required by ``nmap -sS`` and
other raw-socket tools).
3. Add ``host.docker.internal`` → host-gateway to ``extra_hosts`` so the
agent can reach host-served apps.
BˆYˆY[WÛ[Z]‚ˆÜ™X]WÚÝØ\™ÜÖÈ›Y[WÛ[Z]—HHY[WÛ[Z]‚ˆÚWÜÚ^™HHÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÔÒWÔÒV‘H‹ˆŠKœÝš\
Pinned to ``openai-agents==0.14.6``. Bumping the SDK requires
re-merging the parent body. Track upstream for an injection hook.
"""
BˆYˆÚWÜÚ^™N‚ˆÜ™X]WÚÝØ\™ÜÖÈœÚWÜÚ^™H—HHÚWÜÚ^™B‚ˆÜ\ÈHÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÐÔTÈ‹ˆŠKœÝš\
from __future__ import annotations
BˆYˆÜ\΂ˆÚ]ÛÛ<EFBFBD>^X‹œÝ\™\Üʘ[YQ\œ›Ü‹Ý™\™›ÝÑ\œ›ÜŠN‚ˆ˜[›×ØÜ\ÈH[<EFBFBD>
ݯ]
Ü\ÊH
ˆ WÌ Ì Ì
BˆYˆ ˜[›×ØÜ\ÈH ŠŠ<EFBFBD>ŒÈ H N‚ˆÜ™X]WÚÝØ\™ÜÖÈ›˜[›×ØÜ\È—HH˜[›×ØÜ\‚ˆY×Û[Z]HÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÔQ×ÓSRU‹ˆŠKœÝš\
import contextlib
import logging
import os
import uuid
from typing import Any, cast
BˆYˆY×Û[Z]‚ˆÚ]ÛÛ<EFBFBD>^X‹œÝ\™\Üʘ[YQ\œ›ÜŠN‚ˆÜ™X]WÚÝØ\™ÜÖÈœY×Û[Z]—HH[<EFBFBD>
Y×Û[Z]
B‚‚™YˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJH Oˆ›Û™N‚ˆˆˆ<EFBFBD>›Ý[™HÛÛ<EFBFBD>Z[™\‰ÜÈœÛÛ‹Yš[HÙÈÛÈH<EFBFBD>[˜]Ø^H›ØÙ\ÜÈ[ˆHØ[™›Þˆ
K™ËˆHÛÛ]<EFBFBD>\ÞK[ÛÜÈÜš][™ÈÈÝÝ]
HØ[››Ýš[HÜÝ\Úˆ[™ZÙHHØÚÙ\ˆY[[ÛˆÝÛˆÚ]] ‚‚ˆ[›ZÙHHÙÜ›Ý\Ø\ÈX›Ý™K\ÈY˜][È
Š›ÛŠŠˆ8 %ØÚÙ\‰ÜÈÝÛˆY˜][ˆ\È[ˆ[˜›Ý[™YœÛÛ‹Yš[KÚXÚ\È[œØY™H›Üˆ[ˆ]]Û›Û[Ý\ÈYÙ[<EFBFBD>]ˆ^XÝ]\È\˜š]˜\žHÛÛ[X[™ˈX^ Yš[X›Ý][ÛˆYX[œÈHÛ‹Y\ÚÈØ\\ˆX^ \Ú^™H
ˆX^ Yš[X ˆÙ]Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘XÈ  ØÙ™˜ˆÈܘXÚÈÝ]ÈØÚÙ\‰ÜÈY˜][ ˆˆˆ‚ˆX^ÜÚ^™HHÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘H‹<EFBFBD>LHŠKœÝš\
from agents.sandbox.errors import ExposedPortUnavailableError
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
DockerSandboxSession,
_build_docker_volume_mounts,
_docker_port_key,
_manifest_requires_fuse,
_manifest_requires_sys_admin,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.types import ExposedPortEndpoint
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.types import LogConfig # type: ignore[import-untyped, unused-ignore]
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
from requests.exceptions import RequestException
BˆYˆX^ÜÚ^™K›ÝÙ\Š
H[ˆ
Œ‹›Ù™ˆ‹››Û™H‹<EFBFBD>[›[Z]YŠN‚ˆ™]\›‚ˆX^Ùš[HHÜË™[<EFBFBD>š\›Û‹™Ù]
”Õ’VÔÐS‘“ÖÓÑ×ÓPVÑ’SH‹ŒÈŠKœÝš\
H܈ŒÈ‚ˆÜ™X]WÚÝØ\™ÜÖÈ›ÙרÛÛ™šYÈ—HHÙÐÛÛ™šYʈ\OSÙÐÛÛ™šYË<EFBFBD>\\Ë’”ÓÓ‹ˆÛÛ™šYÏ^È›X^ \Ú^™HŽˆX^ÜÚ^™K›X^ Yš[HŽˆX^Ùš[_Kˆ
B‚‚™YˆØ\WÜ<EFBFBD>[—ÛX™[ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJH Oˆ›Û™N‚ˆ<EFBFBD>[—ÚYHÜË™Ù][<EFBFBD>ДՒVÔ•S—ÒQŠBˆYˆ›Ý<EFBFBD>[—ÚY‚ˆ™]\›‚ˆX™[ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
›X™[È‹ßJBˆYˆ›Ý\Ú[œÝ[˜ÙJX™[ËXÝ
N‚ˆ™]\›‚ˆX™[ÖȜݚ^ \<EFBFBD>[‹ZY—HH<EFBFBD>[—ÚYˆ<EFBFBD>[—Ý\HHÜË™Ù][<EFBFBD>ДՒVÔ•S—ÕTHŠBˆYˆ<EFBFBD>[—Ý\N‚ˆX™[ÖȜݚ^ \<EFBFBD>[‹]\H—HH<EFBFBD>[—Ý\B‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆØ[™›ÞÛ™]ÛܚΈÝˆHˆ‚‚ˆ\Þ[˜ÈYˆÜ™\ÛÛ™WÙ^ÜÙYÜÜ<EFBFBD>
Ù[‹Ü<EFBFBD>ˆ[<EFBFBD>
H Oˆ^ÜÙYÜ<EFBFBD>[™Ú[<EFBFBD>‚ˆžN‚ˆÙ[‹—ØÛÛ<EFBFBD>Z[™\‹œ™[ØY
logger = logging.getLogger(__name__)
Bˆ^Ù\ØÚÙ\—Ù\œ›ÜœË<EFBFBD>TQ\œ›Üˆ\ÈN‚ˆ˜Z\ÙH^ÜÙYÜ<EFBFBD>[˜]˜Z[X›Q\œ›ÜŠˆÜ<EFBFBD>\Ü<EFBFBD> ˆ^ÜÙYÜÜ<EFBFBD>Ï\Ù[‹œÝ]K™^ÜÙYÜÜ<EFBFBD>ˈ™X\ÛÛ<EFBFBD>H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ<EFBFBD>^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ<EFBFBD>Z[™\—Ü™[ØYÙ˜Z[Y‹ˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆØ]\ÙOYKˆ
Hœ›ÛHB‚ˆ]œÈHÙ]]ŠÙ[‹—ØÛÛ<EFBFBD>Z[™\‹˜]œÈ‹ßJH܈ßBˆ™]ÛÜšÜÈH]œË™Ù]
“™]ÛÜšÔÙ][™ÜÈ‹ßJK™Ù]
“™]ÛÜšÜÈ‹ßJBˆ[™Ú[<EFBFBD>H™]ÛÜšÜË™Ù]
Ù[‹œØ[™›ÞÛ™]ÛÜšÊH܈ßBˆ\H[™Ú[<EFBFBD> ™Ù]
’TY™\ÜÈŠH܈[™Ú[<EFBFBD> ™Ù]
‘Ûؘ[T<EFBFBD><EFBFBD>Y™\ÜÈŠBˆYˆ›Ý\Ú[œÝ[˜ÙJ\ ÝŠH܈›Ý\‚ˆ˜Z\ÙH^ÜÙYÜ<EFBFBD>[˜]˜Z[X›Q\œ›ÜŠˆÜ<EFBFBD>\Ü<EFBFBD> ˆ^ÜÙYÜÜ<EFBFBD>Ï\Ù[‹œÝ]K™^ÜÙYÜÜ<EFBFBD>ˈ™X\ÛÛ<EFBFBD>H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ<EFBFBD>^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ<EFBFBD>Z[™\—Û›ÝÛÛ—Û™]Ûܚȋˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆ
BˆÜÝHˆ–ÞÚ\WHˆYˆŽˆˆ[ˆ\[ÙH\ˆ™]\›ˆ^ÜÙYÜ<EFBFBD>[™Ú[<EFBFBD>
ÜÝZÜÝ Ü<EFBFBD>\Ü<EFBFBD> ÏQ˜[ÙJB‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÛY[<EFBFBD>
ØÚÙ\”Ø[™›ÞÛY[<EFBFBD>
N‚ˆÈÜÝ\™XÝÜšY\ÈÈš[™ [[Ý[<EFBFBD>[<EFBFBD>ÈHÛÛ<EFBFBD>Z[™\‹Ù]žHHØÚÙ\‚ˆÈ˜XÚÙ[™™Y›Ü™HÜ™X]J
X ˆXXÚ][H\ÈÜÛÝ\˜ÙK\™Ù] ™XYÛÛ›_X ‚ˆÝš^Øš[™Û[Ý[<EFBFBD>Έ\ÝÙXÝÜÝ‹[žWWH›Û™HH›Û™Bˆ[XYÙWÜ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—HH˜]]È‚‚ˆ\Þ[˜ÈYˆØÜ™X]WØÛÛ<EFBFBD>Z[™\ŠˆÙ[‹ˆ[XYÙNˆÝ‹ˆ
‹ˆX[šY™\݈X[šY™\ݛۙHH›Û™Kˆ^ÜÙYÜÜ<EFBFBD>Έ\VÚ[<EFBFBD>  ‹‹—HH
KˆÙ\ÜÚ[Û—ÚYˆ]ZY •URQ›Û™HH›Û™Kˆ
H OˆÛÛ<EFBFBD>Z[™\Ž‚ˆÈ KKKKH‘QÒSˆ‘T<EFBFBD><EFBFBD>USHÓÔHÙˆØÚÙ\”Ø[™›ÞÛY[<EFBFBD> —ØÜ™X]WØÛÛ<EFBFBD>Z[™\ˆ KKKKBˆÈÑÈ™YŽˆÜ˜ËØYÙ[<EFBFBD>ËÜØ[™›Þ ÜØ[™›Þ\ËÙØÚÙ\‹œNŒM Í LM ÍÈ
Œ ŒM <EFBFBD>ŠK‚ˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JÙ[‹[XYÙKÙ[‹š[XYÙWÜ[ÜÛXÞJB‚ˆ\ÜÙ\<EFBFBD>Ù[‹š[XYÙWÙ^\ÝÊ[XYÙJBˆ[<EFBFBD>š\›Û›Y[<EFBFBD>ˆXÝÜ݋ݗH›Û™HH›Û™BˆYˆX[šY™\Ý‚ˆ[<EFBFBD>š\›Û›Y[<EFBFBD>H]ØZ]X[šY™\Ý ™[<EFBFBD>š\›Û›Y[<EFBFBD> œ™\ÛÛ™J
BˆÈÝš^[Hœ›ÛHHÑÈ›ÙNˆ›Ü[<EFBFBD>ž\Ú[<EFBFBD>Ý™\œšYH[™ˆÈÝ\HZ[ Yˆ Ù]‹Û<EFBFBD>[\ÈÛÛ[X[™ÛÈH[XYÙI܈ÈS•–TÒS•
ØÚÙ\‹Y[<EFBFBD>ž\Ú[<EFBFBD> œÚ
H<EFBFBD>[œÈÙ]\ [ˆ^XˆÈ‰˜™XÛÛY\È^XÈZ[ Yˆ Ù]‹Û<EFBFBD>[›ÜˆHÙY\ X[]™K‚ˆÈÚ]Ý]\ËØZYËXÛH
ÈH[‹XÛÛ<EFBFBD>Z[™\ˆÐH<EFBFBD>\Ý™]™\ˆÙ]ˆÈ[š]X[^™Y ‚ˆÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWHHˆš[XYÙHŽˆ[XYÙKˆ™]XÚŽˆ<EFBFBD>YKˆ˜ÛÛ[X[™ŽˆÈ<EFBFBD>Z[‹‹Yˆ‹‹Ù]‹Û<EFBFBD>[—Kˆ™[<EFBFBD>š\›Û›Y[<EFBFBD>Žˆ[<EFBFBD>š\›Û›Y[<EFBFBD> ˆBˆYˆX[šY™\Ý\țݛۙN‚ˆØÚÙ\—Û[Ý[<EFBFBD>ÈHØ<EFBFBD>Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[<EFBFBD>ʈX[šY™\Ý ˆÙ\ÜÚ[Û—ÚY\Ù\ÜÚ[Û—ÚY ˆ
BˆYˆØÚÙ\—Û[Ý[<EFBFBD>΂ˆÜ™X]WÚÝØ\™ÜÖÈ›[Ý[<EFBFBD>È—HHØÚÙ\—Û[Ý[<EFBFBD>ˆYˆÛX[šY™\ÝÜ™\]Z\™\×Ù<EFBFBD>\ÙJX[šY™\Ý
N‚ˆÜ™X]WÚÝØ\™ÜË<EFBFBD>\]Jˆ]šXÙ\ÏVÈ‹Ù]‹Ù<EFBFBD>\ÙH—KˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ<EFBFBD>[˜ÛÛ™š[™Y—Kˆ
Bˆ[YˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[ŠX[šY™\Ý
N‚ˆÜ™X]WÚÝØ\™ÜË<EFBFBD>\]JˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ<EFBFBD>[˜ÛÛ™š[™Y—Kˆ
BˆYˆ^ÜÙYÜÜ<EFBFBD>΂ˆÜ™X]WÚÝØ\™ÜÖÈœÜ<EFBFBD>È—HHˆÙØÚÙ\—ÜÜ<EFBFBD>ÚÙ^JÜ<EFBFBD>
Nˆ
ŒL<EFBFBD>ËŒ Œ ŒH‹›Û™JH›ÜˆÜ<EFBFBD>[ˆ^ÜÙYÜÜ<EFBFBD>ˆBˆÈ KKKKHS‘‘T<EFBFBD><EFBFBD>USHÓÔH KKKKB‚ˆÈÝš^[š™XÝ[ÛœÈ8 %\[™ Û‰ÝÝ™\<EFBFBD>Üš]KÛÈ•TÑKÔÖT×ÐQRSˆÝ\<EFBFBD>š]™\Ë‚ˆØ\ØYHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
˜Ø\ØY‹×JBˆYˆ›Ý\Ú[œÝ[˜ÙJØ\ØY \Ý
N‚ˆØ\ØYH\Ý
Ø\ØY
BˆÜ™X]WÚÝØ\™ÜÖȘØ\ØY—HHØ\ØYˆ›ÜˆØ\[ˆ
“‘UÐQRSˆ‹“‘UÔ<EFBFBD>UÈŠN‚ˆYˆØ\›Ý[ˆØ\ØY‚ˆØ\ØY ˜\[™
Ø\
B‚ˆ^˜WÚÜÝÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
™^˜WÚÜÝÈ‹ßJBˆ^˜WÚÜÝÖÈšÜÝ ™ØÚÙ\‹š[<EFBFBD>\›˜[—HHšÜÝ YØ]]Ø^H‚‚ˆØ\WÜØ[™›ÞÛ™]ÛÜšÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ<EFBFBD>[—ÛX™[ÊÜ™X]WÚÝØ\™ÜÊB‚ˆÈÝš^[š™XÝ[ÛŽˆØØ[ÛÝ\˜ÙH™Y\ËÛÜ<EFBFBD>YÚ[ÝÙ\Ý Yš\œÝÛÈBˆÈ™\ÝYÜXÈ[™ÈÛˆÜÙˆH™YH]ÛÝ™\œË‚ˆš[™Û[Ý[<EFBFBD>ÈHÙ[‹œÝš^Øš[™Û[Ý[<EFBFBD>È܈
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
BˆYˆš[™Û[Ý[<EFBFBD>΂ˆ[Ý[<EFBFBD>ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
›[Ý[<EFBFBD>È‹×JBˆ›ÜˆÜXÈ[ˆÛÜ<EFBFBD>Y
š[™Û[Ý[<EFBFBD>ËÙ^O[[X™HΈÝŠÖÈ<EFBFBD>\™Ù]—JK˜ÛÝ[<EFBFBD>
‹ÈŠJN‚ˆ[Ý[<EFBFBD>˘\[™
ˆØÚÙ\”ÑÓ[Ý[<EFBFBD>
ˆ\™Ù]\ÜXÖÈ<EFBFBD>\™Ù]—KˆÛÝ\˜ÙO\ÜXÖÈœÛÝ\˜ÙH—Kˆ\OH˜š[™‹ˆ™XYÛÛ›O\ÜXË™Ù]
œ™XYÛÛ›H‹˜[ÙJKˆ
Bˆ
B‚ˆÙÙÙ\‹™X<EFBFBD>Yʈ<EFBFBD>Ü™X][™ÈØ[™›ÞÛÛ<EFBFBD>Z[™\Žˆ[XYÙOI\ÈØ\ÏI\È^ÜÙYÜÜ<EFBFBD>ÏI\È‹ˆ[XYÙKˆØ\ØY ˆ\Ý
^ÜÙYÜÜ<EFBFBD>ÊKˆ
BˆÛÛ<EFBFBD>Z[™\ˆHÙ[‹™ØÚÙ\—ØÛY[<EFBFBD> ˜ÛÛ<EFBFBD>Z[™\œË˜Ü™X]J
Š˜Ü™X]WÚÝØ\™ÜÊBˆÙÙÙ\‹š[™›Êˆ”Ø[™›ÞÛÛ<EFBFBD>Z[™\ˆÜ™X]YˆYI\È[XYÙOI\È‹ˆÛÛ<EFBFBD>Z[™\‹œÚÜ<EFBFBD>ÚYYˆ\Ø]ŠÛÛ<EFBFBD>Z[™\‹œÚÜ<EFBFBD>ÚYŠH[ÙH<EFBFBD>È‹ˆ[XYÙKˆ
Bˆ™]\›ˆÛÛ<EFBFBD>Z[™\‚‚ˆ\Þ[˜ÈYˆÜ™X]JÙ[‹
ŠšÝØ\™ÜΈ[žJH OˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÙ\ÜÚ[ÛˆH]ØZ]Ý\\Š
K˜Ü™X]J
ŠšÝØ\™ÜÊBˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
Bˆ[›™\ˆHÙ\ÜÚ[Û‹—Ú[›™\‚ˆYˆ™]ÛÜšÈ[™\Ú[œÝ[˜ÙJ[›™\‹ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆ[›™\‹—רÛ\Ü××ÈHÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‚ˆØ\Ý
”Ýš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Ûˆ‹[›™\ŠKœØ[™›ÞÛ™]ÛÜšÈH™]Ûܚˆ™]\›ˆÙ\ÜÚ[Û‚‚ˆ\Þ[˜ÈYˆ[]JÙ[‹Ù\ÜÚ[ÛŽˆØ[™›ÞÙ\ÜÚ[ÛŠH OˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÛÛ<EFBFBD>Z[™\—ÚYHÙ]]ŠÙ]]ŠÙ\ÜÚ[Û‹—Ú[›™\‹œÝ]H‹›Û™JK˜ÛÛ<EFBFBD>Z[™\—ÚY‹›Û™JBˆYˆÛÛ<EFBFBD>Z[™\—ÚY‚ˆÈ™\Ý YY™›Ü<EFBFBD>Ú[ˆ›Ý›Ý[™ ÐTQ\œ›ÜˆÛÝ™\ˆHÛÛ™H܈[š\BˆÈÛÛ<EFBFBD>Z[™\‹ˆ™\]Y\Ý^Ù\[ÛˆÛÝ™\œÈHÜ›‹YÝÛˆY[[ÛˆÛØÚÙ]8 %ˆÈÛÛ<EFBFBD>Z[™\œË™Ù]
H Oˆ[œÜXÝØÛÛ<EFBFBD>Z[™\ˆ˜Z\Ù\È™\]Y\ÝɈÈÛÛ›™XÝ[Û‘\œ›Ü‹ÚXÚ\ÈHÚX›[™ÈÙˆØÚÙ\‹™\œ›ÜœË<EFBFBD>TQ\œ›Ü‚ˆÈ[™\ˆ™\]Y\ÝË”™\]Y\Ý^Ù\[Ûˆ
›ÝHÝX˜Û\ÜÊKÛÈ]\ØØ\\ˆÈ[ˆTQ\œ›Ü‹[Û›HÝ\™\ÜÈ[™Ý\™˜XÙ\ÈH<EFBFBD>[˜XÙX˜XÚÈ]™[‚ˆÈÝYÚ\ÈX\™ÝÛˆ\ÈYX[<EFBFBD>È™H™\Ý YY™›Ü<EFBFBD> ‚ˆÚ]ÛÛ<EFBFBD>^X‹œÝ\™\ÜʈØÚÙ\—Ù\œ›ÜœË“›Ý›Ý[™ ØÚÙ\—Ù\œ›ÜœË<EFBFBD>TQ\œ›Ü‹™\]Y\Ý^Ù\[Û‚ˆ
N‚ˆÙ[‹™ØÚÙ\—ØÛY[<EFBFBD> ˜ÛÛ<EFBFBD>Z[™\œË™Ù]
ÛÛ<EFBFBD>Z[™\—ÚY
KšÚ[
def _sandbox_network() -> str | None:
value = os.environ.get(_SANDBOX_NETWORK_ENV, "").strip()
return value or None
def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
network = _sandbox_network()
if network:
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None:
"""Apply optional cgroup resource caps from the environment. Unset/blank
values leave docker's default (unbounded), so this is opt-in per host."""
mem_limit = os.environ.get("STRIX_SANDBOX_MEM_LIMIT", "").strip()
if mem_limit:
create_kwargs["mem_limit"] = mem_limit
shm_size = os.environ.get("STRIX_SANDBOX_SHM_SIZE", "").strip()
if shm_size:
create_kwargs["shm_size"] = shm_size
cpus = os.environ.get("STRIX_SANDBOX_CPUS", "").strip()
if cpus:
with contextlib.suppress(ValueError, OverflowError):
nano_cpus = int(float(cpus) * 1_000_000_000)
if 0 < nano_cpus <= 2**63 - 1:
create_kwargs["nano_cpus"] = nano_cpus
pids_limit = os.environ.get("STRIX_SANDBOX_PIDS_LIMIT", "").strip()
if pids_limit:
with contextlib.suppress(ValueError):
create_kwargs["pids_limit"] = int(pids_limit)
def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
"""Bound the container's json-file log so a runaway process in the sandbox
(e.g. a tool that busy-loops writing to stdout) cannot fill the host disk
and take the Docker daemon down with it.
Unlike the cgroup caps above, this defaults **on** — docker's own default
is an unbounded json-file, which is unsafe for an autonomous agent that
executes arbitrary commands. ``max-file`` rotation means the on-disk cap is
``max-size * max-file``. Set ``STRIX_SANDBOX_LOG_MAX_SIZE`` to ``0``/``off``
to opt back out to docker's default."""
max_size = os.environ.get("STRIX_SANDBOX_LOG_MAX_SIZE", "50m").strip()
if max_size.lower() in ("0", "off", "none", "unlimited"):
return
max_file = os.environ.get("STRIX_SANDBOX_LOG_MAX_FILE", "3").strip() or "3"
create_kwargs["log_config"] = LogConfig(
type=LogConfig.types.JSON,
config={"max-size": max_size, "max-file": max_file},
)
def _apply_run_labels(create_kwargs: dict[str, Any]) -> None:
run_id = os.getenv("STRIX_RUN_ID")
if not run_id:
return
labels = create_kwargs.setdefault("labels", {})
if not isinstance(labels, dict):
return
labels["strix-run-id"] = run_id
run_type = os.getenv("STRIX_RUN_TYPE")
if run_type:
labels["strix-run-type"] = run_type
class StrixDockerSandboxSession(DockerSandboxSession):
sandbox_network: str = ""
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
try:
self._container.reload()
except docker_errors.APIError as e:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_reload_failed",
"network": self.sandbox_network,
},
cause=e,
) from e
attrs = getattr(self._container, "attrs", {}) or {}
networks = attrs.get("NetworkSettings", {}).get("Networks", {})
endpoint = networks.get(self.sandbox_network) or {}
ip = endpoint.get("IPAddress") or endpoint.get("GlobalIPv6Address")
if not isinstance(ip, str) or not ip:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_not_on_network",
"network": self.sandbox_network,
},
)
host = f"[{ip}]" if ":" in ip else ip
return ExposedPortEndpoint(host=host, port=port, tls=False)
class StrixDockerSandboxClient(DockerSandboxClient):
# Host directories to bind-mount into the container, set by the docker
# backend before ``create()``. Each item is ``{source, target, read_only}``.
strix_bind_mounts: list[dict[str, Any]] | None = None
async def _create_container(
self,
image: str,
*,
manifest: Manifest | None = None,
exposed_ports: tuple[int, ...] = (),
session_id: uuid.UUID | None = None,
) -> Container:
# ----- BEGIN VERBATIM COPY of DockerSandboxClient._create_container -----
# SDK ref: src/agents/sandbox/sandboxes/docker.py:1434-1477 (v0.14.6).
if not self.image_exists(image):
repo, tag = parse_repository_tag(image)
self.docker_client.images.pull(repo, tag=tag or None, all_tags=False)
assert self.image_exists(image)
environment: dict[str, str] | None = None
if manifest:
environment = await manifest.environment.resolve()
# Strix delta from the SDK body: drop ``entrypoint`` override and
# supply ``tail -f /dev/null`` as ``command`` so the image's
# ENTRYPOINT (``docker-entrypoint.sh``) runs setup, then ``exec
# "$@"`` becomes ``exec tail -f /dev/null`` for the keep-alive.
# Without this, caido-cli + the in-container CA trust never get
# initialized.
create_kwargs: dict[str, Any] = {
"image": image,
"detach": True,
"command": ["tail", "-f", "/dev/null"],
"environment": environment,
}
if manifest is not None:
docker_mounts = _build_docker_volume_mounts(
manifest,
session_id=session_id,
)
if docker_mounts:
create_kwargs["mounts"] = docker_mounts
if _manifest_requires_fuse(manifest):
create_kwargs.update(
devices=["/dev/fuse"],
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
elif _manifest_requires_sys_admin(manifest):
create_kwargs.update(
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
if exposed_ports:
create_kwargs["ports"] = {
_docker_port_key(port): ("127.0.0.1", None) for port in exposed_ports
}
# ----- END VERBATIM COPY -----
# Strix injections — append, don't overwrite, so FUSE/SYS_ADMIN survives.
cap_add = create_kwargs.setdefault("cap_add", [])
if not isinstance(cap_add, list):
cap_add = list(cap_add)
create_kwargs["cap_add"] = cap_add
for cap in ("NET_ADMIN", "NET_RAW"):
if cap not in cap_add:
cap_add.append(cap)
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
_apply_run_labels(create_kwargs)
# Strix injection: local source trees, sorted shallowest-first so a
# nested spec lands on top of the tree it covers.
bind_mounts = self.strix_bind_mounts or ()
if bind_mounts:
mounts = create_kwargs.setdefault("mounts", [])
for spec in sorted(bind_mounts, key=lambda s: str(s["target"]).count("/")):
mounts.append(
DockerSDKMount(
target=spec["target"],
source=spec["source"],
type="bind",
read_only=spec.get("read_only", False),
)
)
logger.debug(
"Creating sandbox container: image=%s caps=%s exposed_ports=%s",
image,
cap_add,
list(exposed_ports),
)
container = self.docker_client.containers.create(**create_kwargs)
logger.info(
"Sandbox container created: id=%s image=%s",
container.short_id if hasattr(container, "short_id") else "?",
image,
)
return container
async def create(self, **kwargs: Any) -> SandboxSession:
session = await super().create(**kwargs)
network = _sandbox_network()
inner = session._inner
if network and isinstance(inner, DockerSandboxSession):
inner.__class__ = StrixDockerSandboxSession
cast("StrixDockerSandboxSession", inner).sandbox_network = network
return session
async def delete(self, session: SandboxSession) -> SandboxSession:
container_id = getattr(getattr(session._inner, "state", None), "container_id", None)
if container_id:
# Best-effort kill: NotFound/APIError cover a gone or unhappy
# container. RequestException covers a torn-down daemon socket —
# containers.get() -> inspect_container raises requests'
# ConnectionError, which is a sibling of docker.errors.APIError
# under requests.RequestException (not a subclass), so it escapes
# an APIError-only suppress and surfaces a full traceback even
# though this teardown is meant to be best-effort.
with contextlib.suppress(
docker_errors.NotFound, docker_errors.APIError, RequestException
):
self.docker_client.containers.get(container_id).kill()
return await super().delete(session)
Bˆ™]\›ˆ]ØZ]Ý\\Š
K™[]JÙ\ÜÚ[ÛŠB

View file

@ -1,218 +1,66 @@
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
­r‡^Ñf¥–ئ{[r‰Ý°ë­¦ëHˆˆ•\Ýț܈ݚ^ ˜ÛÛ™šYË›ØY\Žˆ”Óӈݙ\œšY\Ë[X\È™\ÛÛ][Û‹\œÚ\Ý[˜ÙKˆˆˆ‚‚™œ›ÛH×Ù<EFBFBD>]\™W×È[\Ü<EFBFBD>[››Ý][ۜ‚š[\Ü<EFBFBD>œÛÛ‚™œ›ÛH\[™È[\Ü<EFBFBD>TWÐÒPÒÒS‘‚š[\Ü<EFBFBD>]\Ý™œ›ÛHY[<EFBFBD>XÈ[\Ü<EFBFBD>[X\ÐÚÚXÙ\ËšY[ ˜[Y][Û‘\œ›Ü‚™œ›ÛHY[<EFBFBD>XË™šY[È[\Ü<EFBFBD>šY[[™›Â‚™œ›ÛHÝš^ ˜ÛÛ™šYÈ[\Ü<EFBFBD>ØY\‚™œ›ÛHÝš^ ˜ÛÛ™šYËœÙ][™ÜÈ[\Ü<EFBFBD>ÛÛ<EFBFBD>^Ù][™Ü‚‚šYˆTWÐÒPÒÒS‘΂ˆœ›ÛH]Xˆ[\Ü<EFBFBD>]‚‚—ÓWÑS•—ÒÑVTÈHˆ”Õ’VÓH‹ˆ“WÐTWÒÑVH‹ˆ“ÔS<EFBFBD>RWÐTWÒÑVH‹ˆ“WÐTWÐ<EFBFBD>TÑH‹ˆ“ÔS<EFBFBD>RWÐTWÐ<EFBFBD>TÑH‹ˆ“ÔS<EFBFBD>RWÐ<EFBFBD>TÑWÕT“‹ˆ“USWÐ<EFBFBD>TÑWÕT“‹ˆ“ÓSPWÐTWÐ<EFBFBD>TÑH‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹ˆ”Õ’VÑ“Ô<EFBFBD>ÑWÔ‘TURT‘QÕÓÓÐÒÒPÑH‹ˆ“WÕSQSÕU‹ˆ”T”VUWÐTWÒÑVH‹ˆÈ<EFBFBD>[<EFBFBD>[YTÙ][™Üˆ”Õ’VÒSPQÑH‹ˆ”Õ’VÒSPQÑWÔSÔÓPÖH‹ˆ”Õ’VÔ•S•SQWÐ<EFBFBD>PÒÑS‘‹ˆ”Õ’VÐÐRQ×ГÓÕÕÐRUÔÈ‹ˆÈ[[Y]žTÙ][™Üˆ”Õ’VÕSSQU–H‹—B‚‚<EFBFBD>]\Ý ™š^\™J]]Ý\ÙOU<EFBFBD>YJB™YˆÜ™\Ù]ÛØY\—ÜÝ]J[ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]Ú
H Oˆ›Û™N‚ˆˆˆ”™\Ù][Ù[HÛؘ[È[™ÛX\ˆÛ›ÝÛˆ[<EFBFBD>ˆ˜\œÈ›Üˆ]\›Z[š\ÝXÈ<EFBFBD>[œËˆˆˆ‚ˆ›ÜˆÙ^H[ˆÓWÑS•—ÒÑVT΂ˆ[ÛšÙ^\]Ú ™[[<EFBFBD>ŠÙ^K˜Z\Ú[™ÏQ˜[ÙJBˆ[ÛšÙ^\]Ú œÙ]]ŠØY\‹—ØØXÚY‹›Û™JBˆ[ÛšÙ^\]Ú œÙ]]ŠØY\‹—ÛÝ™\œšYH‹›Û™JB‚‚ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈÜ™XYÚœÛÛ—ÛÝ™\œšY\ÂˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛZ\ÜÚ[™×Ùš[J\Ü]ˆ]
H Oˆ›Û™N‚ˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê\Ü] È››ÜKšœÛÛˆŠHOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\רÛÜœ<EFBFBD>\ÚœÛÛŠ\Ü]ˆ]
H Oˆ›Û™N‚ˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
žÛ›Ý˜[YœÛÛˆ‹[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Û›Û—ÙXÝÙ[<EFBFBD>Š\Ü]ˆ]
H Oˆ›Û™N‚ˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ››Ý‹˜H‹™XÝ—_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛX\×Ý×Û™\ÝYÜÙ][™ÜÊ\Ü]ˆ]
H Oˆ›Û™N‚ˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
ˆœÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆ›^K[[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH<EFBFBD>]‹N‹ˆ
Bˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHˆ›HŽˆÈ›[Ù[Žˆ›^K[[Ù[ŸKˆš[<EFBFBD>Yܘ][ۜȎˆÈœ\œ^]WØ\WÚÙ^HŽˆœÈŸKˆB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÜÚÚ\×ÚÙ^\ר[™XYWÚ[—Ù[<EFBFBD>š\›ÛŠˆ\Ü]ˆ] [ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]ÚŠH Oˆ›Û™N‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ДՒVÓH‹™œ›ÛKY[<EFBFBD>ˆŠBˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆÈ[<EFBFBD>ˆÚ[œÈ OˆH”ÓÓˆ˜[YH\È›ÝÝ\™˜XÙY\È[ˆ[š]ÝØ\™Ë‚ˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[<EFBFBD>—ÝÚ[œ×ØXÜ›ÜÜ×ÙšY[Ø[X\Ù\ʈ\Ü]ˆ] [ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]ÚŠH Oˆ›Û™N‚ˆÈ\WÚÙ^H™\ÛÛ™\Èœ›ÛH[X\ÐÚÚXÙ\Ê“WÐTWÒÑVH‹“ÔS<EFBFBD>RWÐTWÒÑVHŠKˆH[<EFBFBD>‚ˆÈÙ]ÈÛ™H[X\ÈÚ[HH\œÚ\ÝYš[HÛÈ[›Ý\‹ˆ[<EFBFBD>ˆ]\ÝÝ[Ú[‹ÛˆÈHÝ[Hš[H˜[YH]\ݛݙHÝ\™˜XÙY\È[ˆ[š]ÝØ\™È
ÚXÚÝ]˜[šÜÈ[<EFBFBD>ŠK‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>Š“ÔS<EFBFBD>RWÐTWÒÑVH‹œÚËY[<EFBFBD>ˆŠBˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ“WÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[<EFBFBD>—ÝÚ[œ×ØØ\ÙWÚ[œÙ[œÚ]]™[Jˆ\Ü]ˆ] [ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]ÚŠH Oˆ›Û™N‚ˆÈÙ][™ÜÈ\ÙHØ\ÙWÜÙ[œÚ]]™OQ˜[ÙKÛÈHÝÙ\˜Ø\ÙH[<EFBFBD>ˆ˜\ˆ[ÛÈÛÝ[<EFBFBD>È\ÈÙ] ‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ŠœÝš^ÛH‹™œ›ÛKY[<EFBFBD>ˆŠBˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ý\Ù\×ÚœÛÛ—ÝÚ[—Û›×Ø[X\×Ú[—Ù[<EFBFBD>š\›ÛŠ\Ü]ˆ]
H Oˆ›Û™N‚ˆÈ›È[X\ÈÙˆ\WÚÙ^H\ÈÙ][ˆH[<EFBFBD>š\›Û›Y[<EFBFBD> OˆHš[H˜[YH\È\ÙY ]™[‚ˆÈÚ[ˆ]\ÈÝÜ™Y[™\ˆH›Û‹Yš\œÝ[X\Ë‚ˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ“ÔS<EFBFBD>RWÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
HOHÈ›HŽˆȘ\WÚÙ^HŽˆœÚËYš[HŸ_B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\×Ü™Z™XÝ×ÜÝX—Û›ÝXÙWݘ[Y\Ê
H Oˆ›Û™N‚ˆÚ]]\Ý œ˜Z\Ù\ʘ[Y][Û‘\œ›ÜŠN‚ˆÛÛ<EFBFBD>^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏM<EFBFBD>
B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\רXØÙ\×Ù›ÛÜŠ
H Oˆ›Û™N‚ˆ\ÜÙ\<EFBFBD>ÛÛ<EFBFBD>^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏLL <EFBFBD>
K<EFBFBD>ÛÛÛÝ]]ÛX^Øž]\ÈOH L <EFBFBD>‚‚™Yˆ\ÝÜ<EFBFBD>[<EFBFBD>[YWØØZYר›ÛÝÝØZ]Ùœ›ÛWÙ[<EFBFBD>Š[ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]Ú
H Oˆ›Û™N‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ДՒVÐÐRQ×ГÓÕÕÐRUÔÈ‹ŽL ŠB‚ˆ\ÜÙ\<EFBFBD>ØY\‹›ØYÜÙ][™ÜÊ
Kœ<EFBFBD>[<EFBFBD>[YK˜ØZYר›ÛÝÝØZ]ÜÈOHL ‚‚™Yˆ\ÝÜ<EFBFBD>[<EFBFBD>[YWÚ[XYÙWÜ[ÜÛXÞWÙœ›ÛWÙ[<EFBFBD>Š[ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]Ú
H Oˆ›Û™N‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ДՒVÒSPQÑWÔSÔÓPÖH‹›™]™\ˆŠB‚ˆ\ÜÙ\<EFBFBD>ØY\‹›ØYÜÙ][™ÜÊ
Kœ<EFBFBD>[<EFBFBD>[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆ‚‚‚ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈØ[X\Ù\×ٛ܂ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÚ[\WØ[X\Ê
H Oˆ›Û™N‚ˆš[™›ÈHšY[[™›Ê[X\ÏH”ÒSTWÐSPTÈŠBˆ\ÜÙ\<EFBFBD>ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ”ÒSTWÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗØ[X\רÚÚXÙ\Ê
H Oˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
È\NˆYۛܙVØ\ÜÚYÛ›Y[<EFBFBD>BˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\Ê‘’T”Õ‹”ÑPÓÓ‘ŠKˆ
Bˆ\ÜÙ\<EFBFBD>ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ‘’T”Õ‹”ÑPÓÓ‘—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÝš[™×ݘ[Y][Û—Ø[X\Ê
H Oˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
Y˜][S›Û™K˜[Y][Û—Ø[X\ÏH”Õ—ÐSPTÈŠHÈ\NˆYۛܙVØ\ÜÚYÛ›Y[<EFBFBD>Bˆ\ÜÙ\<EFBFBD>ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHȔ՗ÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×Ù›Ü—Û›×Ø[X\Ê
H Oˆ›Û™N‚ˆ\ÜÙ\<EFBFBD>ØY\‹—Ø[X\Ù\×ٛ܊šY[[™›Ê
JHOH×B‚‚ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\WØÛÛ™šY×ÛÝ™\œšYH
ÈØYÜÙ][™ÜÈ›Ý[™ ]š\ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ\WÛÝ™\œšYWØ[™ÛØYÜÙ][™Ü×Ü›Ý[™Ýš\
\Ü]ˆ]
H Oˆ›Û™N‚ˆ]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ] <EFBFBD>Üš]WÝ^
ˆœÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆœ›Ý[™ ]š\ [[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH<EFBFBD>]‹N‹ˆ
B‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ]
BˆÙ][™ÜÈHØY\‹›ØYÜÙ][™ÜÊ
B‚ˆ\ÜÙ\<EFBFBD>Ù][™ÜË›K›[Ù[OHœ›Ý[™ ]š\ [[Ù[‚ˆ\ÜÙ\<EFBFBD>Ù][™ÜËš[<EFBFBD>Yܘ][ۜ˜\œ^]WØ\WÚÙ^HOHœÈ‚ˆÈÙXÛÛ™Ø[\ÈY[[Ú^™Y OˆØ[YHØš™XÝ ‚ˆ\ÜÙ\<EFBFBD>ØY\‹›ØYÜÙ][™ÜÊ
H\ÈÙ][™Ü‚‚™Yˆ\ÝØ\WØÛÛ™šY×ÛÝ™\œšYWÚ[<EFBFBD>˜[Y]\רØXÚJ\Ü]ˆ]
H Oˆ›Û™N‚ˆš\œÝH\Ü] È™š\œÝ šœÛÛˆ‚ˆš\œÝ <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆ™š\œÝ [[Ù[Ÿ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠBˆÙXÛÛ™H\Ü] ÈœÙXÛÛ™ šœÛÛˆ‚ˆÙXÛÛ™ <EFBFBD>Üš]WÝ^
œÛÛ‹™[\ÊÈ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆœÙXÛÛ™ [[Ù[Ÿ_JK[˜ÛÙ[™ÏH<EFBFBD>]‹NŠB‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJš\œÝ
Bˆ\ÜÙ\<EFBFBD>ØY\‹›ØYÜÙ][™ÜÊ
K›K›[Ù[OH™š\œÝ [[Ù[‚‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJÙXÛÛ™
Bˆ\ÜÙ\<EFBFBD>ØY\‹›ØYÜÙ][™ÜÊ
K›K›[Ù[OHœÙXÛÛ™ [[Ù[‚‚‚ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\œÚ\ÝØÝ\œ™[<EFBFBD>ˆÈ KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[<EFBFBD>ÝÜš]\×Ù[<EFBFBD>—Ø›ØÚÊ\Ü]ˆ] [ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]Ú
H Oˆ›Û™N‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ДՒVÓH‹œ\œÚ\ÝY [[Ù[ŠBˆ\™Ù]H\Ü] ÈœÝXˆˆ ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[<EFBFBD>
from __future__ import annotations
B‚ˆ\ÜÙ\<EFBFBD>\™Ù] ™^\ÝÊ
Bˆ\ÜÙ\<EFBFBD>œÛÛ‹›ØYÊ\™Ù] œ™XYÝ^
[˜ÛÙ[™ÏH<EFBFBD>]‹NŠJHOHˆ™[<EFBFBD>ˆŽˆÈ”Õ’VÓHŽˆœ\œÚ\ÝY [[Ù[ŸBˆB‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[<EFBFBD>ÜÙ]×Ì Œ Û[ÙJ\Ü]ˆ] [ÛšÙ^\]Úˆ]\Ý “[ÛšÙ^T]Ú
H Oˆ›Û™N‚ˆ[ÛšÙ^\]Ú œÙ][<EFBFBD>ДՒVÓH‹œ\œÚ\ÝY [[Ù[ŠBˆ\™Ù]H\Ü] ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[<EFBFBD>
import json
from typing import TYPE_CHECKING
B‚ˆ\ÜÙ\<EFBFBD>\™Ù] œÝ]
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
KœÝÛ[ÙH ˆ ÍÍÍÈOH ÍŒ 

View file

@ -0,0 +1,85 @@
­r‡^Ñf¥–ئ{O,yÊ'vî¶›­"""Regression tests for sandbox image acquisition policy."""
from __future__ import annotations
from typing import Literal
from unittest.mock import MagicMock, patch
import pytest
from strix.config.settings import RuntimeSettings, Settings
from strix.interface.environment import pull_docker_image
from strix.runtime.docker_client import StrixDockerSandboxClient
def _missing_image_client(
*, pull_policy: Literal["auto", "never"] = "auto"
) -> StrixDockerSandboxClient:
client = StrixDockerSandboxClient.__new__(StrixDockerSandboxClient)
client.docker_client = MagicMock()
client.image_pull_policy = pull_policy
client.image_exists = MagicMock(return_value=False)
return client
def test_cli_never_policy_rejects_missing_image_without_pull() -> None:
docker_client = MagicMock()
settings = Settings(
runtime=RuntimeSettings(
STRIX_IMAGE="example.invalid/sandbox@sha256:deadbeef",
STRIX_IMAGE_PULL_POLICY="never",
)
)
with (
patch("strix.interface.environment.check_docker_connection", return_value=docker_client),
patch("strix.interface.environment.image_exists", return_value=False),
patch("strix.interface.environment.load_settings", return_value=settings),
pytest.raises(
RuntimeError,
match=r"example\.invalid/sandbox@sha256:deadbeef.*forbids pulling",
),
):
pull_docker_image()
docker_client.api.pull.assert_not_called()
@pytest.mark.asyncio
async def test_container_creation_rechecks_never_policy_without_pull() -> None:
client = _missing_image_client(pull_policy="never")
with pytest.raises(
RuntimeError,
match=r"example\.invalid/sandbox@sha256:deadbeef.*forbids pulling",
):
await client._create_container("example.invalid/sandbox@sha256:deadbeef")
client.docker_client.images.pull.assert_not_called()
client.docker_client.containers.create.assert_not_called()
@pytest.mark.asyncio
async def test_container_creation_uses_present_image_with_never_policy() -> None:
client = _missing_image_client(pull_policy="never")
client.image_exists = MagicMock(return_value=True)
client.docker_client.containers.create.return_value = MagicMock(short_id="abc123")
await client._create_container("example.invalid/sandbox@sha256:deadbeef")
client.docker_client.images.pull.assert_not_called()
client.docker_client.containers.create.assert_called_once()
@pytest.mark.asyncio
async def test_container_creation_keeps_default_auto_pull() -> None:
client = _missing_image_client()
client.image_exists = MagicMock(side_effect=[False, True])
client.docker_client.containers.create.return_value = MagicMock(short_id="abc123")
await client._create_container("example.invalid/sandbox:latest")
client.docker_client.images.pull.assert_called_once_with(
"example.invalid/sandbox", tag="latest", all_tags=False
)
client.docker_client.containers.create.assert_called_once()