From c42887410815915b0c64ac70a0120db3e282cb02 Mon Sep 17 00:00:00 2001
From: maxi-maxima <240684166@qq.com>
Date: Wed, 12 Aug 2026 10:18:51 +0800
Subject: [PATCH 1/3] feat(runtime): support pull-never sandbox images
---
docs/advanced/configuration.mdx | 186 ++--------------
strix/config/settings.py | 7 +-
strix/interface/environment.py | 308 +++++++++----------------
strix/runtime/backends.py | 4 +-
strix/runtime/docker_client.py | 382 +++++++++-----------------------
tests/test_config_loader.py | 278 ++++++-----------------
tests/test_image_pull_policy.py | 85 +++++++
7 files changed, 379 insertions(+), 871 deletions(-)
create mode 100644 tests/test_image_pull_policy.py
diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index f1542b75..5148d616 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -1,169 +1,17 @@
----
-title: "Configuration"
-description: "Environment variables for Strix"
----
-
-Configure Strix using environment variables or a config file.
-
-## LLM Configuration
-
-
- Model name in LiteLLM format (e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4-6`).
-
-
-
- API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).
-
-
-
- Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
-
-
-
- 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.
-
-
-
- Request timeout in seconds for LLM calls.
-
-
-
- Maximum number of retries for LLM API calls on transient failures.
-
-
-
- Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium` for quick scan mode.
-
-
-
- Timeout in seconds for memory compression operations (context summarization).
-
-
-### 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.
-
-
- Model used to judge whether a candidate finding duplicates an existing report.
- Falls back to `STRIX_LLM` when unset.
-
-
-
- Optional provider key for the deduplication model.
-
-
-
- Optional custom API base URL for the deduplication model. Use when the dedupe
- model runs on a different endpoint than the main model.
-
-
-
- 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.
-
-
-
- Reasoning effort for the deduplication model. Defaults to the model's own
- baseline when unset.
-
-
-## Optional Features
-
-
- API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
-
-
-
- Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://`), and Postman environments (`postman://?env=`) to resolve collection variables. Not needed when passing a local collection export file.
-
-
-
- Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
-
-
-
- OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
-
-
-
- API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.
-
-
-
- Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.
-
-
-When remote OTEL vars are not set, Strix still writes complete run telemetry locally to:
-
-```bash
-strix_runs//events.jsonl
-```
-
-When remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.
-
-## Docker Configuration
-
-
- Docker image to use for the sandbox container.
-
-
-
- Docker daemon socket path. Use for remote Docker hosts or custom configurations.
-
-
-
- Runtime backend for the sandbox environment.
-
-
-## Sandbox Configuration
-
-
- Maximum execution time in seconds for sandbox operations.
-
-
-
- Timeout in seconds for connecting to the sandbox container.
-
-
-## 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�]Nˆ�ÛÛ™šYÝ\˜][Ûˆ‚™\ØÜš\[ÛŽˆ‘[�š\›Û›Y[�˜\šXX›\ț܈ݚ^‚‹KKB‚�ÛÛ™šYÝ\™HÝš^\Ú[™È[�š\›Û›Y[�˜\šXX›\È܈HÛÛ™šYÈš[K‚‚ˆÈÈHÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÓHˆ\OHœÝš[™Èˆ™\]Z\™Y‚ˆ[Ù[˜[YH[ˆ]SH›Ü›X]
+K™Ë‹Ü[˜ZKÙÜMK�[�›ÜXËØÛ]YK\ÛÛ›™]MM˜
+K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H›Üˆ[Ý\ˆH›ÝšY\‹ˆ›Ý™\]Z\™Y›ÜˆØØ[[Ù[È܈ÛÝY›ÝšY\ˆ]]
+™\�^RKUÔÈ™Y›ØÚÊK‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÐTWÐ�TÑHˆ\OHœÝš[™È�‚ˆÝ\ÝÛHTH˜\ÙHT“ˆ[ÛÈXØÙ\ÈÔS�RWÐTWÐ�TÑXUSWÐ�TÑWÕT“܈ÓSPWÐTWÐ�TÑX‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÑV�WÒPQT”Ȉ\OHœÝš[™È�‚ˆ^˜HXY\œÈÙ[�Ûˆ]™\žHH™\]Y\Ý\ÈH”Óӈؚ™XÝ
+K™Ë‚ˆÈ–Q™X]\™KRÙ^HŽˆ�˜[YH‹–U[˜[�Žˆ˜XÛYHŸX
+Kˆ\ÙY�[›ÜˆÜ[�RKXÛÛ\]X›BˆØ]]Ø^\È]™\]Z\™H]šX�][ۈ܈›Ý][™ÈXY\œÈ[ˆY][ÛˆÈH™X\™\‚ˆÚÙ[‹ˆH™X\™\ˆÚÙ[ˆ]Ù[ˆÝ[ÛÛY\Èœ›ÛHWÐTWÒÑVXˆ\Y\Èț݈H]SH[™˜]]™HÜ[�RH›Ý][™È]Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÕSQSÕUˆY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆ™\]Y\Ý[Y[Ý][ˆÙXÛۙț܈HØ[Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÓWÓPVÔ‘U’QTȈY˜][H�Hˆ\OHš[�YÙ\ˆ�‚ˆX^[][H�[X™\ˆÙˆ™]šY\ț܈HTHØ[ÈÛˆ˜[œÚY[�˜Z[\™\Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆY˜][HšYÚˆ\OHœÝš[™È�‚ˆÛÛ�›Û[šÚ[™ÈY™›Ü�›Üˆ™X\ÛÛš[™È[Ù[ˈ˜[Y˜[Y\Έ›Û™XZ[š[X[ÝØYY][XYÚYÚX^ˆY˜][ÈÈYY][X›Üˆ]ZXÚÈØØ[ˆ[ÙK‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÓQSSÔ–WÐÓÓT‘TÔÓÔ—ÕSQSÕUˆY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆ[Y[Ý][ˆÙXÛۙț܈Y[[ÜžHÛÛ\™\ÜÚ[ÛˆÜ\˜][ÛœÈ
+ÛÛ�^Ý[[X\š^˜][ÛŠK‚�Ô\˜[QšY[‚‚ˆÈÈÈYXØ]YY\XØ][Ûˆ[Ù[‚‘š[™[™ÈY\XØ][Ûˆ\ÈHÚX\Ý�XÝ\™YÛ\ÜÚYšXØ][Ûˆ\ÚˈžHY˜][]œ�[œÈÛˆHXZ[ˆ[Ù[�][ÝHØ[ˆ›Ý]H]ÈHÛX[\‹ØÚX\\ˆ[Ù[Ú]Ý]˜Y™™XÝ[™ÈHYÙ[�È]ÈHXÝX[\Ý[™Ë‚‚�\˜[QšY[]H”Õ’VÑQTWÓSÑSˆ\OHœÝš[™È�‚ˆ[Ù[\ÙYÈ�YÙHÚ]\ˆHØ[™Y]Hš[™[™È\XØ]\È[ˆ^\Ý[™È™\Ü�‚ˆ˜[ȘXÚÈÈÕ’VÓXÚ[ˆ[œÙ]‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÐTWÒÑVHˆ\OHœÝš[™È�‚ˆÜ[Û˜[›ÝšY\ˆÙ^H›ÜˆHY\XØ][Ûˆ[Ù[‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÐTWÐ�TÑHˆ\OHœÝš[™È�‚ˆÜ[Û˜[Ý\ÝÛHTH˜\ÙHT“›ÜˆHY\XØ][Ûˆ[Ù[ˆ\ÙHÚ[ˆHY\Bˆ[Ù[�[œÈÛˆHY™™\™[�[™Ú[�[ˆHXZ[ˆ[Ù[‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÑV�WÒPQT”Ȉ\OHœÝš[™È�‚ˆÜ[Û˜[”Óӈؚ™XÝÙˆ^˜HXY\œÈÙ[�Ûˆ]™\žHY\XØ][Û‹[[Ù[ˆ™\]Y\ÝK™ËˆÈ–Q™X]\™KRÙ^HŽˆ�˜[YHŸXˆHYXØ]YY\H[Ù[™]™\‚ˆ[š\š]ÈWÑV�WÒPQT”ØÈÙ]\ÈÚ[ˆ]È[™Ú[�™YYÈÝ\ÝÛHXY\œË‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÑQTWÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆ\OHœÝš[™È�‚ˆ™X\ÛÛš[™ÈY™›Ü�›ÜˆHY\XØ][Ûˆ[Ù[ˆY˜][ÈÈH[Ù[ ÜÈÝÛ‚ˆ˜\Ù[[™HÚ[ˆ[œÙ]‚�Ô\˜[QšY[‚‚ˆÈÈÜ[Û˜[™X]\™\‚�\˜[QšY[]H”T”VUWÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H›Üˆ\œ^]HRKˆ[˜X›\È™X[][YHÙXˆÙX\˜Ú\š[™ÈØØ[œÈ›ÜˆÔÒS•[™�[™\˜Xš[]H™\ÙX\˜Ú‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”ÔÕPS—ÐTWÒÑVHˆ\OHœÝš[™È�‚ˆÜÝX[ˆTHÙ^H
+PRËx )˜
+Kˆ[˜X›\È™]Ú[™ÈÜÝX[ˆÛÛXÝ[ۜȞHY\ÈH\™Ù]
+ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY˜
+K[™ÜÝX[ˆ[�š\›Û›Y[�È
+ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY�Ù[��O[�š\›Û›Y[�]ZY˜
+HÈ™\ÛÛ™HÛÛXÝ[Ûˆ˜\šXX›\ˈ›Ý™YYYÚ[ˆ\ÜÚ[™ÈHØØ[ÛÛXÝ[Ûˆ^Ü�š[K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÕSSQU–HˆY˜][HŒHˆ\OHœÝš[™È�‚ˆ[[Y]žHÙÙÛKˆÙ]Ș[ÙX›ØÜˆÙ™˜È\ØX›H[[Y]žH
+ÜÝÙËØØ\™‹ÕS
+K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÐ�TÑWÕT“ˆ\OHœÝš[™È�‚ˆÕÕ˜XÙ[Ûܘ\ÙHT“›Üˆ™[[ÝHÜ[•[[Y]žH^Ü�ˆYˆ[œÙ]Ýš^ÙY\ȘXÙ\ÈØØ[Û›K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H\ÙY›Üˆ™[[ÝH˜XÙH^Ü�ˆ™[[ÝH^Ü�\È[˜X›YÛ›HÚ[ˆ›Ý�PÑSÓÔÐ�TÑWÕT“[™�PÑSÓÔÐTWÒÑVX\™HÙ]‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÒPQT”Ȉ\OHœÝš[™È�‚ˆÜ[Û˜[Ý\ÝÛHÕSXY\œÈ
+”Óӈؚ™XÝ܈Ù^O]˜[YKÙ^L�]˜[YL˜
+Kˆ\ÙY�[›Üˆ[™Ù�\ÙH܈Ý\ÝÛKÜÙ[‹ZÜÝYÕØ]]Ø^\Ë‚�Ô\˜[QšY[‚‚•Ú[ˆ™[[ÝHÕS˜\œÈ\™H›ÝÙ]Ýš^Ý[Üš]\ÈÛÛ\]H�[ˆ[[Y]žHØØ[H΂‚˜˜\ڜݚ^Ü�[œËÏ�[—Û˜[YO‹Ù]™[�ËšœÛÛ›˜‚•Ú[ˆ™[[ÝH˜\œÈ\™HÙ]Ýš^X[]Üš]\È[[Y]žHÈ›ÝØØ[”ÓÓ“[™H™[[ÝHÕS[™Ú[�‚‚ˆÈÈØÚÙ\ˆÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÒSPQÑHˆY˜][H™ÚÜ‹š[ËÝ\Ù\Ýš^ÜÝš^\Ø[™›ÞŒKŒËŒˆ\OHœÝš[™È�‚ˆØÚÙ\ˆ[XYÙHÈ\ÙH›ÜˆHØ[™›ÞÛÛ�Z[™\‹‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÒSPQÑWÔSÔÓPÖHˆY˜][H˜]]Ȉ\OHœÝš[™È�‚ˆÛÛ�›ÛÈØ[™›Þ[XYÙHXÜ]Z\Ú][Û‹ˆ\ÙH™]™\˜È™\]Z\™HH™[ØYY[XYÙBˆ[™˜Z[Ú]Ý][[™ÈÚ[ˆ]\È[˜]˜Z[X›NÈ]]Ø™\Ù\�™\ÈHY˜][ˆš\œÝ\�[ˆ[™Z]š[Ü‹ˆ\ÈÙ\țݙ\ÝšXÝ™]ÛÜšÈXØÙ\ÜÈœ›ÛHH�[›š[™ÂˆØ[™›Þ‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘ÐÒÑT—ÒÔÕˆ\OHœÝš[™È�‚ˆØÚÙ\ˆY[[ÛˆÛØÚÙ]]ˆ\ÙH›Üˆ™[[ÝHØÚÙ\ˆÜÝÈ܈Ý\ÝÛHÛÛ™šYÝ\˜][ۜ˂�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔ•S•SQWÐ�PÒÑS‘ˆY˜][H™ØÚÙ\ˆˆ\OHœÝš[™È�‚ˆ�[�[YH˜XÚÙ[™›ÜˆHØ[™›Þ[�š\›Û›Y[�‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÐÐRQ×ГÓÕÕÐRUÔȈY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆX^[][H[YH[ˆÙXÛÛ™ÈÈØZ]›ÜˆH[‹\Ø[™›ÞØZYÈ›ÞHÈ™XÛÛYH™XYK‚ˆ[˜Ü™X\ÙH\ÈÛˆÛÝÈ܈X]š[HØYYÛÛ�Z[™\ˆÜÝË‚�Ô\˜[QšY[‚‚ˆÈÈØ[™›ÞÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUˆY˜][HŒLŒˆ\OHš[�YÙ\ˆ�‚ˆX^[][H^XÝ][Ûˆ[YH[ˆÙXÛۙț܈Ø[™›ÞÜ\˜][ۜ˂�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔÐS‘“ÖÐÓÓ“‘PÕÕSQSÕUˆY˜][HŒLˆ\OHš[�YÙ\ˆ�‚ˆ[Y[Ý][ˆÙXÛۙț܈ÛÛ›™XÝ[™ÈÈHØ[™›ÞÛÛ�Z[™\‹‚�Ô\˜[QšY[‚‚ˆÈÈÛÛ™šYÈš[B‚”Ýš^ÝÜ™\ÈÛÛ™šYÝ\˜][Ûˆ[ˆ‹ËœÝš^ØÛKXÛÛ™šYËšœÛÛ˜ˆ[ÝHØ[ˆ[ÛÈÜXÚYžHHÝ\ÝÛHÛÛ™šYÈš[N‚‚˜˜\ڜݚ^K]\™Ù]‹Ø\KXÛÛ™šYÈÜ]ÝËØÛÛ™šYËšœÛÛ‚˜‚ŠŠ�ÛÛ™šYÈš[H›Ü›X]ŠŠ‚‚˜œÛÛ‚žÂˆ™[�ˆŽˆÂˆ”Õ’VÓHŽˆ›Ü[˜ZKÙÜMK�‹ˆ“WÐTWÒÑVHŽˆœÚËK‹‹ˆ‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ŽˆšYÚ‚ˆBŸB˜‚ˆÈÈ^[\HÙ]\‚˜˜\ڈș\]Z\™Y™^Ü�Õ’VÓOH›Ü[˜ZKÙÜMK�‚™^Ü�WÐTWÒÑVOHœÚËK‹‹ˆ‚‚ˆÈÜ[Û˜[ˆ[˜X›HÙXˆÙX\˜Ú™^Ü�T”VUWÐTWÒÑVOHœK‹‹ˆ‚‚ˆÈÜ[Û˜[ˆÝ\ÝÛH[Y[Ý]™^Ü�WÕSQSÕUH�Œ‚™^Ü�Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUHŒÌ‚‚˜
\ No newline at end of file
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 42a2c97e..c6f6572f 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -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")
diff --git a/strix/interface/environment.py b/strix/interface/environment.py
index 5589a1fe..4e6379d5 100644
--- a/strix/interface/environment.py
+++ b/strix/interface/environment.py
@@ -1,217 +1,115 @@
-"""Startup environment validation and Docker image management."""
+r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ý\�\[�š\›Û›Y[�˜[Y][Ûˆ[™ØÚÙ\ˆ[XYÙHX[˜YÙ[Y[�ˆˆˆ‚‚š[\Ü�ÙÙÚ[™Âš[\Ü�Ú][š[\Ü�Þ\‚™œ›ÛHšXÚ˜ÛÛœÛÛH[\Ü�ÛÛœÛÛB™œ›ÛHšXÚœ[™[[\Ü�[™[™œ›ÛHšXÚ�^[\Ü�^‚™œ›ÛHÝš^˜ÛÛ™šYÈ[\Ü�ÛÙ^ØYÜÙ][™Ü™œ›ÛHÝš^š[�\™˜XÙK�][È[\Ü�
+ˆÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[Û‹ˆ[XYÙWÙ^\Ýˈ›ØÙ\Ü×Ü[Û[™KŠB‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚™Yˆ˜[Y]WÙ[�š\›Û›Y[�
-import logging
-import shutil
-import sys
+HOˆ›Û™N‚ˆÙÙÙ\‹š[™›Ê•˜[Y][™È[�š\›Û›Y[�ŠBˆÛÛœÛÛHHÛÛœÛÛJ
+BˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÈH×BˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈH×B‚ˆÙ][™ÜÈHØYÜÙ][™ÜÊ
+B‚ˆYˆÛÙ^œÝXœØÜš\[Û—Û[Ù[
+Ù][™ÜË›K›[Ù[
+N‚ˆYˆ›ÝÛÙ^š\ר]][�XØ]Y
-from rich.console import Console
-from rich.panel import Panel
-from rich.text import Text
+N‚ˆÛÛœÛÛKœš[�
+ˆˆ–Ü™YTÕ’VÓO^ÜÙ][™ÜË›K›[Ù[H\Ù\È[Ý\ˆÚ]ÔÝXœØÜš\[Û‹‚ˆ˜�][ÝIÜ™H›ÝÚYÛ™Y[‹–Ë×H�[ˆØÞX[—\Ýš^]]ÙÚ[ˆÚ]ÜË×Hš\œÝˆ‚ˆ
+BˆÞ\Ë™^]
+JBˆÙÙÙ\‹š[™›Ê‘[�š\›Û›Y[�ÒÈ
+Ú]ÔÝ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Ð�TÑHŠB‚ˆYˆ›ÝÙ][™ÜËš[�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•�T’PP“TÈ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
+——ˆ‹Ý[OH�Ú]HŠB‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
+ˆ¸ (ˆÝ˜\ŸH‹Ý[OH˜›ÛY[ÝÈŠBˆ\œ›Ü—Ý^˜\[™
+ˆ\È›ÝÙ]ˆ‹Ý[OH�Ú]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
+—“Ü[Û˜[[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH™[HÚ]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
+ˆ¸ (ˆÝ˜\ŸH‹Ý[OH™[HY[ÝÈŠBˆ\œ›Ü—Ý^˜\[™
+ˆ\È›ÝÙ]ˆ‹Ý[OH™[HÚ]HŠB‚ˆ\œ›Ü—Ý^˜\[™
+—”™\]Z\™Y[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH�Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆYˆ˜\ˆOH”Õ’VÓHŽ‚ˆ\œ›Ü—Ý^˜\[™
+¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+”Õ’VÓH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
+ˆˆH[Ù[˜[YHÈ\ÙH
+K™Ë‹ ÛÜ[˜ZKÙÜMK� È܈‚ˆ‰Ø[�›ÜXËØÛ]YK[Ü\ËMMÉÊWˆ‹ˆÝ[OH�Ú]H‹ˆ
+B‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
+—“Ü[Û˜[[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH�Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ�TÑHŽ‚ˆ\œ›Ü—Ý^˜\[™
+¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+“WÐTWÐ�TÑH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
+ˆˆHÝ\ÝÛHTH˜\ÙHT“Yˆ\Ú[™ÈØØ[[Ù[È
+K™Ë‹Û[XKTÝY[ÊWˆ‹ˆÝ[OH�Ú]H‹ˆ
+Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^˜\[™
+¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+”T”VUWÐTWÒÑVH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
+ˆˆHTHÙ^H›Üˆ\œ^]HRHÙXˆÙX\˜Ú
+[˜X›\È™X[][YH™\ÙX\˜Ú
+Wˆ‹ˆÝ[OH�Ú]H‹ˆ
+Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^˜\[™
+¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
+ˆˆH™X\ÛÛš[™ÈY™›Ü�]™[ˆ›Û™KZ[š[X[ÝËYY][KYÚYÚ‚ˆ›X^
+Y˜][ˆYÚ
+Wˆ‹ˆÝ[OH�Ú]H‹ˆ
+B‚ˆ\œ›Ü—Ý^˜\[™
+—‘^[\HÙ]\—ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+™^Ü�Õ’VÓOIÛÜ[˜ZKÙÜMK� ׈‹Ý[OH™[HÚ]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ�TÑHŽ‚ˆ\œ›Ü—Ý^˜\[™
+ˆ™^Ü�WÐTWÐ�TÑOIÚ‹ËÛØØ[ÜÝŒLMÍ È‚ˆˆÈ™YYY›ÜˆØØ[[Ù[ÈÛ›Wˆ‹ˆÝ[OH™[HÚ]H‹ˆ
+Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^˜\[™
+ˆ™^Ü�T”VUWÐTWÒÑVOIÞ[Ý\‹\\œ^]KZÙ^KZ\™I׈‹Ý[OH™[HÚ]H‚ˆ
+Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^˜\[™
+ˆ™^Ü�Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•IÚYÚ ×ˆ‹ˆÝ[OH™[HÚ]H‹ˆ
+B‚ˆ[™[H[™[
+ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
+B‚ˆÙÙÙ\‹™X�YÊ“Z\ÜÚ[™È™\]Z\™Y[�ˆ˜\œÎˆ \È‹Z\ÜÚ[™×Ü™\]Z\™Yݘ\œÊBˆÛÛœÛÛKœš[�
+—ˆŠBˆÛÛœÛÛKœš[�
+[™[
+BˆÛÛœÛÛKœš[�
+BˆÞ\Ë™^]
+JBˆÙÙÙ\‹š[™›Êˆ‘[�š\›Û›Y[�ÒÈ
+Ü[Û˜[Z\ÜÚ[™Îˆ \ÊH‹ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈ܈››Û™H‹ˆ
+B‚‚™YˆÚXÚ×ÙØÚÙ\—Ú[œÝ[Y
-logger = logging.getLogger(__name__)
+HOˆ›Û™N‚ˆYˆÚ][�ÚXÚ
+™ØÚÙ\ˆŠH\țۙN‚ˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆÓH›Ý›Ý[™[ˆUŠBˆÛÛœÛÛHHÛÛœÛÛJ
+Bˆ\œ›Ü—Ý^H^
+Bˆ\œ›Ü—Ý^˜\[™
+‘ÐÒÑTˆ“ÕS”ÕSQ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
+——ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+•H ÙØÚÙ\‰ÈÓHØ\țݛÝ[™[ˆ[Ý\ˆU—ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+ˆ”X\ÙH[œÝ[ØÚÙ\ˆ[™[œÝ\™HH ÙØÚÙ\‰ÈÛÛ[X[™\È]˜Z[X›K——ˆ‹Ý[OH�Ú]H‚ˆ
+B‚ˆ[™[H[™[
+ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
+BˆÛÛœÛÛKœš[�
+—ˆ‹[™[—ˆŠBˆÞ\Ë™^]
+JBˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆÓH™\Ù[�ŠB‚‚™Yˆ[ÙØÚÙ\—Ú[XYÙJ
+HOˆ›Û™N‚ˆœ›ÛHØÚÙ\‹™\œ›ÜœÈ[\Ü�ØÚÙ\‘^Ù\[Û‚‚ˆÛÛœÛÛHHÛÛœÛÛJ
+BˆÛY[�HÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[ÛŠ
+B‚ˆ�[�[YHHØYÜÙ][™ÜÊ
+Kœ�[�[YBˆ[XYÙHH�[�[YKš[XYÙB‚ˆYˆ[XYÙWÙ^\ÝÊÛY[�[XYÙJN‚ˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆ[XYÙH[™XYH™\Ù[�ØØ[Nˆ \È‹[XYÙJBˆ™]\›‚‚ˆYˆ�[�[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH�[�[YQ\œ›ÜŠˆˆ‘ØÚÙ\ˆ[XYÙHÚ[XYÙH\ŸH\È›Ý]˜Z[X›HØØ[H[™‚ˆ”Õ’VÒSPQÑWÔSÔÓPÖO[™]™\ˆ›Ü˜šYÈ[[™È]‚ˆ
+B‚ˆÙÙÙ\‹š[™›Ê”[[™ÈØÚÙ\ˆ[XYÙNˆ \È‹[XYÙJBˆÛÛœÛÛKœš[�
-def validate_environment() -> None:
- logger.info("Validating environment")
- console = Console()
- missing_required_vars = []
- missing_optional_vars = []
+BˆÛÛœÛÛKœš[�
+ˆ–Ù[WT[[™È[XYÙVË×HÚ[XYÙ_HŠBˆÛÛœÛÛKœš[�
+–Ù[HY[Ý×U\ÈÛ›H\[œÈÛˆš\œÝ�[ˆ[™X^HZÙHH™]ÈZ[�]\Ë‹‹–Ë×HŠBˆÛÛœÛÛKœš[�
- settings = load_settings()
+B‚ˆÚ]ÛÛœÛÛKœÝ]\Ê–Ø›ÛÞX[—QÝÛ›ØY[™È[XYÙH^Y\œË‹‹ˆ‹Ü[›™\�H™ÝÈŠH\ÈÝ]\΂ˆžN‚ˆ^Y\œ×Ú[™›ÎˆXÝÜ݋ݗHHßBˆ\ÝÝ\]HHˆ‚‚ˆ›Üˆ[™H[ˆÛY[�˜\Kœ[
+[XYÙKÝ™X[OU�YKXÛÙOU�YJN‚ˆ\ÝÝ\]HH›ØÙ\Ü×Ü[Û[™J[™K^Y\œ×Ú[™›ËÝ]\Ë\ÝÝ\]JB‚ˆ^Ù\ØÚÙ\‘^Ù\[Ûˆ\ÈN‚ˆÙÙÙ\‹™X�YÊ‘˜Z[YÈ[ØÚÙ\ˆ[XYÙH \È‹[XYÙK^×Ú[™›ÏU�YJBˆÛÛœÛÛKœš[�
- 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ˆ\œ›Ü—Ý^˜\[™
+‘�RSQÈSSPQÑH‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
+——ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+ˆ�ÛÝ[›ÝÝÛ›ØYˆÚ[XYÙ_Wˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
+ÝŠJKÝ[OH™[H™YŠB‚ˆ[™[H[™[
+ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
+BˆÛÛœÛÛKœš[�
+[™[—ˆŠ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œš[�
+ÝXØÙ\Ü×Ý^
+BˆÛÛœÛÛKœš[�
- 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
\ No newline at end of file
diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py
index ec49f7a7..5398ed8b 100644
--- a/strix/runtime/backends.py
+++ b/strix/runtime/backends.py
@@ -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()
diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py
index 969267d5..c70f2d2d 100644
--- a/strix/runtime/docker_client.py
+++ b/strix/runtime/docker_client.py
@@ -1,292 +1,114 @@
-"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
-NET_ADMIN/NET_RAW capabilities + host-gateway.
+r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ýš^ØÚÙ\”Ø[™›ÞÛY[�8 %™\Ù\�™\ÈH[XYÙIÜÈS•–TÒS•[™Y“‘UÐQRS‹Ó‘UÔ�UÈØ\Xš[]Y\È
+ÈÜÝYØ]]Ø^K‚‚•HÑÉÜÈØÚÙ\”Ø[™›ÞÛY[�—ØÜ™X]WØÛÛ�Z[™\˜Ù\È›Ý^ÜÙHHÛÚț܂™^[™[™ÈÜ™X]WÚÝØ\™ÜØ™Y›Ü™HÛÛ�Z[™\œË˜Ü™X]X\ÈØ[YˆÙHÝX˜Û\ܘ[™™Z[\[Y[�HY]Ù›ÙH™\˜˜][Hœ›ÛHHÑÈÛÝ\˜ÙKÚ]™YB™[\΂‚ŒKˆ›ÜHÑÉÜÈ[�ž\Ú[�VÈ�Z[—XÝ™\œšYNÈÝ\HÈ�Z[‹‹Yˆ‹ˆ‹Ù]‹Û�[—X\ÈÛÛ[X[™[œÝXYˆ\È]ÈÝ\ˆ[XYÙI܈ØÚÙ\‹Y[�ž\Ú[�œÚXÝX[H�[ˆ8 %Ú]Ý]]ØZYËXÛX™]™\‚ˆÝ\�È[œÚYHHÛÛ�Z[™\ˆ[™›ÛÝݘ\ØØZYØ™]šY\ÈYØZ[œÝBˆXYÜ�‚Œ‹ˆ\[™‘UÐQRS‹Ó‘UÔ�UÈÈØ\ØY
+™\]Z\™YžH›X\\ÔØ[™ˆÝ\ˆ˜]Ë\ÛØÚÙ]ÛÛÊK‚ŒËˆYÜÝ™ØÚÙ\‹š[�\›˜[8¡¤ˆÜÝYØ]]Ø^HÈ^˜WÚÜÝØÛÈBˆYÙ[�Ø[ˆ™XXÚÜÝ\Ù\�™Y\Ë‚‚”[›™YÈÜ[˜ZKXYÙ[�ÏOLŒM�˜ˆ�[\[™ÈHÑÈ™\]Z\™\œ™K[Y\™Ú[™ÈH\™[�›ÙKˆ˜XÚÈ\Ý™X[H›Üˆ[ˆ[š™XÝ[ÛˆÛÚË‚ˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�ÛÛ�^X‚š[\Ü�ÙÙÚ[™Âš[\Ü�Üš[\Ü�]ZY™œ›ÛH\[™È[\Ü�[žK]\˜[Ø\Ý‚™œ›ÛHYÙ[�ËœØ[™›Þ™\œ›ÜœÈ[\Ü�^ÜÙYÜ�[˜]˜Z[X›Q\œ›Ü‚™œ›ÛHYÙ[�ËœØ[™›Þ›X[šY™\Ý[\Ü�X[šY™\Ý™œ›ÛHYÙ[�ËœØ[™›ÞœØ[™›Þ\Ë™ØÚÙ\ˆ[\Ü�
+ˆØÚÙ\”Ø[™›ÞÛY[�ˆØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‹ˆØ�Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[�ËˆÙØÚÙ\—ÜÜ�ÚÙ^KˆÛX[šY™\ÝÜ™\]Z\™\×Ù�\ÙKˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[‹ŠB™œ›ÛHYÙ[�ËœØ[™›ÞœÙ\ÜÚ[Û‹œØ[™›ÞÜÙ\ÜÚ[Ûˆ[\Ü�Ø[™›ÞÙ\ÜÚ[Û‚™œ›ÛHYÙ[�ËœØ[™›Þ�\\È[\Ü�^ÜÙYÜ�[™Ú[�™œ›ÛHØÚÙ\ˆ[\Ü�\œ›ÜœÈ\ÈØÚÙ\—Ù\œ›ÜœÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹›[Ù[˘ÛÛ�Z[™\œÈ[\Ü�ÛÛ�Z[™\ˆÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�\\È[\Ü�ÙÐÛÛ™šYÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�\\È[\Ü�[Ý[�\ÈØÚÙ\”ÑÓ[Ý[�È\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�][È[\Ü�\œÙWÜ™\ÜÚ]ÜžWÝYÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛH™\]Y\ÝË™^Ù\[ÛœÈ[\Ü�™\]Y\Ý^Ù\[Û‚‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚—ÔÐS‘“ÖÓ‘UÓÔ’×ÑS•ˆH”Õ’VÑÐÒÑT—ÔÐS‘“ÖÓ‘UÓÔ’È‚‚‚™YˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JˆÛY[�ˆ[žKˆ[XYÙNˆÝ‹ˆ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—KŠHOˆ›Û™N‚ˆYˆÛY[�š[XYÙWÙ^\ÝÊ[XYÙJN‚ˆ™]\›‚ˆYˆ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH�[�[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[�™ØÚÙ\—ØÛY[�š[XYÙ\Ëœ[
+™\ËYÏ]YÈ܈›Û™K[ÝYÜÏQ˜[ÙJB‚‚™YˆÜØ[™›ÞÛ™]ÛÜšÊ
+HOˆÝˆ›Û™N‚ˆ˜[YHHÜË™[�š\›Û‹™Ù]
+ÔÐ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ÝÜÝ‹[žWJHOˆ›Û™N‚ˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
+BˆYˆ™]Ûܚ΂ˆÜ™X]WÚÝØ\™ÜÖÈ›™]ÛܚȗHH™]ÛܚˆܙX]WÚÝØ\™ÜËœÜ
+œÜ�È‹›Û™JB‚‚™YˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆˆˆ�\HÜ[Û˜[ÙÜ›Ý\™\ÛÝ\˜ÙHØ\Èœ›ÛHH[�š\›Û›Y[�ˆ[œÙ]Ø›[šÂˆ˜[Y\ÈX]™HØÚÙ\‰ÜÈY˜][
+[˜›Ý[™Y
+KÛÈ\È\ÈÜZ[ˆ\ˆÜ݈ˆˆ‚ˆY[WÛ[Z]HÜË™[�š\›Û‹™Ù]
+”Õ’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ÜË™[�š\›Û‹™Ù]
+”Õ’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ÜË™[�š\›Û‹™Ù]
+”Õ’VÔÐS‘“ÖÐÔTÈ‹ˆŠKœÝš\
-from __future__ import annotations
+BˆYˆÜ\΂ˆÚ]ÛÛ�^X‹œÝ\™\Üʘ[YQ\œ›Ü‹Ý™\™›ÝÑ\œ›ÜŠN‚ˆ˜[›×ØÜ\ÈH[�
+ݯ]
+Ü\ÊH
+ˆWÌÌÌ
+BˆYˆ˜[›×ØÜ\ÈHŠŠ�ŒÈHN‚ˆÜ™X]WÚÝØ\™ÜÖÈ›˜[›×ØÜ\È—HH˜[›×ØÜ\‚ˆY×Û[Z]HÜË™[�š\›Û‹™Ù]
+”Õ’VÔÐS‘“ÖÔQ×ÓSRU‹ˆŠKœÝš\
-import contextlib
-import logging
-import os
-import uuid
-from typing import Any, cast
+BˆYˆY×Û[Z]‚ˆÚ]ÛÛ�^X‹œÝ\™\Üʘ[YQ\œ›ÜŠN‚ˆÜ™X]WÚÝØ\™ÜÖÈœY×Û[Z]—HH[�
+Y×Û[Z]
+B‚‚™YˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆˆˆ�›Ý[™HÛÛ�Z[™\‰ÜÈœÛÛ‹Yš[HÙÈÛÈH�[˜]Ø^H›ØÙ\ÜÈ[ˆHØ[™›Þˆ
+K™ËˆHÛÛ]�\ÞK[ÛÜÈÜš][™ÈÈÝÝ]
+HØ[››Ýš[HÜÝ\Úˆ[™ZÙHHØÚÙ\ˆY[[ÛˆÝÛˆÚ]]‚‚ˆ[›ZÙHHÙÜ›Ý\Ø\ÈX›Ý™K\ÈY˜][È
+Š›ÛŠŠˆ8 %ØÚÙ\‰ÜÈÝÛˆY˜][ˆ\È[ˆ[˜›Ý[™YœÛÛ‹Yš[KÚXÚ\È[œØY™H›Üˆ[ˆ]]Û›Û[Ý\ÈYÙ[�]ˆ^XÝ]\È\˜š]˜\žHÛÛ[X[™ËˆX^Yš[X›Ý][ÛˆYX[œÈHÛ‹Y\ÚÈØ\\ˆX^\Ú^™H
+ˆX^Yš[XˆÙ]Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘XÈØÙ™˜ˆÈܘXÚÈÝ]ÈØÚÙ\‰ÜÈY˜][ˆˆˆ‚ˆX^ÜÚ^™HHÜË™[�š\›Û‹™Ù]
+”Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘H‹�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‹�[›[Z]YŠN‚ˆ™]\›‚ˆX^Ùš[HHÜË™[�š\›Û‹™Ù]
+”Õ’VÔÐS‘“ÖÓÑ×ÓPVÑ’SH‹ŒÈŠKœÝš\
+H܈ŒÈ‚ˆÜ™X]WÚÝØ\™ÜÖÈ›ÙרÛÛ™šYÈ—HHÙÐÛÛ™šYʈ\OSÙÐÛÛ™šYË�\\Ë’”ÓÓ‹ˆÛÛ™šYÏ^È›X^\Ú^™HŽˆX^ÜÚ^™K›X^Yš[HŽˆX^Ùš[_Kˆ
+B‚‚™YˆØ\WÜ�[—ÛX™[ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆ�[—ÚYHÜË™Ù][�ДՒVÔ•S—ÒQŠBˆYˆ›Ý�[—ÚY‚ˆ™]\›‚ˆX™[ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
+›X™[È‹ßJBˆYˆ›Ý\Ú[œÝ[˜ÙJX™[ËXÝ
+N‚ˆ™]\›‚ˆX™[ÖȜݚ^\�[‹ZY—HH�[—ÚYˆ�[—Ý\HHÜË™Ù][�ДՒVÔ•S—ÕTHŠBˆYˆ�[—Ý\N‚ˆX™[ÖȜݚ^\�[‹]\H—HH�[—Ý\B‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆØ[™›ÞÛ™]ÛܚΈ݈Hˆ‚‚ˆ\Þ[˜ÈYˆÜ™\ÛÛ™WÙ^ÜÙYÜÜ�
+Ù[‹Ü�ˆ[�
+HOˆ^ÜÙYÜ�[™Ú[�‚ˆžN‚ˆÙ[‹—ØÛÛ�Z[™\‹œ™[ØY
-logger = logging.getLogger(__name__)
+Bˆ^Ù\ØÚÙ\—Ù\œ›ÜœË�TQ\œ›Üˆ\ÈN‚ˆ˜Z\ÙH^ÜÙYÜ�[˜]˜Z[X›Q\œ›ÜŠˆÜ�\Ü�ˆ^ÜÙYÜÜ�Ï\Ù[‹œÝ]K™^ÜÙYÜÜ�ˈ™X\ÛÛ�H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ�^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ�Z[™\—Ü™[ØYÙ˜Z[Y‹ˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆØ]\ÙOYKˆ
+Hœ›ÛHB‚ˆ]œÈHÙ]]ŠÙ[‹—ØÛÛ�Z[™\‹˜]œÈ‹ßJH܈ßBˆ™]ÛÜšÜÈH]œË™Ù]
+“™]ÛÜšÔÙ][™ÜÈ‹ßJK™Ù]
+“™]ÛÜšÜÈ‹ßJBˆ[™Ú[�H™]ÛÜšÜË™Ù]
+Ù[‹œØ[™›ÞÛ™]ÛÜšÊH܈ßBˆ\H[™Ú[�™Ù]
+’TY™\ÜÈŠH܈[™Ú[�™Ù]
+‘ÛØ˜[T��Y™\ÜÈŠBˆYˆ›Ý\Ú[œÝ[˜ÙJ\ÝŠH܈›Ý\‚ˆ˜Z\ÙH^ÜÙYÜ�[˜]˜Z[X›Q\œ›ÜŠˆÜ�\Ü�ˆ^ÜÙYÜÜ�Ï\Ù[‹œÝ]K™^ÜÙYÜÜ�ˈ™X\ÛÛ�H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ�^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ�Z[™\—Û›ÝÛÛ—Û™]Ûܚȋˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆ
+BˆÜÝHˆ–ÞÚ\WHˆYˆŽˆˆ[ˆ\[ÙH\ˆ™]\›ˆ^ÜÙYÜ�[™Ú[�
+ÜÝZÜÝÜ�\Ü�ÏQ˜[ÙJB‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÛY[�
+ØÚÙ\”Ø[™›ÞÛY[�
+N‚ˆÈÜÝ\™XÝÜšY\ÈÈš[™[[Ý[�[�ÈHÛÛ�Z[™\‹Ù]žHHØÚÙ\‚ˆÈ˜XÚÙ[™™Y›Ü™HÜ™X]J
+XˆXXÚ][H\ÈÜÛÝ\˜ÙK\™Ù]™XYÛÛ›_X‚ˆÝš^Øš[™Û[Ý[�Έ\ÝÙXÝÜÝ‹[žWWH›Û™HH›Û™Bˆ[XYÙWÜ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—HH˜]]È‚‚ˆ\Þ[˜ÈYˆØÜ™X]WØÛÛ�Z[™\ŠˆÙ[‹ˆ[XYÙNˆÝ‹ˆ
+‹ˆX[šY™\݈X[šY™\ݛۙHH›Û™Kˆ^ÜÙYÜÜ�Έ\VÚ[�‹‹—HH
+KˆÙ\ÜÚ[Û—ÚYˆ]ZY•URQ›Û™HH›Û™Kˆ
+HOˆÛÛ�Z[™\Ž‚ˆÈKKKKH‘QÒSˆ‘T��USHÓÔHÙˆØÚÙ\”Ø[™›ÞÛY[�—ØÜ™X]WØÛÛ�Z[™\ˆKKKKBˆÈÑÈ™YŽˆÜ˜ËØYÙ[�ËÜØ[™›ÞÜØ[™›Þ\ËÙØÚÙ\‹œNŒMÍLM
ÍÈ
+ŒŒM�ŠK‚ˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JÙ[‹[XYÙKÙ[‹š[XYÙWÜ[ÜÛXÞJB‚ˆ\ÜÙ\�Ù[‹š[XYÙWÙ^\ÝÊ[XYÙJBˆ[�š\›Û›Y[�ˆXÝÜ݋ݗH›Û™HH›Û™BˆYˆX[šY™\Ý‚ˆ[�š\›Û›Y[�H]ØZ]X[šY™\Ý™[�š\›Û›Y[�œ™\ÛÛ™J
+BˆÈÝš^[Hœ›ÛHHÑÈ›ÙNˆ›Ü[�ž\Ú[�Ý™\œšYH[™ˆÈÝ\HZ[YˆÙ]‹Û�[\ÈÛÛ[X[™ÛÈH[XYÙI܈ÈS•–TÒS•
+ØÚÙ\‹Y[�ž\Ú[�œÚ
+H�[œÈÙ]\[ˆ^Xˆȉ˜™XÛÛY\È^XÈZ[YˆÙ]‹Û�[›ÜˆHÙY\X[]™K‚ˆÈÚ]Ý]\ËØZYËXÛH
+ÈH[‹XÛÛ�Z[™\ˆÐH�\Ý™]™\ˆÙ]ˆÈ[š]X[^™Y‚ˆÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWHHˆš[XYÙHŽˆ[XYÙKˆ™]XÚŽˆ�YKˆ˜ÛÛ[X[™ŽˆÈ�Z[‹‹Yˆ‹‹Ù]‹Û�[—Kˆ™[�š\›Û›Y[�Žˆ[�š\›Û›Y[�ˆBˆYˆX[šY™\Ý\țݛۙN‚ˆØÚÙ\—Û[Ý[�ÈHØ�Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[�ʈX[šY™\݈Ù\ÜÚ[Û—ÚY\Ù\ÜÚ[Û—ÚYˆ
+BˆYˆØÚÙ\—Û[Ý[�΂ˆÜ™X]WÚÝØ\™ÜÖÈ›[Ý[�È—HHØÚÙ\—Û[Ý[�ˆYˆÛX[šY™\ÝÜ™\]Z\™\×Ù�\ÙJX[šY™\Ý
+N‚ˆÜ™X]WÚÝØ\™ÜË�\]Jˆ]šXÙ\ÏVÈ‹Ù]‹Ù�\ÙH—KˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ�[˜ÛÛ™š[™Y—Kˆ
+Bˆ[YˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[ŠX[šY™\Ý
+N‚ˆÜ™X]WÚÝØ\™ÜË�\]JˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ�[˜ÛÛ™š[™Y—Kˆ
+BˆYˆ^ÜÙYÜÜ�΂ˆÜ™X]WÚÝØ\™ÜÖÈœÜ�È—HHÂˆÙØÚÙ\—ÜÜ�ÚÙ^JÜ�
+Nˆ
+ŒL�ËŒŒŒH‹›Û™JH›ÜˆÜ�[ˆ^ÜÙYÜÜ�ˆBˆÈKKKKHS‘‘T��USHÓÔHKKKKB‚ˆÈÝš^[š™XÝ[ÛœÈ8 %\[™Û‰ÝÝ™\�Üš]KÛÈ•TÑKÔÖT×ÐQRSˆÝ\�š]™\Ë‚ˆØ\ØYHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
+˜Ø\ØY‹×JBˆYˆ›Ý\Ú[œÝ[˜ÙJØ\ØY\Ý
+N‚ˆØ\ØYH\Ý
+Ø\ØY
+BˆÜ™X]WÚÝØ\™ÜÖȘØ\ØY—HHØ\ØYˆ›ÜˆØ\[ˆ
+“‘UÐQRSˆ‹“‘UÔ�UÈŠN‚ˆYˆØ\›Ý[ˆØ\ØY‚ˆØ\ØY˜\[™
+Ø\
+B‚ˆ^˜WÚÜÝÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
+™^˜WÚÜÝÈ‹ßJBˆ^˜WÚÜÝÖÈšÜÝ™ØÚÙ\‹š[�\›˜[—HHšÜÝYØ]]Ø^H‚‚ˆØ\WÜØ[™›ÞÛ™]ÛÜšÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ�[—ÛX™[ÊÜ™X]WÚÝØ\™ÜÊB‚ˆÈÝš^[š™XÝ[ÛŽˆØØ[ÛÝ\˜ÙH™Y\ËÛÜ�YÚ[ÝÙ\ÝYš\œÝÛÈBˆÈ™\ÝYÜXÈ[™ÈÛˆÜÙˆH™YH]ÛÝ™\œË‚ˆš[™Û[Ý[�ÈHÙ[‹œÝš^Øš[™Û[Ý[�È܈
-_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
+BˆYˆš[™Û[Ý[�΂ˆ[Ý[�ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
+›[Ý[�È‹×JBˆ›ÜˆÜXÈ[ˆÛÜ�Y
+š[™Û[Ý[�ËÙ^O[[X™HΈ݊ÖÈ�\™Ù]—JK˜ÛÝ[�
+‹ÈŠJN‚ˆ[Ý[�˘\[™
+ˆØÚÙ\”ÑÓ[Ý[�
+ˆ\™Ù]\ÜXÖÈ�\™Ù]—KˆÛÝ\˜ÙO\ÜXÖÈœÛÝ\˜ÙH—Kˆ\OH˜š[™‹ˆ™XYÛÛ›O\ÜXË™Ù]
+œ™XYÛÛ›H‹˜[ÙJKˆ
+Bˆ
+B‚ˆÙÙÙ\‹™X�Yʈ�Ü™X][™ÈØ[™›ÞÛÛ�Z[™\Žˆ[XYÙOI\ÈØ\ÏI\È^ÜÙYÜÜ�ÏI\È‹ˆ[XYÙKˆØ\ØYˆ\Ý
+^ÜÙYÜÜ�ÊKˆ
+BˆÛÛ�Z[™\ˆHÙ[‹™ØÚÙ\—ØÛY[�˜ÛÛ�Z[™\œË˜Ü™X]J
+Š˜Ü™X]WÚÝØ\™ÜÊBˆÙÙÙ\‹š[™›Êˆ”Ø[™›ÞÛÛ�Z[™\ˆÜ™X]YˆYI\È[XYÙOI\È‹ˆÛÛ�Z[™\‹œÚÜ�ÚYYˆ\Ø]ŠÛÛ�Z[™\‹œÚÜ�ÚYŠH[ÙH�È‹ˆ[XYÙKˆ
+Bˆ™]\›ˆÛÛ�Z[™\‚‚ˆ\Þ[˜ÈYˆÜ™X]JÙ[‹
+ŠšÝØ\™ÜΈ[žJHOˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÙ\ÜÚ[ÛˆH]ØZ]Ý\\Š
+K˜Ü™X]J
+ŠšÝØ\™ÜÊBˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
+Bˆ[›™\ˆHÙ\ÜÚ[Û‹—Ú[›™\‚ˆYˆ™]ÛÜšÈ[™\Ú[œÝ[˜ÙJ[›™\‹ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆ[›™\‹—רÛ\Ü××ÈHÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‚ˆØ\Ý
+”Ýš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Ûˆ‹[›™\ŠKœØ[™›ÞÛ™]ÛÜšÈH™]Ûܚˆ™]\›ˆÙ\ÜÚ[Û‚‚ˆ\Þ[˜ÈYˆ[]JÙ[‹Ù\ÜÚ[ÛŽˆØ[™›ÞÙ\ÜÚ[ÛŠHOˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÛÛ�Z[™\—ÚYHÙ]]ŠÙ]]ŠÙ\ÜÚ[Û‹—Ú[›™\‹œÝ]H‹›Û™JK˜ÛÛ�Z[™\—ÚY‹›Û™JBˆYˆÛÛ�Z[™\—ÚY‚ˆÈ™\ÝYY™›Ü�Ú[ˆ›Ý›Ý[™ÐTQ\œ›ÜˆÛÝ™\ˆHÛÛ™H܈[š\BˆÈÛÛ�Z[™\‹ˆ™\]Y\Ý^Ù\[ÛˆÛÝ™\œÈHÜ›‹YÝÛˆY[[ÛˆÛØÚÙ]8 %ˆÈÛÛ�Z[™\œË™Ù]
+HOˆ[œÜXÝØÛÛ�Z[™\ˆ˜Z\Ù\È™\]Y\ÝɈÈÛÛ›™XÝ[Û‘\œ›Ü‹ÚXÚ\ÈHÚX›[™ÈÙˆØÚÙ\‹™\œ›ÜœË�TQ\œ›Ü‚ˆÈ[™\ˆ™\]Y\ÝË”™\]Y\Ý^Ù\[Ûˆ
+›ÝHÝX˜Û\ÜÊKÛÈ]\ØØ\\ˆÈ[ˆTQ\œ›Ü‹[Û›HÝ\™\ÜÈ[™Ý\™˜XÙ\ÈH�[˜XÙX˜XÚÈ]™[‚ˆÈÝYÚ\ÈX\™ÝÛˆ\ÈYX[�È™H™\ÝYY™›Ü�‚ˆÚ]ÛÛ�^X‹œÝ\™\ÜʈØÚÙ\—Ù\œ›ÜœË“›Ý›Ý[™ØÚÙ\—Ù\œ›ÜœË�TQ\œ›Ü‹™\]Y\Ý^Ù\[Û‚ˆ
+N‚ˆÙ[‹™ØÚÙ\—ØÛY[�˜ÛÛ�Z[™\œË™Ù]
+ÛÛ�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
\ No newline at end of file
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index e83ab119..6a3a3a84 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -1,218 +1,66 @@
-"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
+r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ•\Ýț܈ݚ^˜ÛÛ™šYË›ØY\Žˆ”Óӈݙ\œšY\Ë[X\È™\ÛÛ][Û‹\œÚ\Ý[˜ÙKˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�œÛÛ‚™œ›ÛH\[™È[\Ü�TWÐÒPÒÒS‘‚š[\Ü�]\Ý™œ›ÛHY[�XÈ[\Ü�[X\ÐÚÚXÙ\ËšY[˜[Y][Û‘\œ›Ü‚™œ›ÛHY[�XË™šY[È[\Ü�šY[[™›Â‚™œ›ÛHÝš^˜ÛÛ™šYÈ[\Ü�ØY\‚™œ›ÛHÝš^˜ÛÛ™šYËœÙ][™ÜÈ[\Ü�ÛÛ�^Ù][™Ü‚‚šYˆTWÐÒPÒÒS‘΂ˆœ›ÛH]Xˆ[\Ü�]‚‚—ÓWÑS•—ÒÑVTÈHˆ”Õ’VÓH‹ˆ“WÐTWÒÑVH‹ˆ“ÔS�RWÐTWÒÑVH‹ˆ“WÐTWÐ�TÑH‹ˆ“ÔS�RWÐTWÐ�TÑH‹ˆ“ÔS�RWÐ�TÑWÕT“‹ˆ“USWÐ�TÑWÕT“‹ˆ“ÓSPWÐTWÐ�TÑH‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹ˆ”Õ’VÑ“Ô�ÑWÔ‘TURT‘QÕÓÓÐÒÒPÑH‹ˆ“WÕSQSÕU‹ˆ”T”VUWÐTWÒÑVH‹ˆÈ�[�[YTÙ][™Üˆ”Õ’VÒSPQÑH‹ˆ”Õ’VÒSPQÑWÔSÔÓPÖH‹ˆ”Õ’VÔ•S•SQWÐ�PÒÑS‘‹ˆ”Õ’VÐÐRQ×ГÓÕÕÐRUÔÈ‹ˆÈ[[Y]žTÙ][™Üˆ”Õ’VÕSSQU–H‹—B‚‚�]\Ý™š^\™J]]Ý\ÙOU�YJB™YˆÜ™\Ù]ÛØY\—ÜÝ]J[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
+HOˆ›Û™N‚ˆˆˆ”™\Ù][Ù[HÛØ˜[È[™ÛX\ˆÛ›ÝÛˆ[�ˆ˜\œÈ›Üˆ]\›Z[š\ÝXÈ�[œËˆˆˆ‚ˆ›ÜˆÙ^H[ˆÓWÑS•—ÒÑVT΂ˆ[ÛšÙ^\]Ú™[[�ŠÙ^K˜Z\Ú[™ÏQ˜[ÙJBˆ[ÛšÙ^\]ÚœÙ]]ŠØY\‹—ØØXÚY‹›Û™JBˆ[ÛšÙ^\]ÚœÙ]]ŠØY\‹—ÛÝ™\œšYH‹›Û™JB‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈÜ™XYÚœÛÛ—ÛÝ™\œšY\ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛZ\ÜÚ[™×Ùš[J\Ü]ˆ]
+HOˆ›Û™N‚ˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê\Ü]È››ÜKšœÛÛˆŠHOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\רÛÜœ�\ÚœÛÛŠ\Ü]ˆ]
+HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+žÛ›Ý˜[YœÛÛˆ‹[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Û›Û—ÙXÝÙ[�Š\Ü]ˆ]
+HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ››Ý‹˜H‹™XÝ—_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛX\×Ý×Û™\ÝYÜÙ][™ÜÊ\Ü]ˆ]
+HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+ˆœÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ›^K[[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH�]‹N‹ˆ
+Bˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHˆ›HŽˆÈ›[Ù[Žˆ›^K[[Ù[ŸKˆš[�Yܘ][ۜȎˆÈœ\œ^]WØ\WÚÙ^HŽˆœÈŸKˆB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÜÚÚ\×ÚÙ^\ר[™XYWÚ[—Ù[�š\›ÛŠˆ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹™œ›ÛKY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆÈ[�ˆÚ[œÈOˆH”ÓÓˆ˜[YH\È›ÝÝ\™˜XÙY\È[ˆ[š]ÝØ\™Ë‚ˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[�—ÝÚ[œ×ØXÜ›ÜÜ×ÙšY[Ø[X\Ù\ʈ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆÈ\WÚÙ^H™\ÛÛ™\Èœ›ÛH[X\ÐÚÚXÙ\Ê“WÐTWÒÑVH‹“ÔS�RWÐTWÒÑVHŠKˆH[�‚ˆÈÙ]ÈÛ™H[X\ÈÚ[HH\œÚ\ÝYš[HÛÈ[›Ý\‹ˆ[�ˆ]\ÝÝ[Ú[‹ÛˆÈHÝ[Hš[H˜[YH]\ݛݙHÝ\™˜XÙY\È[ˆ[š]ÝØ\™È
+ÚXÚÝ]˜[šÜÈ[�ŠK‚ˆ[ÛšÙ^\]ÚœÙ][�Š“ÔS�RWÐTWÒÑVH‹œÚËY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ“WÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[�—ÝÚ[œ×ØØ\ÙWÚ[œÙ[œÚ]]™[Jˆ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆÈÙ][™ÜÈ\ÙHØ\ÙWÜÙ[œÚ]]™OQ˜[ÙKÛÈHÝÙ\˜Ø\ÙH[�ˆ˜\ˆ[ÛÈÛÝ[�È\ÈÙ]‚ˆ[ÛšÙ^\]ÚœÙ][�ŠœÝš^ÛH‹™œ›ÛKY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ý\Ù\×ÚœÛÛ—ÝÚ[—Û›×Ø[X\×Ú[—Ù[�š\›ÛŠ\Ü]ˆ]
+HOˆ›Û™N‚ˆÈ›È[X\ÈÙˆ\WÚÙ^H\ÈÙ][ˆH[�š\›Û›Y[�OˆHš[H˜[YH\È\ÙY]™[‚ˆÈÚ[ˆ]\ÈÝÜ™Y[™\ˆH›Û‹Yš\œÝ[X\Ë‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ“ÔS�RWÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
+HOHÈ›HŽˆÈ˜\WÚÙ^HŽˆœÚËYš[HŸ_B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\×Ü™Z™XÝ×ÜÝX—Û›ÝXÙWݘ[Y\Ê
+HOˆ›Û™N‚ˆÚ]]\Ýœ˜Z\Ù\ʘ[Y][Û‘\œ›ÜŠN‚ˆÛÛ�^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏM�
+B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\רXØÙ\×Ù›ÛÜŠ
+HOˆ›Û™N‚ˆ\ÜÙ\�ÛÛ�^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏLL�
+K�ÛÛÛÝ]]ÛX^Øž]\ÈOHL�‚‚™Yˆ\ÝÜ�[�[YWØØZYר›ÛÝÝØZ]Ùœ›ÛWÙ[�Š[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
+HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÐÐRQ×ГÓÕÕÐRUÔÈ‹ŽLŠB‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
+Kœ�[�[YK˜ØZYר›ÛÝÝØZ]ÜÈOHL‚‚™Yˆ\ÝÜ�[�[YWÚ[XYÙWÜ[ÜÛXÞWÙœ›ÛWÙ[�Š[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
+HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÒSPQÑWÔSÔÓPÖH‹›™]™\ˆŠB‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
+Kœ�[�[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆ‚‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHÂˆÈØ[X\Ù\×ٛ܂ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÚ[\WØ[X\Ê
+HOˆ›Û™N‚ˆš[™›ÈHšY[[™›Ê[X\ÏH”ÒSTWÐSPTÈŠBˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ”ÒSTWÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗØ[X\רÚÚXÙ\Ê
+HOˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
+È\NˆYۛܙVØ\ÜÚYÛ›Y[�BˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\Ê‘’T”Õ‹”ÑPÓÓ‘ŠKˆ
+Bˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ‘’T”Õ‹”ÑPÓÓ‘—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÝš[™×ݘ[Y][Û—Ø[X\Ê
+HOˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
+Y˜][S›Û™K˜[Y][Û—Ø[X\ÏH”Õ—ÐSPTÈŠHÈ\NˆYۛܙVØ\ÜÚYÛ›Y[�Bˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHȔ՗ÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×Ù›Ü—Û›×Ø[X\Ê
+HOˆ›Û™N‚ˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊šY[[™›Ê
+JHOH×B‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\WØÛÛ™šY×ÛÝ™\œšYH
+ÈØYÜÙ][™ÜÈ›Ý[™]š\ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ\WÛÝ™\œšYWØ[™ÛØYÜÙ][™Ü×Ü›Ý[™Ýš\
+\Ü]ˆ]
+HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
+ˆœÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆœ›Ý[™]š\[[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH�]‹N‹ˆ
+B‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ]
+BˆÙ][™ÜÈHØY\‹›ØYÜÙ][™ÜÊ
+B‚ˆ\ÜÙ\�Ù][™ÜË›K›[Ù[OHœ›Ý[™]š\[[Ù[‚ˆ\ÜÙ\�Ù][™ÜËš[�Yܘ][ۜ˜\œ^]WØ\WÚÙ^HOHœÈ‚ˆÈÙXÛÛ™Ø[\ÈY[[Ú^™YOˆØ[YHØš™XÝ‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
+H\ÈÙ][™Ü‚‚™Yˆ\ÝØ\WØÛÛ™šY×ÛÝ™\œšYWÚ[�˜[Y]\רØXÚJ\Ü]ˆ]
+HOˆ›Û™N‚ˆš\œÝH\Ü]È™š\œÝšœÛÛˆ‚ˆš\œÝ�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™š\œÝ[[Ù[Ÿ_JK[˜ÛÙ[™ÏH�]‹NŠBˆÙXÛÛ™H\Ü]ÈœÙXÛÛ™šœÛÛˆ‚ˆÙXÛÛ™�Üš]WÝ^
+œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆœÙXÛÛ™[[Ù[Ÿ_JK[˜ÛÙ[™ÏH�]‹NŠB‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJš\œÝ
+Bˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
+K›K›[Ù[OH™š\œÝ[[Ù[‚‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJÙXÛÛ™
+Bˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
+K›K›[Ù[OHœÙXÛÛ™[[Ù[‚‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\œÚ\ÝØÝ\œ™[�ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[�ÝÜš]\×Ù[�—Ø›ØÚÊ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
+HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹œ\œÚ\ÝY[[Ù[ŠBˆ\™Ù]H\Ü]ÈœÝXˆˆÈ˜ÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
+B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[�
-from __future__ import annotations
+B‚ˆ\ÜÙ\�\™Ù]™^\ÝÊ
+Bˆ\ÜÙ\�œÛÛ‹›ØYÊ\™Ù]œ™XYÝ^
+[˜ÛÙ[™ÏH�]‹NŠJHOHˆ™[�ˆŽˆÈ”Õ’VÓHŽˆœ\œÚ\ÝY[[Ù[ŸBˆB‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[�ÜÙ]×Ì
ŒÛ[ÙJ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
+HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹œ\œÚ\ÝY[[Ù[ŠBˆ\™Ù]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
+B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[�
-import json
-from typing import TYPE_CHECKING
+B‚ˆ\ÜÙ\�\™Ù]œÝ]
-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ÍŒ
\ No newline at end of file
diff --git a/tests/test_image_pull_policy.py b/tests/test_image_pull_policy.py
new file mode 100644
index 00000000..a9184833
--- /dev/null
+++ b/tests/test_image_pull_policy.py
@@ -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()
From 02ff1d0a28a76bf46af5ce3aa19a4b7017da8a6c Mon Sep 17 00:00:00 2001
From: maxi-maxima <240684166@qq.com>
Date: Wed, 12 Aug 2026 10:20:16 +0800
Subject: [PATCH 2/3] Normalize source line endings
---
strix/config/settings.py | 221 +++++-------------
strix/interface/environment.py | 315 +++++++++++++++++---------
strix/runtime/backends.py | 132 +++--------
strix/runtime/docker_client.py | 397 ++++++++++++++++++++++++---------
4 files changed, 595 insertions(+), 470 deletions(-)
diff --git a/strix/config/settings.py b/strix/config/settings.py
index c6f6572f..78bcec9d 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -1,161 +1,60 @@
-r‡^Ñf¥–ئ{O,yÊ'vî¶›"""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",
- )
- 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")
-
-
-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)
+r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ýš^\XØ][ÛˆÙ][™ÜÈ8 %Y[�XË\Ù][™ÜÈÝÙ\™Yˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚™œ›ÛH\[™È[\Ü�]\˜[‚™œ›ÛHY[�XÈ[\Ü�[X\ÐÚÚXÙ\ËšY[™œ›ÛHY[�X×ÜÙ][™ÜÈ[\Ü�˜\ÙTÙ][™ÜËÙ][™ÜÐÛÛ™šYÑXÝ‚‚”™X\ÛÛš[™ÑY™›Ü�H]\˜[È››Û™H‹›Z[š[X[‹›ÝÈ‹›YY][H‹šYÚ‹žYÚ‹›X^—B‚‘Q�USÓPVÕT“”ÈH
L‚—Ð�TÑWÐÓÓ‘’QÈHÙ][™ÜÐÛÛ™šYÑXÝ
+ˆØ\ÙWÜÙ[œÚ]]™OQ˜[ÙKˆÜ[]WØžWÛ˜[YOU�YKˆ^˜OHšYۛܙH‹ŠB‚‚˜Û\ÜÈTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[Ù[ˆÝˆ›Û™HHšY[
+Y˜][S›Û™K[X\ÏH”Õ’VÓHŠBˆ\WÚÙ^NˆÝˆ›Û™HHšY[
+ˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\Ê“WÐTWÒÑVH‹“ÔS�RWÐTWÒÑVHŠKˆ™\�Q˜[ÙKˆ
+Bˆ\Wؘ\ÙNˆÝˆ›Û™HHšY[
+ˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\ʈ“WÐTWÐ�TÑH‹ˆ“ÔS�RWÐTWÐ�TÑH‹ˆ“ÔS�RWÐ�TÑWÕT“‹ˆ“USWÐ�TÑWÕT“‹ˆ“ÓSPWÐTWÐ�TÑH‹ˆ
+Kˆ
+Bˆ^˜WÚXY\œÎˆXÝÜ݋ݗH›Û™HHšY[
+ˆY˜][S›Û™Kˆ[X\ÏH“WÑV�WÒPQT”È‹ˆ™\�Q˜[ÙKˆ
+Bˆ™X\ÛÛš[™×ÙY™›Ü�ˆ™X\ÛÛš[™ÑY™›Ü�HšY[
+Y˜][HšYÚ‹[X\ÏH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ŠBˆ›Ü˜ÙWÜ™\]Z\™YÝÛÛØÚÚXÙNˆ›ÛÛHšY[
+ˆY˜][Q˜[ÙKˆ[X\ÏH”Õ’VÑ“Ô�ÑWÔ‘TURT‘QÕÓÓÐÒÒPÑH‹ˆ
+Bˆ›Û\ØØXÚNˆ›ÛÛHšY[
+ˆY˜][U�YKˆ[X\ÏH”Õ’VÔ“ÓTÐÐPÒH‹ˆ
+Bˆ\ØX›WÜÝ™X[Z[™Îˆ›ÛÛHšY[
+ˆY˜][Q˜[ÙKˆ[X\ÏH“WÑTÐP“WÔÕ‘PSRS‘È‹ˆ
+Bˆ[Y[Ý]ˆ[�HšY[
+Y˜][LÌ[X\ÏH“WÕSQSÕUŠBˆÝ™X[WÚYWÝ[Y[Ý]ˆ[�HšY[
+Y˜][LÌÙOL[X\ÏH“WÔÕ‘PSWÒQWÕSQSÕUŠBˆX^ÝÛÛØØ[×Ü\—Ý\›Žˆ[�HšY[
+ˆY˜][LÌ‹ˆÙOLˆ[X\ÏH“WÓPVÕÓÓÐÐS×ÔT—ÕT“ˆ‹ˆ
+B‚‚˜Û\ÜÈY\TÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[Ù[ˆÝˆ›Û™HHšY[
+Y˜][S›Û™K[X\ÏH”Õ’VÑQTWÓSÑSŠBˆ™X\ÛÛš[™×ÙY™›Ü�ˆ™X\ÛÛš[™ÑY™›Ü�›Û™HHšY[
+ˆY˜][S›Û™Kˆ[X\ÏH”Õ’VÑQTWÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹ˆ
+Bˆ\WÚÙ^NˆÝˆ›Û™HHšY[
+Y˜][S›Û™K[X\ÏH‘QTWÓWÐTWÒÑVH‹™\�Q˜[ÙJBˆ\Wؘ\ÙNˆÝˆ›Û™HHšY[
+Y˜][S›Û™K[X\ÏH‘QTWÓWÐTWÐ�TÑHŠBˆ^˜WÚXY\œÎˆXÝÜ݋ݗH›Û™HHšY[
+ˆY˜][S›Û™Kˆ[X\ÏH‘QTWÓWÑV�WÒPQT”È‹ˆ™\�Q˜[ÙKˆ
+B‚‚˜Û\ÜÈÛÛ�^Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆˆˆ�ÛÛ�^]Ú[™ÝÈX[˜YÙ[Y[�ˆ\‹]ÛÛ[Ý]]Ø\È[™\ÝÜžHÛÛ\XÝ[Û‹ˆˆˆ‚‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ]]רÛÛ\X݈›ÛÛHšY[
+Y˜][U�YK[X\ÏH”Õ’VÐÓÓ•VÐUU×ÐÓÓTPÕŠBˆÛÛ\XÝØ�Y™™\—ÝÚÙ[œÎˆ[�HšY[
+Y˜][LŒÌÝL[X\ÏH”Õ’VÐÓÓ•VЕQ‘‘T—ÕÒÑS”ÈŠBˆÙY\ÝÚÙ[œÎˆ[�HšY[
+Y˜][NÌÝL[X\ÏH”Õ’VÐÓÓ•VÒÑQTÕÒÑS”ÈŠBˆ˜[˜XÚרÛÛ�^ÝÚÙ[œÎˆ[�HšY[
+ˆY˜][LŒÌÝL[X\ÏH”Õ’VÐÓÓ•VÑ�S�PÒ×ÕÒÑS”È‚ˆ
+BˆÝ[[X\žWÛX^ÝÚÙ[œÎˆ[�HšY[
+Y˜][MÌM‹ÝL[X\ÏH”Õ’VÐÓÓ•VÔÕSSPT–WÕÒÑS”ÈŠBˆÛÛÛÝ]]ÛX^ÝÚÙ[œÎˆ[�HšY[
+Y˜][NÌÝL[X\ÏH”Õ’VÕÓÓÓÕUUÓPVÕÒÑS”ÈŠBˆÛÛÛÝ]]ÛX^Û[™\Έ[�HšY[
+Y˜][L—ÌÝL[X\ÏH”Õ’VÕÓÓÓÕUUÓPVÓS‘TÈŠBˆÈ›Û܈X›Ý™HH�[˜Ø][Û‹[›ÝXÙHÚ^™HÛÈH™]šY]È[Ø^\Èš]Ë‚ˆÛÛÛÝ]]ÛX^Øž]\Έ[�HšY[
+ˆY˜][ML
+ˆL�ÙOLL�[X\ÏH”Õ’VÕÓÓÓÕUUÓPVЖUTÈ‚ˆ
+B‚‚˜Û\ÜÈ�[�[YTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[XYÙNˆÝˆHšY[
+ˆY˜][H™ÚÜ‹š[ËÝ\Ù\Ýš^ÜÝš^\Ø[™›ÞŒKŒËŒ‹ˆ[X\ÏH”Õ’VÒSPQÑH‹ˆ
+Bˆ[XYÙWÜ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—HHšY[
+ˆY˜][H˜]]È‹ˆ[X\ÏH”Õ’VÒSPQÑWÔSÔÓPÖH‹ˆ
+Bˆ˜XÚÙ[™ˆÝˆHšY[
+Y˜][H™ØÚÙ\ˆ‹[X\ÏH”Õ’VÔ•S•SQWÐ�PÒÑS‘ŠBˆØZYר›ÛÝÝØZ]ÜΈ[�HšY[
+Y˜][LÌÝL[X\ÏH”Õ’VÐÐRQ×ГÓÕÕÐRUÔÈŠBˆÈX^ØÜ™Y[œÚÝÚ[XYÙHÛÛÝ]]ÈÙ\]™H\ˆYÙ[�ÛÛ�^
+H›Û™JK‚ˆX^ØÛÛ�^Ú[XYÙ\Έ[�HšY[
+Y˜][LËÙOL[X\ÏH”Õ’VÓPVÐÓÓ•VÒSPQÑTÈŠB‚‚˜Û\ÜÈ[[Y]žTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[˜X›Yˆ›ÛÛHšY[
+Y˜][U�YK[X\ÏH”Õ’VÕSSQU–HŠB‚‚˜Û\ÜÈ[�Yܘ][Û”Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ\œ^]WØ\WÚÙ^NˆÝˆ›Û™HHšY[
+ˆY˜][S›Û™Kˆ[X\ÏH”T”VUWÐTWÒÑVH‹ˆ™\�Q˜[ÙKˆ
+BˆÜÝX[—Ø\WÚÙ^NˆÝˆ›Û™HHšY[
+ˆY˜][S›Û™Kˆ[X\ÏH”ÔÕPS—ÐTWÒÑVH‹ˆ™\�Q˜[ÙKˆ
+B‚‚˜Û\ÜÈšY]Ù\”Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆÈ˜\ÙHT“ÙˆHÝš^™[^HHØØ[šY]Ù\ˆ›ÞY\Èț܈[XZ[ˆÈ™\šYšXØ][Ûˆ[™[˜Üž\Y™\Ü�[]™\žKˆHœ›ÝÜÙ\ˆ™]™\ˆ[ÜȈÈH™[^H\™XÝNÈHØØ[Ù\�™\ˆ\ÈHÛ›HØ[\‹‚ˆ\Ý\›ˆÝˆHšY[
+Y˜][HšÎ‹ËØ\œÝš^˜ZH‹[X\ÏH”Õ’VÐTÕT“ŠB‚‚˜Û\ÜÈÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆNˆTÙ][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOSTÙ][™ÜÊBˆY\NˆY\TÙ][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOQY\TÙ][™ÜÊBˆ�[�[YNˆ�[�[YTÙ][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOT�[�[YTÙ][™ÜÊBˆÛÛ�^ˆÛÛ�^Ù][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOPÛÛ�^Ù][™ÜÊBˆ[[Y]žNˆ[[Y]žTÙ][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOU[[Y]žTÙ][™ÜÊBˆ[�Yܘ][ۜΈ[�Yܘ][Û”Ù][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOR[�Yܘ][Û”Ù][™ÜÊBˆšY]Ù\ŽˆšY]Ù\”Ù][™ÜÈHšY[
+Y˜][Ù˜XÝÜžOUšY]Ù\”Ù][™ÜÊB
\ No newline at end of file
diff --git a/strix/interface/environment.py b/strix/interface/environment.py
index 4e6379d5..8f6b3a03 100644
--- a/strix/interface/environment.py
+++ b/strix/interface/environment.py
@@ -1,115 +1,224 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ý\�\[�š\›Û›Y[�˜[Y][Ûˆ[™ØÚÙ\ˆ[XYÙHX[˜YÙ[Y[�ˆˆˆ‚‚š[\Ü�ÙÙÚ[™Âš[\Ü�Ú][š[\Ü�Þ\‚™œ›ÛHšXÚ˜ÛÛœÛÛH[\Ü�ÛÛœÛÛB™œ›ÛHšXÚœ[™[[\Ü�[™[™œ›ÛHšXÚ�^[\Ü�^‚™œ›ÛHÝš^˜ÛÛ™šYÈ[\Ü�ÛÙ^ØYÜÙ][™Ü™œ›ÛHÝš^š[�\™˜XÙK�][È[\Ü�
-ˆÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[Û‹ˆ[XYÙWÙ^\Ýˈ›ØÙ\Ü×Ü[Û[™KŠB‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚™Yˆ˜[Y]WÙ[�š\›Û›Y[�
+r‡^Ñf¥–ئ{O,yÊ'vî¶›"""Startup environment validation and Docker image management."""
-HOˆ›Û™N‚ˆÙÙÙ\‹š[™›Ê•˜[Y][™È[�š\›Û›Y[�ŠBˆÛÛœÛÛHHÛÛœÛÛJ
-BˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÈH×BˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈH×B‚ˆÙ][™ÜÈHØYÜÙ][™ÜÊ
-B‚ˆYˆÛÙ^œÝXœØÜš\[Û—Û[Ù[
-Ù][™ÜË›K›[Ù[
-N‚ˆYˆ›ÝÛÙ^š\ר]][�XØ]Y
+import logging
+import shutil
+import sys
-N‚ˆÛÛœÛÛKœš[�
-ˆˆ–Ü™YTÕ’VÓO^ÜÙ][™ÜË›K›[Ù[H\Ù\È[Ý\ˆÚ]ÔÝXœØÜš\[Û‹‚ˆ˜�][ÝIÜ™H›ÝÚYÛ™Y[‹–Ë×H�[ˆØÞX[—\Ýš^]]ÙÚ[ˆÚ]ÜË×Hš\œÝˆ‚ˆ
-BˆÞ\Ë™^]
-JBˆÙÙÙ\‹š[™›Ê‘[�š\›Û›Y[�ÒÈ
-Ú]ÔÝ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Ð�TÑHŠB‚ˆYˆ›ÝÙ][™ÜËš[�Yܘ][ۜ˜\œ^]WØ\WÚÙ^N‚ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œË˜\[™
-”T”VUWÐTWÒÑVHŠB‚ˆYˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆ\œ›Ü—Ý^H^
+from rich.console import Console
+from rich.panel import Panel
+from rich.text import Text
-Bˆ\œ›Ü—Ý^˜\[™
-“RTÔÒS‘È‘TURT‘QS•’T“Ó“QS•�T’PP“TÈ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
-——ˆ‹Ý[OH�Ú]HŠB‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
-ˆ¸ (ˆÝ˜\ŸH‹Ý[OH˜›ÛY[ÝÈŠBˆ\œ›Ü—Ý^˜\[™
-ˆ\È›ÝÙ]ˆ‹Ý[OH�Ú]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
-—“Ü[Û˜[[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH™[HÚ]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
-ˆ¸ (ˆÝ˜\ŸH‹Ý[OH™[HY[ÝÈŠBˆ\œ›Ü—Ý^˜\[™
-ˆ\È›ÝÙ]ˆ‹Ý[OH™[HÚ]HŠB‚ˆ\œ›Ü—Ý^˜\[™
-—”™\]Z\™Y[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH�Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×Ü™\]Z\™Yݘ\œÎ‚ˆYˆ˜\ˆOH”Õ’VÓHŽ‚ˆ\œ›Ü—Ý^˜\[™
-¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-”Õ’VÓH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
-ˆˆH[Ù[˜[YHÈ\ÙH
-K™Ë‹ ÛÜ[˜ZKÙÜMK� È܈‚ˆ‰Ø[�›ÜXËØÛ]YK[Ü\ËMMÉÊWˆ‹ˆÝ[OH�Ú]H‹ˆ
-B‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ\œ›Ü—Ý^˜\[™
-—“Ü[Û˜[[�š\›Û›Y[�˜\šXX›\Ηˆ‹Ý[OH�Ú]HŠBˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ�TÑHŽ‚ˆ\œ›Ü—Ý^˜\[™
-¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-“WÐTWÐ�TÑH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
-ˆˆHÝ\ÝÛHTH˜\ÙHT“Yˆ\Ú[™ÈØØ[[Ù[È
-K™Ë‹Û[XKTÝY[ÊWˆ‹ˆÝ[OH�Ú]H‹ˆ
-Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^˜\[™
-¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-”T”VUWÐTWÒÑVH‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
-ˆˆHTHÙ^H›Üˆ\œ^]HRHÙXˆÙX\˜Ú
-[˜X›\È™X[][YH™\ÙX\˜Ú
-Wˆ‹ˆÝ[OH�Ú]H‹ˆ
-Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^˜\[™
-¸ (ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹Ý[OH˜›ÛÞX[ˆŠBˆ\œ›Ü—Ý^˜\[™
-ˆˆH™X\ÛÛš[™ÈY™›Ü�]™[ˆ›Û™KZ[š[X[ÝËYY][KYÚYÚ‚ˆ›X^
-Y˜][ˆYÚ
-Wˆ‹ˆÝ[OH�Ú]H‹ˆ
-B‚ˆ\œ›Ü—Ý^˜\[™
-—‘^[\HÙ]\—ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-™^Ü�Õ’VÓOIÛÜ[˜ZKÙÜMK� ׈‹Ý[OH™[HÚ]HŠB‚ˆYˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆ›Üˆ˜\ˆ[ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÎ‚ˆYˆ˜\ˆOH“WÐTWÐ�TÑHŽ‚ˆ\œ›Ü—Ý^˜\[™
-ˆ™^Ü�WÐTWÐ�TÑOIÚ‹ËÛØØ[ÜÝŒLMÍ È‚ˆˆÈ™YYY›ÜˆØØ[[Ù[ÈÛ›Wˆ‹ˆÝ[OH™[HÚ]H‹ˆ
-Bˆ[Yˆ˜\ˆOH”T”VUWÐTWÒÑVHŽ‚ˆ\œ›Ü—Ý^˜\[™
-ˆ™^Ü�T”VUWÐTWÒÑVOIÞ[Ý\‹\\œ^]KZÙ^KZ\™I׈‹Ý[OH™[HÚ]H‚ˆ
-Bˆ[Yˆ˜\ˆOH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•Ž‚ˆ\œ›Ü—Ý^˜\[™
-ˆ™^Ü�Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•IÚYÚ ×ˆ‹ˆÝ[OH™[HÚ]H‹ˆ
-B‚ˆ[™[H[™[
-ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
-B‚ˆÙÙÙ\‹™X�YÊ“Z\ÜÚ[™È™\]Z\™Y[�ˆ˜\œÎˆ \È‹Z\ÜÚ[™×Ü™\]Z\™Yݘ\œÊBˆÛÛœÛÛKœš[�
-—ˆŠBˆÛÛœÛÛKœš[�
-[™[
-BˆÛÛœÛÛKœš[�
+from strix.config import codex, load_settings
+from strix.interface.utils import (
+ check_docker_connection,
+ image_exists,
+ process_pull_line,
+)
-BˆÞ\Ë™^]
-JBˆÙÙÙ\‹š[™›Êˆ‘[�š\›Û›Y[�ÒÈ
-Ü[Û˜[Z\ÜÚ[™Îˆ \ÊH‹ˆZ\ÜÚ[™×ÛÜ[Û˜[ݘ\œÈ܈››Û™H‹ˆ
-B‚‚™YˆÚXÚ×ÙØÚÙ\—Ú[œÝ[Y
-HOˆ›Û™N‚ˆYˆÚ][�ÚXÚ
-™ØÚÙ\ˆŠH\țۙN‚ˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆÓH›Ý›Ý[™[ˆUŠBˆÛÛœÛÛHHÛÛœÛÛJ
-Bˆ\œ›Ü—Ý^H^
+logger = logging.getLogger(__name__)
-Bˆ\œ›Ü—Ý^˜\[™
-‘ÐÒÑTˆ“ÕS”ÕSQ‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
-——ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-•H ÙØÚÙ\‰ÈÓHØ\țݛÝ[™[ˆ[Ý\ˆU—ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-ˆ”X\ÙH[œÝ[ØÚÙ\ˆ[™[œÝ\™HH ÙØÚÙ\‰ÈÛÛ[X[™\È]˜Z[X›K——ˆ‹Ý[OH�Ú]H‚ˆ
-B‚ˆ[™[H[™[
-ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
-BˆÛÛœÛÛKœš[�
-—ˆ‹[™[—ˆŠBˆÞ\Ë™^]
-JBˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆÓH™\Ù[�ŠB‚‚™Yˆ[ÙØÚÙ\—Ú[XYÙJ
-HOˆ›Û™N‚ˆœ›ÛHØÚÙ\‹™\œ›ÜœÈ[\Ü�ØÚÙ\‘^Ù\[Û‚‚ˆÛÛœÛÛHHÛÛœÛÛJ
-BˆÛY[�HÚXÚ×ÙØÚÙ\—ØÛÛ›™XÝ[ÛŠ
-B‚ˆ�[�[YHHØYÜÙ][™ÜÊ
-Kœ�[�[YBˆ[XYÙHH�[�[YKš[XYÙB‚ˆYˆ[XYÙWÙ^\ÝÊÛY[�[XYÙJN‚ˆÙÙÙ\‹™X�YÊ‘ØÚÙ\ˆ[XYÙH[™XYH™\Ù[�ØØ[Nˆ \È‹[XYÙJBˆ™]\›‚‚ˆYˆ�[�[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH�[�[YQ\œ›ÜŠˆˆ‘ØÚÙ\ˆ[XYÙHÚ[XYÙH\ŸH\È›Ý]˜Z[X›HØØ[H[™‚ˆ”Õ’VÒSPQÑWÔSÔÓPÖO[™]™\ˆ›Ü˜šYÈ[[™È]‚ˆ
-B‚ˆÙÙÙ\‹š[™›Ê”[[™ÈØÚÙ\ˆ[XYÙNˆ \È‹[XYÙJBˆÛÛœÛÛKœš[�
-BˆÛÛœÛÛKœš[�
-ˆ–Ù[WT[[™È[XYÙVË×HÚ[XYÙ_HŠBˆÛÛœÛÛKœš[�
-–Ù[HY[Ý×U\ÈÛ›H\[œÈÛˆš\œÝ�[ˆ[™X^HZÙHH™]ÈZ[�]\Ë‹‹–Ë×HŠBˆÛÛœÛÛKœš[�
+def validate_environment() -> None:
+ logger.info("Validating environment")
+ console = Console()
+ missing_required_vars = []
+ missing_optional_vars = []
-B‚ˆÚ]ÛÛœÛÛKœÝ]\Ê–Ø›ÛÞX[—QÝÛ›ØY[™È[XYÙH^Y\œË‹‹ˆ‹Ü[›™\�H™ÝÈŠH\ÈÝ]\΂ˆžN‚ˆ^Y\œ×Ú[™›ÎˆXÝÜ݋ݗHHßBˆ\ÝÝ\]HHˆ‚‚ˆ›Üˆ[™H[ˆÛY[�˜\Kœ[
-[XYÙKÝ™X[OU�YKXÛÙOU�YJN‚ˆ\ÝÝ\]HH›ØÙ\Ü×Ü[Û[™J[™K^Y\œ×Ú[™›ËÝ]\Ë\ÝÝ\]JB‚ˆ^Ù\ØÚÙ\‘^Ù\[Ûˆ\ÈN‚ˆÙÙÙ\‹™X�YÊ‘˜Z[YÈ[ØÚÙ\ˆ[XYÙH \È‹[XYÙK^×Ú[™›ÏU�YJBˆÛÛœÛÛKœš[�
+ settings = load_settings()
-Bˆ\œ›Ü—Ý^H^
+ 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ˆ\œ›Ü—Ý^˜\[™
-‘�RSQÈSSPQÑH‹Ý[OH˜›Û™YŠBˆ\œ›Ü—Ý^˜\[™
-——ˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-ˆ�ÛÝ[›ÝÝÛ›ØYˆÚ[XYÙ_Wˆ‹Ý[OH�Ú]HŠBˆ\œ›Ü—Ý^˜\[™
-ÝŠJKÝ[OH™[H™YŠB‚ˆ[™[H[™[
-ˆ\œ›Ü—Ý^ˆ]OH–Ø›ÛÚ]WTÕ’V‹ˆ]WØ[YÛ�H›Y�‹ˆ›Ü™\—ÜÝ[OHœ™Y‹ˆY[™ÏJKŠKˆ
-BˆÛÛœÛÛKœš[�
-[™[—ˆŠBˆÞ\Ë™^]
-JB‚ˆÙÙÙ\‹š[™›Ê‘ØÚÙ\ˆ[XYÙH \È™XYH‹[XYÙJBˆÝXØÙ\Ü×Ý^H^
+ if not settings.llm.model:
+ missing_required_vars.append("STRIX_LLM")
-BˆÝXØÙ\Ü×Ý^˜\[™
-‘ØÚÙ\ˆ[XYÙH™XYH‹Ý[OHˆÌŒ˜ÍMYHŠBˆÛÛœÛÛKœš[�
-ÝXØÙ\Ü×Ý^
-BˆÛÛœÛÛKœš[�
+ if not settings.llm.api_key:
+ missing_optional_vars.append("LLM_API_KEY")
-B
\ No newline at end of file
+ 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()
+
+ runtime = load_settings().runtime
+ image = runtime.image
+
+ if image_exists(client, image):
+ logger.debug("Docker image already present locally: %s", image)
+ return
+
+ if runtime.image_pull_policy == "never":
+ raise RuntimeError(
+ f"Docker image {image!r} is not available locally and "
+ "STRIX_IMAGE_PULL_POLICY=never forbids pulling it"
+ )
+
+ 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()
diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py
index 5398ed8b..7bc9b9e2 100644
--- a/strix/runtime/backends.py
+++ b/strix/runtime/backends.py
@@ -1,107 +1,31 @@
-r‡^Ñf¥–ئ{O,yÊ'vî¶›"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
+r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ø[™›Þ˜XÚÙ[™™YÚ\ÝžH8 %Ù[XÝYšXHÕ’VÔ•S•SQWÐ�PÒÑS‘
+Y˜][ˆØÚÙ\ŠKˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�ÙÙÚ[™Â™œ›ÛHÛÛXÝ[ۜ˘X˜È[\Ü�]ØZ]X›KØ[X›B™œ›ÛH\[™È[\Ü�TWÐÒPÒÒS‘Ë[žB‚‚šYˆTWÐÒPÒÒS‘΂ˆœ›ÛHYÙ[�ËœØ[™›Þ›X[šY™\Ý[\Ü�X[šY™\Ý‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚”Ø[™›Þ˜XÚÙ[™HØ[X›VË‹‹‹]ØZ]X›VÝ\VÐ[žK[žWWWB‚‚˜\Þ[˜ÈYˆÙØÚÙ\—ؘXÚÙ[™
+ˆ
+‹ˆ[XYÙNˆÝ‹ˆX[šY™\݈X[šY™\݈^ÜÙYÜÜ�Έ\VÚ[�‹‹—Kˆš[™Û[Ý[�Έ\ÝÙXÝÜÝ‹[žWWH›Û™HH›Û™KŠHOˆ\VÐ[žK[žWN‚ˆˆˆ�œš[™È\HÙ\ÜÚ[Ûˆ˜XÚÙYžHHØØ[ØÚÙ\ˆY[[Û‹‚‚ˆ\Ù\ȘÛ\ÜΘݚ^ØÚÙ\”Ø[™›ÞÛY[�È[š™XÝ‘UÐQRSˆÂˆ‘UÔ�UÈØ\È
+ÈÜÝ™ØÚÙ\‹š[�\›˜[ÜÝYØ]]Ø^Kˆ[\Ü�ˆØÚÙ\˜^š[HÛÈ\Þ[Y[�È]\™Ù]H›Û‹QØÚÙ\‚ˆ˜XÚÙ[™Û‰Ý™YYHØÚÙ\‹\HXœ˜\žH[œÝ[Y‚‚ˆÙ\ÜÚ[Û‹œÝ\�
-from __future__ import annotations
+X\ÈÚ]X]\šX[^™\ÈHX[šY™\Ý[�ÈH�[›š[™ÂˆÛÛ�Z[™\ˆ8 %HÑÉÜÈÛY[�˜Ü™X]J
+XÛ›H�Z[ÈH[›™\ˆÙ\ÜÚ[Û‚ˆØš™XÝÚ]Ý]\Z[™È]ˆ\Þ[˜ÈÚ]Ù\ÜÚ[ÛŽ˜ÛÝ[Ø[]ÛË�]ˆÝš^X[˜YÙ\ÈÙ\ÜÚ[ÛˆY™][YH^XÚ]HšXHÛY[�™[]J
+XÛÈÙBˆšYÙÙ\ˆÝ\�
-import logging
-from collections.abc import Awaitable, Callable
-from typing import TYPE_CHECKING, Any
+XÝ\œÙ[™\Ë‚ˆˆˆ‚ˆ[\Ü�ØÚÙ\‚ˆœ›ÛHYÙ[�ËœØ[™›ÞœØ[™›Þ\Ë™ØÚÙ\ˆ[\Ü�ØÚÙ\”Ø[™›ÞÛY[�Ü[ۜ‚ˆœ›ÛHÝš^˜ÛÛ™šYË›ØY\ˆ[\Ü�ØYÜÙ][™Üˆœ›ÛHÝš^œ�[�[YK™ØÚÙ\—ØÛY[�[\Ü�Ýš^ØÚÙ\”Ø[™›ÞÛY[�‚ˆÛY[�HÝš^ØÚÙ\”Ø[™›ÞÛY[�
+ØÚÙ\‹™œ›ÛWÙ[�Š
+JBˆÛY[�œÝš^Øš[™Û[Ý[�ÈHš[™Û[Ý[�È܈×BˆÛY[�š[XYÙWÜ[ÜÛXÞHHØYÜÙ][™ÜÊ
+Kœ�[�[YKš[XYÙWÜ[ÜÛXÞBˆÜ[ÛœÈHØÚÙ\”Ø[™›ÞÛY[�Ü[ÛœÊ[XYÙOZ[XYÙK^ÜÙYÜÜ�ÏY^ÜÙYÜÜ�ÊBˆÙ\ÜÚ[ÛˆH]ØZ]ÛY[�˜Ü™X]JÜ[ÛœÏ[Ü[ÛœËX[šY™\Ý[X[šY™\Ý
+Bˆ]ØZ]Ù\ÜÚ[Û‹œÝ\�
-
-if TYPE_CHECKING:
- from agents.sandbox.manifest import Manifest
-
-
-logger = logging.getLogger(__name__)
-
-
-SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
-
-
-async def _docker_backend(
- *,
- image: str,
- manifest: Manifest,
- exposed_ports: tuple[int, ...],
- bind_mounts: list[dict[str, Any]] | None = None,
-) -> tuple[Any, Any]:
- """Bring up a session backed by the local Docker daemon.
-
- Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN /
- NET_RAW caps + ``host.docker.internal`` host-gateway. Imports
- ``docker`` lazily so deployments that target a non-Docker
- backend don't need the docker-py library installed.
-
- ``session.start()`` is what materializes the manifest into the running
- container — the SDK's ``client.create()`` only builds the inner session
- object without applying it. ``async with session:`` would call it too, but
- Strix manages session lifetime explicitly via ``client.delete()`` so we
- trigger ``start()`` ourselves.
- """
- 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()
- return client, session
-
-
-_BACKENDS: dict[str, SandboxBackend] = {
- "docker": _docker_backend,
-}
-
-_BIND_MOUNT_BACKENDS: set[str] = {"docker"}
-
-
-def get_backend(name: str) -> SandboxBackend:
- """Return the backend factory for ``name`` or raise.
-
- Args:
- name: Backend identifier (e.g. ``"docker"``). Match is exact;
- no fallback. Unknown values raise so config typos surface
- immediately instead of silently picking a default.
- """
- backend = _BACKENDS.get(name)
- if backend is None:
- supported = ", ".join(sorted(_BACKENDS))
- raise ValueError(
- f"Unknown STRIX_RUNTIME_BACKEND: {name!r} (supported: {supported})",
- )
- logger.debug("Selected sandbox backend: %s", name)
- return backend
-
-
-def register_backend(
- name: str,
- backend: SandboxBackend,
- *,
- supports_bind_mounts: bool = False,
-) -> None:
- """Register a custom backend under ``name``.
-
- Intended for downstream users who ship their own runtime — register
- before any ``session_manager.create_or_reuse`` call. Re-registering
- an existing name overwrites the prior entry. ``supports_bind_mounts``
- defaults to False: a remote runtime cannot see the caller's filesystem, so
- it is handed local sources as manifest entries to upload instead.
- """
- _BACKENDS[name] = backend
- if supports_bind_mounts:
- _BIND_MOUNT_BACKENDS.add(name)
- else:
- _BIND_MOUNT_BACKENDS.discard(name)
- logger.info("Registered sandbox backend: %s (bind mounts: %s)", name, supports_bind_mounts)
-
-
-def backend_supports_bind_mounts(name: str) -> bool:
- return name in _BIND_MOUNT_BACKENDS
-
-
-def supported_backends() -> list[str]:
- return sorted(_BACKENDS)
+Bˆ™]\›ˆÛY[�Ù\ÜÚ[Û‚‚‚—Ð�PÒÑS‘ΈXÝÜÝ‹Ø[™›Þ˜XÚÙ[™HHˆ™ØÚÙ\ˆŽˆÙØÚÙ\—ؘXÚÙ[™ŸB‚—Ð’S‘ÓSÕS•Ð�PÒÑS‘ΈÙ]ÜÝ—HHÈ™ØÚÙ\ˆŸB‚‚™YˆÙ]ؘXÚÙ[™
+˜[YNˆÝŠHOˆØ[™›Þ˜XÚÙ[™‚ˆˆˆ”™]\›ˆH˜XÚÙ[™˜XÝÜžH›Üˆ˜[YX܈˜Z\ÙK‚‚ˆ\™Ü΂ˆ˜[YNˆ˜XÚÙ[™Y[�YšY\ˆ
+K™Ëˆ™ØÚÙ\ˆ˜
+KˆX]Ú\È^X݈›È˜[˜XÚˈ[šÛ›ÝÛˆ˜[Y\ȘZ\ÙHÛÈÛÛ™šYÈ\ÜÈÝ\™˜XÙBˆ[[YYX][H[œÝXYÙˆÚ[[�HXÚÚ[™ÈHY˜][‚ˆˆˆ‚ˆ˜XÚÙ[™HÐ�PÒÑS‘Ë™Ù]
+˜[YJBˆYˆ˜XÚÙ[™\țۙN‚ˆÝ\Ü�YH‹‹š›Ú[ŠÛÜ�Y
+Ð�PÒÑS‘ÊJBˆ˜Z\ÙH˜[YQ\œ›ÜŠˆˆ•[šÛ›ÝÛˆÕ’VÔ•S•SQWÐ�PÒÑS‘ˆÛ˜[YH\ŸH
+Ý\Ü�YˆÜÝ\Ü�YJH‹ˆ
+BˆÙÙÙ\‹™X�YÊ”Ù[XÝYØ[™›Þ˜XÚÙ[™ˆ \È‹˜[YJBˆ™]\›ˆ˜XÚÙ[™‚‚™Yˆ™YÚ\Ý\—ؘXÚÙ[™
+ˆ˜[YNˆÝ‹ˆ˜XÚÙ[™ˆØ[™›Þ˜XÚÙ[™ˆ
+‹ˆÝ\Ü�רš[™Û[Ý[�Έ›ÛÛH˜[ÙKŠHOˆ›Û™N‚ˆˆˆ”™YÚ\Ý\ˆHÝ\ÝÛH˜XÚÙ[™[™\ˆ˜[YX‚‚ˆ[�[™Y›ÜˆÝۜݙX[H\Ù\œÈÚÈÚ\Z\ˆÝÛˆ�[�[YH8 %™YÚ\Ý\‚ˆ™Y›Ü™H[žHÙ\ÜÚ[Û—ÛX[˜YÙ\‹˜Ü™X]WÛÜ—Ü™]\ÙXØ[ˆ™K\™YÚ\Ý\š[™Âˆ[ˆ^\Ý[™È˜[YHÝ™\�Üš]\ÈHš[܈[�žKˆÝ\Ü�רš[™Û[Ý[�؈Y˜][ÈȘ[ÙNˆH™[[ÝH�[�[YHØ[››ÝÙYHHØ[\‰ÜÈš[\Þ\Ý[KÛˆ]\È[™YØØ[ÛÝ\˜Ù\È\ÈX[šY™\Ý[�šY\ÈÈ\ØY[œÝXY‚ˆˆˆ‚ˆÐ�PÒÑS‘ÖÛ˜[YWHH˜XÚÙ[™ˆYˆÝ\Ü�רš[™Û[Ý[�΂ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘˘Y
+˜[YJBˆ[ÙN‚ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘Ë™\ØØ\™
+˜[YJBˆÙÙÙ\‹š[™›Ê”™YÚ\Ý\™YØ[™›Þ˜XÚÙ[™ˆ \È
+š[™[Ý[�Έ \ÊH‹˜[YKÝ\Ü�רš[™Û[Ý[�ÊB‚‚™Yˆ˜XÚÙ[™ÜÝ\Ü�רš[™Û[Ý[�ʘ[YNˆÝŠHOˆ›ÛÛ‚ˆ™]\›ˆ˜[YH[ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘‚‚™YˆÝ\Ü�YؘXÚÙ[™Ê
+HOˆ\ÝÜÝ—N‚ˆ™]\›ˆÛÜ�Y
+Ð�PÒÑS‘ÊB
\ No newline at end of file
diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py
index c70f2d2d..fc3a56e5 100644
--- a/strix/runtime/docker_client.py
+++ b/strix/runtime/docker_client.py
@@ -1,114 +1,307 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ýš^ØÚÙ\”Ø[™›ÞÛY[�8 %™\Ù\�™\ÈH[XYÙIÜÈS•–TÒS•[™Y“‘UÐQRS‹Ó‘UÔ�UÈØ\Xš[]Y\È
-ÈÜÝYØ]]Ø^K‚‚•HÑÉÜÈØÚÙ\”Ø[™›ÞÛY[�—ØÜ™X]WØÛÛ�Z[™\˜Ù\È›Ý^ÜÙHHÛÚț܂™^[™[™ÈÜ™X]WÚÝØ\™ÜØ™Y›Ü™HÛÛ�Z[™\œË˜Ü™X]X\ÈØ[YˆÙHÝX˜Û\ܘ[™™Z[\[Y[�HY]Ù›ÙH™\˜˜][Hœ›ÛHHÑÈÛÝ\˜ÙKÚ]™YB™[\΂‚ŒKˆ›ÜHÑÉÜÈ[�ž\Ú[�VÈ�Z[—XÝ™\œšYNÈÝ\HÈ�Z[‹‹Yˆ‹ˆ‹Ù]‹Û�[—X\ÈÛÛ[X[™[œÝXYˆ\È]ÈÝ\ˆ[XYÙI܈ØÚÙ\‹Y[�ž\Ú[�œÚXÝX[H�[ˆ8 %Ú]Ý]]ØZYËXÛX™]™\‚ˆÝ\�È[œÚYHHÛÛ�Z[™\ˆ[™›ÛÝݘ\ØØZYØ™]šY\ÈYØZ[œÝBˆXYÜ�‚Œ‹ˆ\[™‘UÐQRS‹Ó‘UÔ�UÈÈØ\ØY
-™\]Z\™YžH›X\\ÔØ[™ˆÝ\ˆ˜]Ë\ÛØÚÙ]ÛÛÊK‚ŒËˆYÜÝ™ØÚÙ\‹š[�\›˜[8¡¤ˆÜÝYØ]]Ø^HÈ^˜WÚÜÝØÛÈBˆYÙ[�Ø[ˆ™XXÚÜÝ\Ù\�™Y\Ë‚‚”[›™YÈÜ[˜ZKXYÙ[�ÏOLŒM�˜ˆ�[\[™ÈHÑÈ™\]Z\™\œ™K[Y\™Ú[™ÈH\™[�›ÙKˆ˜XÚÈ\Ý™X[H›Üˆ[ˆ[š™XÝ[ÛˆÛÚË‚ˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�ÛÛ�^X‚š[\Ü�ÙÙÚ[™Âš[\Ü�Üš[\Ü�]ZY™œ›ÛH\[™È[\Ü�[žK]\˜[Ø\Ý‚™œ›ÛHYÙ[�ËœØ[™›Þ™\œ›ÜœÈ[\Ü�^ÜÙYÜ�[˜]˜Z[X›Q\œ›Ü‚™œ›ÛHYÙ[�ËœØ[™›Þ›X[šY™\Ý[\Ü�X[šY™\Ý™œ›ÛHYÙ[�ËœØ[™›ÞœØ[™›Þ\Ë™ØÚÙ\ˆ[\Ü�
-ˆØÚÙ\”Ø[™›ÞÛY[�ˆØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‹ˆØ�Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[�ËˆÙØÚÙ\—ÜÜ�ÚÙ^KˆÛX[šY™\ÝÜ™\]Z\™\×Ù�\ÙKˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[‹ŠB™œ›ÛHYÙ[�ËœØ[™›ÞœÙ\ÜÚ[Û‹œØ[™›ÞÜÙ\ÜÚ[Ûˆ[\Ü�Ø[™›ÞÙ\ÜÚ[Û‚™œ›ÛHYÙ[�ËœØ[™›Þ�\\È[\Ü�^ÜÙYÜ�[™Ú[�™œ›ÛHØÚÙ\ˆ[\Ü�\œ›ÜœÈ\ÈØÚÙ\—Ù\œ›ÜœÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹›[Ù[˘ÛÛ�Z[™\œÈ[\Ü�ÛÛ�Z[™\ˆÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�\\È[\Ü�ÙÐÛÛ™šYÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�\\È[\Ü�[Ý[�\ÈØÚÙ\”ÑÓ[Ý[�È\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛHØÚÙ\‹�][È[\Ü�\œÙWÜ™\ÜÚ]ÜžWÝYÈÈ\NˆYۛܙVÚ[\Ü�][�\Y[�\ÙYZYۛܙWB™œ›ÛH™\]Y\ÝË™^Ù\[ÛœÈ[\Ü�™\]Y\Ý^Ù\[Û‚‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚—ÔÐS‘“ÖÓ‘UÓÔ’×ÑS•ˆH”Õ’VÑÐÒÑT—ÔÐS‘“ÖÓ‘UÓÔ’È‚‚‚™YˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JˆÛY[�ˆ[žKˆ[XYÙNˆÝ‹ˆ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—KŠHOˆ›Û™N‚ˆYˆÛY[�š[XYÙWÙ^\ÝÊ[XYÙJN‚ˆ™]\›‚ˆYˆ[ÜÛXÞHOH›™]™\ˆŽ‚ˆ˜Z\ÙH�[�[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[�™ØÚÙ\—ØÛY[�š[XYÙ\Ëœ[
-™\ËYÏ]YÈ܈›Û™K[ÝYÜÏQ˜[ÙJB‚‚™YˆÜØ[™›ÞÛ™]ÛÜšÊ
-HOˆÝˆ›Û™N‚ˆ˜[YHHÜË™[�š\›Û‹™Ù]
-ÔÐS‘“ÖÓ‘UÓÔ’×ÑS•‹ˆŠKœÝš\
+r‡^Ñf¥–ئ{O,yÊ'vî¶›"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
+NET_ADMIN/NET_RAW capabilities + host-gateway.
-Bˆ™]\›ˆ˜[YH܈›Û™B‚‚™YˆØ\WÜØ[™›ÞÛ™]ÛÜšÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
-BˆYˆ™]Ûܚ΂ˆÜ™X]WÚÝØ\™ÜÖÈ›™]ÛܚȗHH™]ÛܚˆܙX]WÚÝØ\™ÜËœÜ
-œÜ�È‹›Û™JB‚‚™YˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆˆˆ�\HÜ[Û˜[ÙÜ›Ý\™\ÛÝ\˜ÙHØ\Èœ›ÛHH[�š\›Û›Y[�ˆ[œÙ]Ø›[šÂˆ˜[Y\ÈX]™HØÚÙ\‰ÜÈY˜][
-[˜›Ý[™Y
-KÛÈ\È\ÈÜZ[ˆ\ˆÜ݈ˆˆ‚ˆY[WÛ[Z]HÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÓQSWÓSRU‹ˆŠ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ˆYˆY[WÛ[Z]‚ˆÜ™X]WÚÝØ\™ÜÖÈ›Y[WÛ[Z]—HHY[WÛ[Z]‚ˆÚWÜÚ^™HHÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÔÒWÔÒV‘H‹ˆŠ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ˆÚWÜÚ^™N‚ˆÜ™X]WÚÝØ\™ÜÖÈœÚWÜÚ^™H—HHÚWÜÚ^™B‚ˆÜ\ÈHÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÐÔTÈ‹ˆŠ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ˆÜ\΂ˆÚ]ÛÛ�^X‹œÝ\™\Üʘ[YQ\œ›Ü‹Ý™\™›ÝÑ\œ›ÜŠN‚ˆ˜[›×ØÜ\ÈH[�
-ݯ]
-Ü\ÊH
-ˆWÌÌÌ
-BˆYˆ˜[›×ØÜ\ÈHŠŠ�ŒÈHN‚ˆÜ™X]WÚÝØ\™ÜÖÈ›˜[›×ØÜ\È—HH˜[›×ØÜ\‚ˆY×Û[Z]HÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÔQ×ÓSRU‹ˆŠKœÝš\
+from __future__ import annotations
-BˆYˆY×Û[Z]‚ˆÚ]ÛÛ�^X‹œÝ\™\Üʘ[YQ\œ›ÜŠN‚ˆÜ™X]WÚÝØ\™ÜÖÈœY×Û[Z]—HH[�
-Y×Û[Z]
-B‚‚™YˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆˆˆ�›Ý[™HÛÛ�Z[™\‰ÜÈœÛÛ‹Yš[HÙÈÛÈH�[˜]Ø^H›ØÙ\ÜÈ[ˆHØ[™›Þˆ
-K™ËˆHÛÛ]�\ÞK[ÛÜÈÜš][™ÈÈÝÝ]
-HØ[››Ýš[HÜÝ\Úˆ[™ZÙHHØÚÙ\ˆY[[ÛˆÝÛˆÚ]]‚‚ˆ[›ZÙHHÙÜ›Ý\Ø\ÈX›Ý™K\ÈY˜][È
-Š›ÛŠŠˆ8 %ØÚÙ\‰ÜÈÝÛˆY˜][ˆ\È[ˆ[˜›Ý[™YœÛÛ‹Yš[KÚXÚ\È[œØY™H›Üˆ[ˆ]]Û›Û[Ý\ÈYÙ[�]ˆ^XÝ]\È\˜š]˜\žHÛÛ[X[™ËˆX^Yš[X›Ý][ÛˆYX[œÈHÛ‹Y\ÚÈØ\\ˆX^\Ú^™H
-ˆX^Yš[XˆÙ]Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘XÈØÙ™˜ˆÈܘXÚÈÝ]ÈØÚÙ\‰ÜÈY˜][ˆˆˆ‚ˆX^ÜÚ^™HHÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÓÑ×ÓPVÔÒV‘H‹�LHŠKœÝš\
+import contextlib
+import logging
+import os
+import uuid
+from typing import Any, Literal, cast
-BˆYˆX^ÜÚ^™K›ÝÙ\Š
-H[ˆ
-Œ‹›Ù™ˆ‹››Û™H‹�[›[Z]YŠN‚ˆ™]\›‚ˆX^Ùš[HHÜË™[�š\›Û‹™Ù]
-”Õ’VÔÐS‘“ÖÓÑ×ÓPVÑ’SH‹ŒÈŠ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
-H܈ŒÈ‚ˆÜ™X]WÚÝØ\™ÜÖÈ›ÙרÛÛ™šYÈ—HHÙÐÛÛ™šYʈ\OSÙÐÛÛ™šYË�\\Ë’”ÓÓ‹ˆÛÛ™šYÏ^È›X^\Ú^™HŽˆX^ÜÚ^™K›X^Yš[HŽˆX^Ùš[_Kˆ
-B‚‚™YˆØ\WÜ�[—ÛX™[ÊÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWJHOˆ›Û™N‚ˆ�[—ÚYHÜË™Ù][�ДՒVÔ•S—ÒQŠBˆYˆ›Ý�[—ÚY‚ˆ™]\›‚ˆX™[ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
-›X™[È‹ßJBˆYˆ›Ý\Ú[œÝ[˜ÙJX™[ËXÝ
-N‚ˆ™]\›‚ˆX™[ÖȜݚ^\�[‹ZY—HH�[—ÚYˆ�[—Ý\HHÜË™Ù][�ДՒVÔ•S—ÕTHŠBˆYˆ�[—Ý\N‚ˆX™[ÖȜݚ^\�[‹]\H—HH�[—Ý\B‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆØ[™›ÞÛ™]ÛܚΈ݈Hˆ‚‚ˆ\Þ[˜ÈYˆÜ™\ÛÛ™WÙ^ÜÙYÜÜ�
-Ù[‹Ü�ˆ[�
-HOˆ^ÜÙYÜ�[™Ú[�‚ˆžN‚ˆÙ[‹—ØÛÛ�Z[™\‹œ™[ØY
-Bˆ^Ù\ØÚÙ\—Ù\œ›ÜœË�TQ\œ›Üˆ\ÈN‚ˆ˜Z\ÙH^ÜÙYÜ�[˜]˜Z[X›Q\œ›ÜŠˆÜ�\Ü�ˆ^ÜÙYÜÜ�Ï\Ù[‹œÝ]K™^ÜÙYÜÜ�ˈ™X\ÛÛ�H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ�^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ�Z[™\—Ü™[ØYÙ˜Z[Y‹ˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆØ]\ÙOYKˆ
-Hœ›ÛHB‚ˆ]œÈHÙ]]ŠÙ[‹—ØÛÛ�Z[™\‹˜]œÈ‹ßJH܈ßBˆ™]ÛÜšÜÈH]œË™Ù]
-“™]ÛÜšÔÙ][™ÜÈ‹ßJK™Ù]
-“™]ÛÜšÜÈ‹ßJBˆ[™Ú[�H™]ÛÜšÜË™Ù]
-Ù[‹œØ[™›ÞÛ™]ÛÜšÊH܈ßBˆ\H[™Ú[�™Ù]
-’TY™\ÜÈŠH܈[™Ú[�™Ù]
-‘ÛØ˜[T��Y™\ÜÈŠBˆYˆ›Ý\Ú[œÝ[˜ÙJ\ÝŠH܈›Ý\‚ˆ˜Z\ÙH^ÜÙYÜ�[˜]˜Z[X›Q\œ›ÜŠˆÜ�\Ü�ˆ^ÜÙYÜÜ�Ï\Ù[‹œÝ]K™^ÜÙYÜÜ�ˈ™X\ÛÛ�H˜˜XÚÙ[™Ý[˜]˜Z[X›H‹ˆÛÛ�^^ˆ˜˜XÚÙ[™Žˆ™ØÚÙ\ˆ‹ˆ™]Z[Žˆ˜ÛÛ�Z[™\—Û›ÝÛÛ—Û™]Ûܚȋˆ›™]ÛܚȎˆÙ[‹œØ[™›ÞÛ™]ÛܚˈKˆ
-BˆÜÝHˆ–ÞÚ\WHˆYˆŽˆˆ[ˆ\[ÙH\ˆ™]\›ˆ^ÜÙYÜ�[™Ú[�
-ÜÝZÜÝÜ�\Ü�ÏQ˜[ÙJB‚‚˜Û\ÜÈÝš^ØÚÙ\”Ø[™›ÞÛY[�
-ØÚÙ\”Ø[™›ÞÛY[�
-N‚ˆÈÜÝ\™XÝÜšY\ÈÈš[™[[Ý[�[�ÈHÛÛ�Z[™\‹Ù]žHHØÚÙ\‚ˆÈ˜XÚÙ[™™Y›Ü™HÜ™X]J
-XˆXXÚ][H\ÈÜÛÝ\˜ÙK\™Ù]™XYÛÛ›_X‚ˆÝš^Øš[™Û[Ý[�Έ\ÝÙXÝÜÝ‹[žWWH›Û™HH›Û™Bˆ[XYÙWÜ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—HH˜]]È‚‚ˆ\Þ[˜ÈYˆØÜ™X]WØÛÛ�Z[™\ŠˆÙ[‹ˆ[XYÙNˆÝ‹ˆ
-‹ˆX[šY™\݈X[šY™\ݛۙHH›Û™Kˆ^ÜÙYÜÜ�Έ\VÚ[�‹‹—HH
+logger = logging.getLogger(__name__)
-KˆÙ\ÜÚ[Û—ÚYˆ]ZY•URQ›Û™HH›Û™Kˆ
-HOˆÛÛ�Z[™\Ž‚ˆÈKKKKH‘QÒSˆ‘T��USHÓÔHÙˆØÚÙ\”Ø[™›ÞÛY[�—ØÜ™X]WØÛÛ�Z[™\ˆKKKKBˆÈÑÈ™YŽˆÜ˜ËØYÙ[�ËÜØ[™›ÞÜØ[™›Þ\ËÙØÚÙ\‹œNŒMÍLM
ÍÈ
-ŒŒM�ŠK‚ˆÙ[œÝ\™WÚ[XYÙWØ]˜Z[X›JÙ[‹[XYÙKÙ[‹š[XYÙWÜ[ÜÛXÞJB‚ˆ\ÜÙ\�Ù[‹š[XYÙWÙ^\ÝÊ[XYÙJBˆ[�š\›Û›Y[�ˆXÝÜ݋ݗH›Û™HH›Û™BˆYˆX[šY™\Ý‚ˆ[�š\›Û›Y[�H]ØZ]X[šY™\Ý™[�š\›Û›Y[�œ™\ÛÛ™J
-BˆÈÝš^[Hœ›ÛHHÑÈ›ÙNˆ›Ü[�ž\Ú[�Ý™\œšYH[™ˆÈÝ\HZ[YˆÙ]‹Û�[\ÈÛÛ[X[™ÛÈH[XYÙI܈ÈS•–TÒS•
-ØÚÙ\‹Y[�ž\Ú[�œÚ
-H�[œÈÙ]\[ˆ^Xˆȉ˜™XÛÛY\È^XÈZ[YˆÙ]‹Û�[›ÜˆHÙY\X[]™K‚ˆÈÚ]Ý]\ËØZYËXÛH
-ÈH[‹XÛÛ�Z[™\ˆÐH�\Ý™]™\ˆÙ]ˆÈ[š]X[^™Y‚ˆÜ™X]WÚÝØ\™ÜΈXÝÜÝ‹[žWHHˆš[XYÙHŽˆ[XYÙKˆ™]XÚŽˆ�YKˆ˜ÛÛ[X[™ŽˆÈ�Z[‹‹Yˆ‹‹Ù]‹Û�[—Kˆ™[�š\›Û›Y[�Žˆ[�š\›Û›Y[�ˆBˆYˆX[šY™\Ý\țݛۙN‚ˆØÚÙ\—Û[Ý[�ÈHØ�Z[ÙØÚÙ\—Ý›Û[YWÛ[Ý[�ʈX[šY™\݈Ù\ÜÚ[Û—ÚY\Ù\ÜÚ[Û—ÚYˆ
-BˆYˆØÚÙ\—Û[Ý[�΂ˆÜ™X]WÚÝØ\™ÜÖÈ›[Ý[�È—HHØÚÙ\—Û[Ý[�ˆYˆÛX[šY™\ÝÜ™\]Z\™\×Ù�\ÙJX[šY™\Ý
-N‚ˆÜ™X]WÚÝØ\™ÜË�\]Jˆ]šXÙ\ÏVÈ‹Ù]‹Ù�\ÙH—KˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ�[˜ÛÛ™š[™Y—Kˆ
-Bˆ[YˆÛX[šY™\ÝÜ™\]Z\™\×ÜÞ\רYZ[ŠX[šY™\Ý
-N‚ˆÜ™X]WÚÝØ\™ÜË�\]JˆØ\ØYVÈ”ÖT×ÐQRSˆ—KˆÙXÝ\š]WÛÜVȘ\\›[ÜŽ�[˜ÛÛ™š[™Y—Kˆ
-BˆYˆ^ÜÙYÜÜ�΂ˆÜ™X]WÚÝØ\™ÜÖÈœÜ�È—HHÂˆÙØÚÙ\—ÜÜ�ÚÙ^JÜ�
-Nˆ
-ŒL�ËŒŒŒH‹›Û™JH›ÜˆÜ�[ˆ^ÜÙYÜÜ�ˆBˆÈKKKKHS‘‘T��USHÓÔHKKKKB‚ˆÈÝš^[š™XÝ[ÛœÈ8 %\[™Û‰ÝÝ™\�Üš]KÛÈ•TÑKÔÖT×ÐQRSˆÝ\�š]™\Ë‚ˆØ\ØYHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
-˜Ø\ØY‹×JBˆYˆ›Ý\Ú[œÝ[˜ÙJØ\ØY\Ý
-N‚ˆØ\ØYH\Ý
-Ø\ØY
-BˆÜ™X]WÚÝØ\™ÜÖȘØ\ØY—HHØ\ØYˆ›ÜˆØ\[ˆ
-“‘UÐQRSˆ‹“‘UÔ�UÈŠN‚ˆYˆØ\›Ý[ˆØ\ØY‚ˆØ\ØY˜\[™
-Ø\
-B‚ˆ^˜WÚÜÝÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
-™^˜WÚÜÝÈ‹ßJBˆ^˜WÚÜÝÖÈšÜÝ™ØÚÙ\‹š[�\›˜[—HHšÜÝYØ]]Ø^H‚‚ˆØ\WÜØ[™›ÞÛ™]ÛÜšÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ™\ÛÝ\˜ÙWÛ[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÛÙ×Û[Z]ÊÜ™X]WÚÝØ\™ÜÊBˆØ\WÜ�[—ÛX™[ÊÜ™X]WÚÝØ\™ÜÊB‚ˆÈÝš^[š™XÝ[ÛŽˆØØ[ÛÝ\˜ÙH™Y\ËÛÜ�YÚ[ÝÙ\ÝYš\œÝÛÈBˆÈ™\ÝYÜXÈ[™ÈÛˆÜÙˆH™YH]ÛÝ™\œË‚ˆš[™Û[Ý[�ÈHÙ[‹œÝš^Øš[™Û[Ý[�È܈
-BˆYˆš[™Û[Ý[�΂ˆ[Ý[�ÈHÜ™X]WÚÝØ\™ÜËœÙ]Y˜][
-›[Ý[�È‹×JBˆ›ÜˆÜXÈ[ˆÛÜ�Y
-š[™Û[Ý[�ËÙ^O[[X™HΈ݊ÖÈ�\™Ù]—JK˜ÛÝ[�
-‹ÈŠJN‚ˆ[Ý[�˘\[™
-ˆØÚÙ\”ÑÓ[Ý[�
-ˆ\™Ù]\ÜXÖÈ�\™Ù]—KˆÛÝ\˜ÙO\ÜXÖÈœÛÝ\˜ÙH—Kˆ\OH˜š[™‹ˆ™XYÛÛ›O\ÜXË™Ù]
-œ™XYÛÛ›H‹˜[ÙJKˆ
-Bˆ
-B‚ˆÙÙÙ\‹™X�Yʈ�Ü™X][™ÈØ[™›ÞÛÛ�Z[™\Žˆ[XYÙOI\ÈØ\ÏI\È^ÜÙYÜÜ�ÏI\È‹ˆ[XYÙKˆØ\ØYˆ\Ý
-^ÜÙYÜÜ�ÊKˆ
-BˆÛÛ�Z[™\ˆHÙ[‹™ØÚÙ\—ØÛY[�˜ÛÛ�Z[™\œË˜Ü™X]J
-Š˜Ü™X]WÚÝØ\™ÜÊBˆÙÙÙ\‹š[™›Êˆ”Ø[™›ÞÛÛ�Z[™\ˆÜ™X]YˆYI\È[XYÙOI\È‹ˆÛÛ�Z[™\‹œÚÜ�ÚYYˆ\Ø]ŠÛÛ�Z[™\‹œÚÜ�ÚYŠH[ÙH�È‹ˆ[XYÙKˆ
-Bˆ™]\›ˆÛÛ�Z[™\‚‚ˆ\Þ[˜ÈYˆÜ™X]JÙ[‹
-ŠšÝØ\™ÜΈ[žJHOˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÙ\ÜÚ[ÛˆH]ØZ]Ý\\Š
-K˜Ü™X]J
-ŠšÝØ\™ÜÊBˆ™]ÛÜšÈHÜØ[™›ÞÛ™]ÛÜšÊ
-Bˆ[›™\ˆHÙ\ÜÚ[Û‹—Ú[›™\‚ˆYˆ™]ÛÜšÈ[™\Ú[œÝ[˜ÙJ[›™\‹ØÚÙ\”Ø[™›ÞÙ\ÜÚ[ÛŠN‚ˆ[›™\‹—רÛ\Ü××ÈHÝš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Û‚ˆØ\Ý
-”Ýš^ØÚÙ\”Ø[™›ÞÙ\ÜÚ[Ûˆ‹[›™\ŠKœØ[™›ÞÛ™]ÛÜšÈH™]Ûܚˆ™]\›ˆÙ\ÜÚ[Û‚‚ˆ\Þ[˜ÈYˆ[]JÙ[‹Ù\ÜÚ[ÛŽˆØ[™›ÞÙ\ÜÚ[ÛŠHOˆØ[™›ÞÙ\ÜÚ[ÛŽ‚ˆÛÛ�Z[™\—ÚYHÙ]]ŠÙ]]ŠÙ\ÜÚ[Û‹—Ú[›™\‹œÝ]H‹›Û™JK˜ÛÛ�Z[™\—ÚY‹›Û™JBˆYˆÛÛ�Z[™\—ÚY‚ˆÈ™\ÝYY™›Ü�Ú[ˆ›Ý›Ý[™ÐTQ\œ›ÜˆÛÝ™\ˆHÛÛ™H܈[š\BˆÈÛÛ�Z[™\‹ˆ™\]Y\Ý^Ù\[ÛˆÛÝ™\œÈHÜ›‹YÝÛˆY[[ÛˆÛØÚÙ]8 %ˆÈÛÛ�Z[™\œË™Ù]
+_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
-HOˆ[œÜXÝØÛÛ�Z[™\ˆ˜Z\Ù\È™\]Y\ÝɈÈÛÛ›™XÝ[Û‘\œ›Ü‹ÚXÚ\ÈHÚX›[™ÈÙˆØÚÙ\‹™\œ›ÜœË�TQ\œ›Ü‚ˆÈ[™\ˆ™\]Y\ÝË”™\]Y\Ý^Ù\[Ûˆ
-›ÝHÝX˜Û\ÜÊKÛÈ]\ØØ\\ˆÈ[ˆTQ\œ›Ü‹[Û›HÝ\™\ÜÈ[™Ý\™˜XÙ\ÈH�[˜XÙX˜XÚÈ]™[‚ˆÈÝYÚ\ÈX\™ÝÛˆ\ÈYX[�È™H™\ÝYY™›Ü�‚ˆÚ]ÛÛ�^X‹œÝ\™\ÜʈØÚÙ\—Ù\œ›ÜœË“›Ý›Ý[™ØÚÙ\—Ù\œ›ÜœË�TQ\œ›Ü‹™\]Y\Ý^Ù\[Û‚ˆ
-N‚ˆÙ[‹™ØÚÙ\—ØÛY[�˜ÛÛ�Z[™\œË™Ù]
-ÛÛ�Z[™\—ÚY
-KšÚ[
-Bˆ™]\›ˆ]ØZ]Ý\\Š
-K™[]JÙ\ÜÚ[ÛŠB
\ No newline at end of file
+def _ensure_image_available(
+ client: Any,
+ image: str,
+ pull_policy: Literal["auto", "never"],
+) -> None:
+ if client.image_exists(image):
+ return
+ if pull_policy == "never":
+ raise RuntimeError(
+ f"Docker image {image!r} is not available locally and "
+ "STRIX_IMAGE_PULL_POLICY=never forbids pulling it"
+ )
+ repo, tag = parse_repository_tag(image)
+ client.docker_client.images.pull(repo, tag=tag or None, all_tags=False)
+
+
+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
+ image_pull_policy: Literal["auto", "never"] = "auto"
+
+ 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).
+ _ensure_image_available(self, image, self.image_pull_policy)
+
+ 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)
From 46bbdcacb01f59c49944f9401c73a22dd53d6f53 Mon Sep 17 00:00:00 2001
From: maxi-maxima <240684166@qq.com>
Date: Wed, 12 Aug 2026 10:26:01 +0800
Subject: [PATCH 3/3] Restore exact upstream file contents
---
docs/advanced/configuration.mdx | 193 +++++++++++++++++++--
strix/config/settings.py | 220 +++++++++++++++++-------
strix/interface/environment.py | 2 +-
strix/runtime/backends.py | 132 +++++++++++----
strix/runtime/docker_client.py | 2 +-
tests/test_config_loader.py | 285 +++++++++++++++++++++++++-------
tests/test_image_pull_policy.py | 2 +-
7 files changed, 665 insertions(+), 171 deletions(-)
diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index 5148d616..59d0bcaf 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -1,17 +1,176 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëKKKB�]Nˆ�ÛÛ™šYÝ\˜][Ûˆ‚™\ØÜš\[ÛŽˆ‘[�š\›Û›Y[�˜\šXX›\ț܈ݚ^‚‹KKB‚�ÛÛ™šYÝ\™HÝš^\Ú[™È[�š\›Û›Y[�˜\šXX›\È܈HÛÛ™šYÈš[K‚‚ˆÈÈHÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÓHˆ\OHœÝš[™Èˆ™\]Z\™Y‚ˆ[Ù[˜[YH[ˆ]SH›Ü›X]
-K™Ë‹Ü[˜ZKÙÜMK�[�›ÜXËØÛ]YK\ÛÛ›™]MM˜
-K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H›Üˆ[Ý\ˆH›ÝšY\‹ˆ›Ý™\]Z\™Y›ÜˆØØ[[Ù[È܈ÛÝY›ÝšY\ˆ]]
-™\�^RKUÔÈ™Y›ØÚÊK‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÐTWÐ�TÑHˆ\OHœÝš[™È�‚ˆÝ\ÝÛHTH˜\ÙHT“ˆ[ÛÈXØÙ\ÈÔS�RWÐTWÐ�TÑXUSWÐ�TÑWÕT“܈ÓSPWÐTWÐ�TÑX‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÑV�WÒPQT”Ȉ\OHœÝš[™È�‚ˆ^˜HXY\œÈÙ[�Ûˆ]™\žHH™\]Y\Ý\ÈH”Óӈؚ™XÝ
-K™Ë‚ˆÈ–Q™X]\™KRÙ^HŽˆ�˜[YH‹–U[˜[�Žˆ˜XÛYHŸX
-Kˆ\ÙY�[›ÜˆÜ[�RKXÛÛ\]X›BˆØ]]Ø^\È]™\]Z\™H]šX�][ۈ܈›Ý][™ÈXY\œÈ[ˆY][ÛˆÈH™X\™\‚ˆÚÙ[‹ˆH™X\™\ˆÚÙ[ˆ]Ù[ˆÝ[ÛÛY\Èœ›ÛHWÐTWÒÑVXˆ\Y\Èț݈H]SH[™˜]]™HÜ[�RH›Ý][™È]Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H“WÕSQSÕUˆY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆ™\]Y\Ý[Y[Ý][ˆÙXÛۙț܈HØ[Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÓWÓPVÔ‘U’QTȈY˜][H�Hˆ\OHš[�YÙ\ˆ�‚ˆX^[][H�[X™\ˆÙˆ™]šY\ț܈HTHØ[ÈÛˆ˜[œÚY[�˜Z[\™\Ë‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆY˜][HšYÚˆ\OHœÝš[™È�‚ˆÛÛ�›Û[šÚ[™ÈY™›Ü�›Üˆ™X\ÛÛš[™È[Ù[ˈ˜[Y˜[Y\Έ›Û™XZ[š[X[ÝØYY][XYÚYÚX^ˆY˜][ÈÈYY][X›Üˆ]ZXÚÈØØ[ˆ[ÙK‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÓQSSÔ–WÐÓÓT‘TÔÓÔ—ÕSQSÕUˆY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆ[Y[Ý][ˆÙXÛۙț܈Y[[ÜžHÛÛ\™\ÜÚ[ÛˆÜ\˜][ÛœÈ
-ÛÛ�^Ý[[X\š^˜][ÛŠK‚�Ô\˜[QšY[‚‚ˆÈÈÈYXØ]YY\XØ][Ûˆ[Ù[‚‘š[™[™ÈY\XØ][Ûˆ\ÈHÚX\Ý�XÝ\™YÛ\ÜÚYšXØ][Ûˆ\ÚˈžHY˜][]œ�[œÈÛˆHXZ[ˆ[Ù[�][ÝHØ[ˆ›Ý]H]ÈHÛX[\‹ØÚX\\ˆ[Ù[Ú]Ý]˜Y™™XÝ[™ÈHYÙ[�È]ÈHXÝX[\Ý[™Ë‚‚�\˜[QšY[]H”Õ’VÑQTWÓSÑSˆ\OHœÝš[™È�‚ˆ[Ù[\ÙYÈ�YÙHÚ]\ˆHØ[™Y]Hš[™[™È\XØ]\È[ˆ^\Ý[™È™\Ü�‚ˆ˜[ȘXÚÈÈÕ’VÓXÚ[ˆ[œÙ]‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÐTWÒÑVHˆ\OHœÝš[™È�‚ˆÜ[Û˜[›ÝšY\ˆÙ^H›ÜˆHY\XØ][Ûˆ[Ù[‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÐTWÐ�TÑHˆ\OHœÝš[™È�‚ˆÜ[Û˜[Ý\ÝÛHTH˜\ÙHT“›ÜˆHY\XØ][Ûˆ[Ù[ˆ\ÙHÚ[ˆHY\Bˆ[Ù[�[œÈÛˆHY™™\™[�[™Ú[�[ˆHXZ[ˆ[Ù[‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘QTWÓWÑV�WÒPQT”Ȉ\OHœÝš[™È�‚ˆÜ[Û˜[”Óӈؚ™XÝÙˆ^˜HXY\œÈÙ[�Ûˆ]™\žHY\XØ][Û‹[[Ù[ˆ™\]Y\ÝK™ËˆÈ–Q™X]\™KRÙ^HŽˆ�˜[YHŸXˆHYXØ]YY\H[Ù[™]™\‚ˆ[š\š]ÈWÑV�WÒPQT”ØÈÙ]\ÈÚ[ˆ]È[™Ú[�™YYÈÝ\ÝÛHXY\œË‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÑQTWÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ˆ\OHœÝš[™È�‚ˆ™X\ÛÛš[™ÈY™›Ü�›ÜˆHY\XØ][Ûˆ[Ù[ˆY˜][ÈÈH[Ù[ ÜÈÝÛ‚ˆ˜\Ù[[™HÚ[ˆ[œÙ]‚�Ô\˜[QšY[‚‚ˆÈÈÜ[Û˜[™X]\™\‚�\˜[QšY[]H”T”VUWÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H›Üˆ\œ^]HRKˆ[˜X›\È™X[][YHÙXˆÙX\˜Ú\š[™ÈØØ[œÈ›ÜˆÔÒS•[™�[™\˜Xš[]H™\ÙX\˜Ú‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”ÔÕPS—ÐTWÒÑVHˆ\OHœÝš[™È�‚ˆÜÝX[ˆTHÙ^H
-PRËx )˜
-Kˆ[˜X›\È™]Ú[™ÈÜÝX[ˆÛÛXÝ[ۜȞHY\ÈH\™Ù]
-ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY˜
-K[™ÜÝX[ˆ[�š\›Û›Y[�È
-ÜÝX[Ž‹ËÏÛÛXÝ[Û‹]ZY�Ù[��O[�š\›Û›Y[�]ZY˜
-HÈ™\ÛÛ™HÛÛXÝ[Ûˆ˜\šXX›\ˈ›Ý™YYYÚ[ˆ\ÜÚ[™ÈHØØ[ÛÛXÝ[Ûˆ^Ü�š[K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÕSSQU–HˆY˜][HŒHˆ\OHœÝš[™È�‚ˆ[[Y]žHÙÙÛKˆÙ]Ș[ÙX›ØÜˆÙ™˜È\ØX›H[[Y]žH
-ÜÝÙËØØ\™‹ÕS
-K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÐ�TÑWÕT“ˆ\OHœÝš[™È�‚ˆÕÕ˜XÙ[Ûܘ\ÙHT“›Üˆ™[[ÝHÜ[•[[Y]žH^Ü�ˆYˆ[œÙ]Ýš^ÙY\ȘXÙ\ÈØØ[Û›K‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÐTWÒÑVHˆ\OHœÝš[™È�‚ˆTHÙ^H\ÙY›Üˆ™[[ÝH˜XÙH^Ü�ˆ™[[ÝH^Ü�\È[˜X›YÛ›HÚ[ˆ›Ý�PÑSÓÔÐ�TÑWÕT“[™�PÑSÓÔÐTWÒÑVX\™HÙ]‚�Ô\˜[QšY[‚‚�\˜[QšY[]H•�PÑSÓÔÒPQT”Ȉ\OHœÝš[™È�‚ˆÜ[Û˜[Ý\ÝÛHÕSXY\œÈ
-”Óӈؚ™XÝ܈Ù^O]˜[YKÙ^L�]˜[YL˜
-Kˆ\ÙY�[›Üˆ[™Ù�\ÙH܈Ý\ÝÛKÜÙ[‹ZÜÝYÕØ]]Ø^\Ë‚�Ô\˜[QšY[‚‚•Ú[ˆ™[[ÝHÕS˜\œÈ\™H›ÝÙ]Ýš^Ý[Üš]\ÈÛÛ\]H�[ˆ[[Y]žHØØ[H΂‚˜˜\ڜݚ^Ü�[œËÏ�[—Û˜[YO‹Ù]™[�ËšœÛÛ›˜‚•Ú[ˆ™[[ÝH˜\œÈ\™HÙ]Ýš^X[]Üš]\È[[Y]žHÈ›ÝØØ[”ÓÓ“[™H™[[ÝHÕS[™Ú[�‚‚ˆÈÈØÚÙ\ˆÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÒSPQÑHˆY˜][H™ÚÜ‹š[ËÝ\Ù\Ýš^ÜÝš^\Ø[™›ÞŒKŒËŒˆ\OHœÝš[™È�‚ˆØÚÙ\ˆ[XYÙHÈ\ÙH›ÜˆHØ[™›ÞÛÛ�Z[™\‹‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÒSPQÑWÔSÔÓPÖHˆY˜][H˜]]Ȉ\OHœÝš[™È�‚ˆÛÛ�›ÛÈØ[™›Þ[XYÙHXÜ]Z\Ú][Û‹ˆ\ÙH™]™\˜È™\]Z\™HH™[ØYY[XYÙBˆ[™˜Z[Ú]Ý][[™ÈÚ[ˆ]\È[˜]˜Z[X›NÈ]]Ø™\Ù\�™\ÈHY˜][ˆš\œÝ\�[ˆ[™Z]š[Ü‹ˆ\ÈÙ\țݙ\ÝšXÝ™]ÛÜšÈXØÙ\ÜÈœ›ÛHH�[›š[™ÂˆØ[™›Þ‚�Ô\˜[QšY[‚‚�\˜[QšY[]H‘ÐÒÑT—ÒÔÕˆ\OHœÝš[™È�‚ˆØÚÙ\ˆY[[ÛˆÛØÚÙ]]ˆ\ÙH›Üˆ™[[ÝHØÚÙ\ˆÜÝÈ܈Ý\ÝÛHÛÛ™šYÝ\˜][ۜ˂�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔ•S•SQWÐ�PÒÑS‘ˆY˜][H™ØÚÙ\ˆˆ\OHœÝš[™È�‚ˆ�[�[YH˜XÚÙ[™›ÜˆHØ[™›Þ[�š\›Û›Y[�‚�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÐÐRQ×ГÓÕÕÐRUÔȈY˜][HŒÌˆ\OHš[�YÙ\ˆ�‚ˆX^[][H[YH[ˆÙXÛÛ™ÈÈØZ]›ÜˆH[‹\Ø[™›ÞØZYÈ›ÞHÈ™XÛÛYH™XYK‚ˆ[˜Ü™X\ÙH\ÈÛˆÛÝÈ܈X]š[HØYYÛÛ�Z[™\ˆÜÝË‚�Ô\˜[QšY[‚‚ˆÈÈØ[™›ÞÛÛ™šYÝ\˜][Û‚‚�\˜[QšY[]H”Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUˆY˜][HŒLŒˆ\OHš[�YÙ\ˆ�‚ˆX^[][H^XÝ][Ûˆ[YH[ˆÙXÛۙț܈Ø[™›ÞÜ\˜][ۜ˂�Ô\˜[QšY[‚‚�\˜[QšY[]H”Õ’VÔÐS‘“ÖÐÓÓ“‘PÕÕSQSÕUˆY˜][HŒLˆ\OHš[�YÙ\ˆ�‚ˆ[Y[Ý][ˆÙXÛۙț܈ÛÛ›™XÝ[™ÈÈHØ[™›ÞÛÛ�Z[™\‹‚�Ô\˜[QšY[‚‚ˆÈÈÛÛ™šYÈš[B‚”Ýš^ÝÜ™\ÈÛÛ™šYÝ\˜][Ûˆ[ˆ‹ËœÝš^ØÛKXÛÛ™šYËšœÛÛ˜ˆ[ÝHØ[ˆ[ÛÈÜXÚYžHHÝ\ÝÛHÛÛ™šYÈš[N‚‚˜˜\ڜݚ^K]\™Ù]‹Ø\KXÛÛ™šYÈÜ]ÝËØÛÛ™šYËšœÛÛ‚˜‚ŠŠ�ÛÛ™šYÈš[H›Ü›X]ŠŠ‚‚˜œÛÛ‚žÂˆ™[�ˆŽˆÂˆ”Õ’VÓHŽˆ›Ü[˜ZKÙÜMK�‹ˆ“WÐTWÒÑVHŽˆœÚËK‹‹ˆ‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ŽˆšYÚ‚ˆBŸB˜‚ˆÈÈ^[\HÙ]\‚˜˜\ڈș\]Z\™Y™^Ü�Õ’VÓOH›Ü[˜ZKÙÜMK�‚™^Ü�WÐTWÒÑVOHœÚËK‹‹ˆ‚‚ˆÈÜ[Û˜[ˆ[˜X›HÙXˆÙX\˜Ú™^Ü�T”VUWÐTWÒÑVOHœK‹‹ˆ‚‚ˆÈÜ[Û˜[ˆÝ\ÝÛH[Y[Ý]™^Ü�WÕSQSÕUH�Œ‚™^Ü�Õ’VÔÐS‘“ÖÑVPÕUSÓ—ÕSQSÕUHŒÌ‚‚˜
\ No newline at end of file
+---
+title: "Configuration"
+description: "Environment variables for Strix"
+---
+
+Configure Strix using environment variables or a config file.
+
+## LLM Configuration
+
+
+ Model name in LiteLLM format (e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4-6`).
+
+
+
+ API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).
+
+
+
+ Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
+
+
+
+ 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.
+
+
+
+ Request timeout in seconds for LLM calls.
+
+
+
+ Maximum number of retries for LLM API calls on transient failures.
+
+
+
+ Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium` for quick scan mode.
+
+
+
+ Timeout in seconds for memory compression operations (context summarization).
+
+
+### 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.
+
+
+ Model used to judge whether a candidate finding duplicates an existing report.
+ Falls back to `STRIX_LLM` when unset.
+
+
+
+ Optional provider key for the deduplication model.
+
+
+
+ Optional custom API base URL for the deduplication model. Use when the dedupe
+ model runs on a different endpoint than the main model.
+
+
+
+ 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.
+
+
+
+ Reasoning effort for the deduplication model. Defaults to the model's own
+ baseline when unset.
+
+
+## Optional Features
+
+
+ API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
+
+
+
+ Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://`), and Postman environments (`postman://?env=`) to resolve collection variables. Not needed when passing a local collection export file.
+
+
+
+ Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
+
+
+
+ OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
+
+
+
+ API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.
+
+
+
+ Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.
+
+
+When remote OTEL vars are not set, Strix still writes complete run telemetry locally to:
+
+```bash
+strix_runs//events.jsonl
+```
+
+When remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.
+
+## Docker Configuration
+
+
+ Docker image to use for the sandbox container.
+
+
+
+ Controls sandbox image acquisition. Use `never` to require a preloaded image
+ and fail without pulling when it is unavailable; `auto` preserves the default
+ first-run pull behavior. This does not restrict network access from a running
+ sandbox.
+
+
+
+ Docker daemon socket path. Use for remote Docker hosts or custom configurations.
+
+
+
+ Runtime backend for the sandbox environment.
+
+
+## Sandbox Configuration
+
+
+ Maximum execution time in seconds for sandbox operations.
+
+
+
+ Timeout in seconds for connecting to the sandbox container.
+
+
+## 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"
+
+```
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 78bcec9d..321f2a26 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -1,60 +1,160 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ýš^\XØ][ÛˆÙ][™ÜÈ8 %Y[�XË\Ù][™ÜÈÝÙ\™Yˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚™œ›ÛH\[™È[\Ü�]\˜[‚™œ›ÛHY[�XÈ[\Ü�[X\ÐÚÚXÙ\ËšY[™œ›ÛHY[�X×ÜÙ][™ÜÈ[\Ü�˜\ÙTÙ][™ÜËÙ][™ÜÐÛÛ™šYÑXÝ‚‚”™X\ÛÛš[™ÑY™›Ü�H]\˜[È››Û™H‹›Z[š[X[‹›ÝÈ‹›YY][H‹šYÚ‹žYÚ‹›X^—B‚‘Q�USÓPVÕT“”ÈH
L‚—Ð�TÑWÐÓÓ‘’QÈHÙ][™ÜÐÛÛ™šYÑXÝ
-ˆØ\ÙWÜÙ[œÚ]]™OQ˜[ÙKˆÜ[]WØžWÛ˜[YOU�YKˆ^˜OHšYۛܙH‹ŠB‚‚˜Û\ÜÈTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[Ù[ˆÝˆ›Û™HHšY[
-Y˜][S›Û™K[X\ÏH”Õ’VÓHŠBˆ\WÚÙ^NˆÝˆ›Û™HHšY[
-ˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\Ê“WÐTWÒÑVH‹“ÔS�RWÐTWÒÑVHŠKˆ™\�Q˜[ÙKˆ
-Bˆ\Wؘ\ÙNˆÝˆ›Û™HHšY[
-ˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\ʈ“WÐTWÐ�TÑH‹ˆ“ÔS�RWÐTWÐ�TÑH‹ˆ“ÔS�RWÐ�TÑWÕT“‹ˆ“USWÐ�TÑWÕT“‹ˆ“ÓSPWÐTWÐ�TÑH‹ˆ
-Kˆ
-Bˆ^˜WÚXY\œÎˆXÝÜ݋ݗH›Û™HHšY[
-ˆY˜][S›Û™Kˆ[X\ÏH“WÑV�WÒPQT”È‹ˆ™\�Q˜[ÙKˆ
-Bˆ™X\ÛÛš[™×ÙY™›Ü�ˆ™X\ÛÛš[™ÑY™›Ü�HšY[
-Y˜][HšYÚ‹[X\ÏH”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•ŠBˆ›Ü˜ÙWÜ™\]Z\™YÝÛÛØÚÚXÙNˆ›ÛÛHšY[
-ˆY˜][Q˜[ÙKˆ[X\ÏH”Õ’VÑ“Ô�ÑWÔ‘TURT‘QÕÓÓÐÒÒPÑH‹ˆ
-Bˆ›Û\ØØXÚNˆ›ÛÛHšY[
-ˆY˜][U�YKˆ[X\ÏH”Õ’VÔ“ÓTÐÐPÒH‹ˆ
-Bˆ\ØX›WÜÝ™X[Z[™Îˆ›ÛÛHšY[
-ˆY˜][Q˜[ÙKˆ[X\ÏH“WÑTÐP“WÔÕ‘PSRS‘È‹ˆ
-Bˆ[Y[Ý]ˆ[�HšY[
-Y˜][LÌ[X\ÏH“WÕSQSÕUŠBˆÝ™X[WÚYWÝ[Y[Ý]ˆ[�HšY[
-Y˜][LÌÙOL[X\ÏH“WÔÕ‘PSWÒQWÕSQSÕUŠBˆX^ÝÛÛØØ[×Ü\—Ý\›Žˆ[�HšY[
-ˆY˜][LÌ‹ˆÙOLˆ[X\ÏH“WÓPVÕÓÓÐÐS×ÔT—ÕT“ˆ‹ˆ
-B‚‚˜Û\ÜÈY\TÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[Ù[ˆÝˆ›Û™HHšY[
-Y˜][S›Û™K[X\ÏH”Õ’VÑQTWÓSÑSŠBˆ™X\ÛÛš[™×ÙY™›Ü�ˆ™X\ÛÛš[™ÑY™›Ü�›Û™HHšY[
-ˆY˜][S›Û™Kˆ[X\ÏH”Õ’VÑQTWÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹ˆ
-Bˆ\WÚÙ^NˆÝˆ›Û™HHšY[
-Y˜][S›Û™K[X\ÏH‘QTWÓWÐTWÒÑVH‹™\�Q˜[ÙJBˆ\Wؘ\ÙNˆÝˆ›Û™HHšY[
-Y˜][S›Û™K[X\ÏH‘QTWÓWÐTWÐ�TÑHŠBˆ^˜WÚXY\œÎˆXÝÜ݋ݗH›Û™HHšY[
-ˆY˜][S›Û™Kˆ[X\ÏH‘QTWÓWÑV�WÒPQT”È‹ˆ™\�Q˜[ÙKˆ
-B‚‚˜Û\ÜÈÛÛ�^Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆˆˆ�ÛÛ�^]Ú[™ÝÈX[˜YÙ[Y[�ˆ\‹]ÛÛ[Ý]]Ø\È[™\ÝÜžHÛÛ\XÝ[Û‹ˆˆˆ‚‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ]]רÛÛ\X݈›ÛÛHšY[
-Y˜][U�YK[X\ÏH”Õ’VÐÓÓ•VÐUU×ÐÓÓTPÕŠBˆÛÛ\XÝØ�Y™™\—ÝÚÙ[œÎˆ[�HšY[
-Y˜][LŒÌÝL[X\ÏH”Õ’VÐÓÓ•VЕQ‘‘T—ÕÒÑS”ÈŠBˆÙY\ÝÚÙ[œÎˆ[�HšY[
-Y˜][NÌÝL[X\ÏH”Õ’VÐÓÓ•VÒÑQTÕÒÑS”ÈŠBˆ˜[˜XÚרÛÛ�^ÝÚÙ[œÎˆ[�HšY[
-ˆY˜][LŒÌÝL[X\ÏH”Õ’VÐÓÓ•VÑ�S�PÒ×ÕÒÑS”È‚ˆ
-BˆÝ[[X\žWÛX^ÝÚÙ[œÎˆ[�HšY[
-Y˜][MÌM‹ÝL[X\ÏH”Õ’VÐÓÓ•VÔÕSSPT–WÕÒÑS”ÈŠBˆÛÛÛÝ]]ÛX^ÝÚÙ[œÎˆ[�HšY[
-Y˜][NÌÝL[X\ÏH”Õ’VÕÓÓÓÕUUÓPVÕÒÑS”ÈŠBˆÛÛÛÝ]]ÛX^Û[™\Έ[�HšY[
-Y˜][L—ÌÝL[X\ÏH”Õ’VÕÓÓÓÕUUÓPVÓS‘TÈŠBˆÈ›Û܈X›Ý™HH�[˜Ø][Û‹[›ÝXÙHÚ^™HÛÈH™]šY]È[Ø^\Èš]Ë‚ˆÛÛÛÝ]]ÛX^Øž]\Έ[�HšY[
-ˆY˜][ML
-ˆL�ÙOLL�[X\ÏH”Õ’VÕÓÓÓÕUUÓPVЖUTÈ‚ˆ
-B‚‚˜Û\ÜÈ�[�[YTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[XYÙNˆÝˆHšY[
-ˆY˜][H™ÚÜ‹š[ËÝ\Ù\Ýš^ÜÝš^\Ø[™›ÞŒKŒËŒ‹ˆ[X\ÏH”Õ’VÒSPQÑH‹ˆ
-Bˆ[XYÙWÜ[ÜÛXÞNˆ]\˜[Ș]]È‹›™]™\ˆ—HHšY[
-ˆY˜][H˜]]È‹ˆ[X\ÏH”Õ’VÒSPQÑWÔSÔÓPÖH‹ˆ
-Bˆ˜XÚÙ[™ˆÝˆHšY[
-Y˜][H™ØÚÙ\ˆ‹[X\ÏH”Õ’VÔ•S•SQWÐ�PÒÑS‘ŠBˆØZYר›ÛÝÝØZ]ÜΈ[�HšY[
-Y˜][LÌÝL[X\ÏH”Õ’VÐÐRQ×ГÓÕÕÐRUÔÈŠBˆÈX^ØÜ™Y[œÚÝÚ[XYÙHÛÛÝ]]ÈÙ\]™H\ˆYÙ[�ÛÛ�^
-H›Û™JK‚ˆX^ØÛÛ�^Ú[XYÙ\Έ[�HšY[
-Y˜][LËÙOL[X\ÏH”Õ’VÓPVÐÓÓ•VÒSPQÑTÈŠB‚‚˜Û\ÜÈ[[Y]žTÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ[˜X›Yˆ›ÛÛHšY[
-Y˜][U�YK[X\ÏH”Õ’VÕSSQU–HŠB‚‚˜Û\ÜÈ[�Yܘ][Û”Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆ\œ^]WØ\WÚÙ^NˆÝˆ›Û™HHšY[
-ˆY˜][S›Û™Kˆ[X\ÏH”T”VUWÐTWÒÑVH‹ˆ™\�Q˜[ÙKˆ
-BˆÜÝX[—Ø\WÚÙ^NˆÝˆ›Û™HHšY[
-ˆY˜][S›Û™Kˆ[X\ÏH”ÔÕPS—ÐTWÒÑVH‹ˆ™\�Q˜[ÙKˆ
-B‚‚˜Û\ÜÈšY]Ù\”Ù][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆÈ˜\ÙHT“ÙˆHÝš^™[^HHØØ[šY]Ù\ˆ›ÞY\Èț܈[XZ[ˆÈ™\šYšXØ][Ûˆ[™[˜Üž\Y™\Ü�[]™\žKˆHœ›ÝÜÙ\ˆ™]™\ˆ[ÜȈÈH™[^H\™XÝNÈHØØ[Ù\�™\ˆ\ÈHÛ›HØ[\‹‚ˆ\Ý\›ˆÝˆHšY[
-Y˜][HšÎ‹ËØ\œÝš^˜ZH‹[X\ÏH”Õ’VÐTÕT“ŠB‚‚˜Û\ÜÈÙ][™Üʘ\ÙTÙ][™ÜÊN‚ˆ[Ù[ØÛÛ™šYÈHÐ�TÑWÐÓÓ‘’Q‚ˆNˆTÙ][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOSTÙ][™ÜÊBˆY\NˆY\TÙ][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOQY\TÙ][™ÜÊBˆ�[�[YNˆ�[�[YTÙ][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOT�[�[YTÙ][™ÜÊBˆÛÛ�^ˆÛÛ�^Ù][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOPÛÛ�^Ù][™ÜÊBˆ[[Y]žNˆ[[Y]žTÙ][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOU[[Y]žTÙ][™ÜÊBˆ[�Yܘ][ۜΈ[�Yܘ][Û”Ù][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOR[�Yܘ][Û”Ù][™ÜÊBˆšY]Ù\ŽˆšY]Ù\”Ù][™ÜÈHšY[
-Y˜][Ù˜XÝÜžOUšY]Ù\”Ù][™ÜÊB
\ No newline at end of file
+"""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",
+ )
+ image_pull_policy: Literal["auto", "never"] = Field(
+ default="auto",
+ alias="STRIX_IMAGE_PULL_POLICY",
+ )
+ 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)
diff --git a/strix/interface/environment.py b/strix/interface/environment.py
index 8f6b3a03..208e103e 100644
--- a/strix/interface/environment.py
+++ b/strix/interface/environment.py
@@ -1,4 +1,4 @@
-r‡^Ñf¥–ئ{O,yÊ'vî¶›"""Startup environment validation and Docker image management."""
+"""Startup environment validation and Docker image management."""
import logging
import shutil
diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py
index 7bc9b9e2..d1e3c2d1 100644
--- a/strix/runtime/backends.py
+++ b/strix/runtime/backends.py
@@ -1,31 +1,107 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ”Ø[™›Þ˜XÚÙ[™™YÚ\ÝžH8 %Ù[XÝYšXHÕ’VÔ•S•SQWÐ�PÒÑS‘
-Y˜][ˆØÚÙ\ŠKˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�ÙÙÚ[™Â™œ›ÛHÛÛXÝ[ۜ˘X˜È[\Ü�]ØZ]X›KØ[X›B™œ›ÛH\[™È[\Ü�TWÐÒPÒÒS‘Ë[žB‚‚šYˆTWÐÒPÒÒS‘΂ˆœ›ÛHYÙ[�ËœØ[™›Þ›X[šY™\Ý[\Ü�X[šY™\Ý‚‚›ÙÙÙ\ˆHÙÙÚ[™Ë™Ù]ÙÙÙ\Š×Û˜[YW×ÊB‚‚”Ø[™›Þ˜XÚÙ[™HØ[X›VË‹‹‹]ØZ]X›VÝ\VÐ[žK[žWWWB‚‚˜\Þ[˜ÈYˆÙØÚÙ\—ؘXÚÙ[™
-ˆ
-‹ˆ[XYÙNˆÝ‹ˆX[šY™\݈X[šY™\݈^ÜÙYÜÜ�Έ\VÚ[�‹‹—Kˆš[™Û[Ý[�Έ\ÝÙXÝÜÝ‹[žWWH›Û™HH›Û™KŠHOˆ\VÐ[žK[žWN‚ˆˆˆ�œš[™È\HÙ\ÜÚ[Ûˆ˜XÚÙYžHHØØ[ØÚÙ\ˆY[[Û‹‚‚ˆ\Ù\ȘÛ\ÜΘݚ^ØÚÙ\”Ø[™›ÞÛY[�È[š™XÝ‘UÐQRSˆÂˆ‘UÔ�UÈØ\È
-ÈÜÝ™ØÚÙ\‹š[�\›˜[ÜÝYØ]]Ø^Kˆ[\Ü�ˆØÚÙ\˜^š[HÛÈ\Þ[Y[�È]\™Ù]H›Û‹QØÚÙ\‚ˆ˜XÚÙ[™Û‰Ý™YYHØÚÙ\‹\HXœ˜\žH[œÝ[Y‚‚ˆÙ\ÜÚ[Û‹œÝ\�
+"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
-X\ÈÚ]X]\šX[^™\ÈHX[šY™\Ý[�ÈH�[›š[™ÂˆÛÛ�Z[™\ˆ8 %HÑÉÜÈÛY[�˜Ü™X]J
-XÛ›H�Z[ÈH[›™\ˆÙ\ÜÚ[Û‚ˆØš™XÝÚ]Ý]\Z[™È]ˆ\Þ[˜ÈÚ]Ù\ÜÚ[ÛŽ˜ÛÝ[Ø[]ÛË�]ˆÝš^X[˜YÙ\ÈÙ\ÜÚ[ÛˆY™][YH^XÚ]HšXHÛY[�™[]J
-XÛÈÙBˆšYÙÙ\ˆÝ\�
+from __future__ import annotations
-XÝ\œÙ[™\Ë‚ˆˆˆ‚ˆ[\Ü�ØÚÙ\‚ˆœ›ÛHYÙ[�ËœØ[™›ÞœØ[™›Þ\Ë™ØÚÙ\ˆ[\Ü�ØÚÙ\”Ø[™›ÞÛY[�Ü[ۜ‚ˆœ›ÛHÝš^˜ÛÛ™šYË›ØY\ˆ[\Ü�ØYÜÙ][™Üˆœ›ÛHÝš^œ�[�[YK™ØÚÙ\—ØÛY[�[\Ü�Ýš^ØÚÙ\”Ø[™›ÞÛY[�‚ˆÛY[�HÝš^ØÚÙ\”Ø[™›ÞÛY[�
-ØÚÙ\‹™œ›ÛWÙ[�Š
-JBˆÛY[�œÝš^Øš[™Û[Ý[�ÈHš[™Û[Ý[�È܈×BˆÛY[�š[XYÙWÜ[ÜÛXÞHHØYÜÙ][™ÜÊ
-Kœ�[�[YKš[XYÙWÜ[ÜÛXÞBˆÜ[ÛœÈHØÚÙ\”Ø[™›ÞÛY[�Ü[ÛœÊ[XYÙOZ[XYÙK^ÜÙYÜÜ�ÏY^ÜÙYÜÜ�ÊBˆÙ\ÜÚ[ÛˆH]ØZ]ÛY[�˜Ü™X]JÜ[ÛœÏ[Ü[ÛœËX[šY™\Ý[X[šY™\Ý
-Bˆ]ØZ]Ù\ÜÚ[Û‹œÝ\�
+import logging
+from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING, Any
-Bˆ™]\›ˆÛY[�Ù\ÜÚ[Û‚‚‚—Ð�PÒÑS‘ΈXÝÜÝ‹Ø[™›Þ˜XÚÙ[™HHˆ™ØÚÙ\ˆŽˆÙØÚÙ\—ؘXÚÙ[™ŸB‚—Ð’S‘ÓSÕS•Ð�PÒÑS‘ΈÙ]ÜÝ—HHÈ™ØÚÙ\ˆŸB‚‚™YˆÙ]ؘXÚÙ[™
-˜[YNˆÝŠHOˆØ[™›Þ˜XÚÙ[™‚ˆˆˆ”™]\›ˆH˜XÚÙ[™˜XÝÜžH›Üˆ˜[YX܈˜Z\ÙK‚‚ˆ\™Ü΂ˆ˜[YNˆ˜XÚÙ[™Y[�YšY\ˆ
-K™Ëˆ™ØÚÙ\ˆ˜
-KˆX]Ú\È^X݈›È˜[˜XÚˈ[šÛ›ÝÛˆ˜[Y\ȘZ\ÙHÛÈÛÛ™šYÈ\ÜÈÝ\™˜XÙBˆ[[YYX][H[œÝXYÙˆÚ[[�HXÚÚ[™ÈHY˜][‚ˆˆˆ‚ˆ˜XÚÙ[™HÐ�PÒÑS‘Ë™Ù]
-˜[YJBˆYˆ˜XÚÙ[™\țۙN‚ˆÝ\Ü�YH‹‹š›Ú[ŠÛÜ�Y
-Ð�PÒÑS‘ÊJBˆ˜Z\ÙH˜[YQ\œ›ÜŠˆˆ•[šÛ›ÝÛˆÕ’VÔ•S•SQWÐ�PÒÑS‘ˆÛ˜[YH\ŸH
-Ý\Ü�YˆÜÝ\Ü�YJH‹ˆ
-BˆÙÙÙ\‹™X�YÊ”Ù[XÝYØ[™›Þ˜XÚÙ[™ˆ \È‹˜[YJBˆ™]\›ˆ˜XÚÙ[™‚‚™Yˆ™YÚ\Ý\—ؘXÚÙ[™
-ˆ˜[YNˆÝ‹ˆ˜XÚÙ[™ˆØ[™›Þ˜XÚÙ[™ˆ
-‹ˆÝ\Ü�רš[™Û[Ý[�Έ›ÛÛH˜[ÙKŠHOˆ›Û™N‚ˆˆˆ”™YÚ\Ý\ˆHÝ\ÝÛH˜XÚÙ[™[™\ˆ˜[YX‚‚ˆ[�[™Y›ÜˆÝۜݙX[H\Ù\œÈÚÈÚ\Z\ˆÝÛˆ�[�[YH8 %™YÚ\Ý\‚ˆ™Y›Ü™H[žHÙ\ÜÚ[Û—ÛX[˜YÙ\‹˜Ü™X]WÛÜ—Ü™]\ÙXØ[ˆ™K\™YÚ\Ý\š[™Âˆ[ˆ^\Ý[™È˜[YHÝ™\�Üš]\ÈHš[܈[�žKˆÝ\Ü�רš[™Û[Ý[�؈Y˜][ÈȘ[ÙNˆH™[[ÝH�[�[YHØ[››ÝÙYHHØ[\‰ÜÈš[\Þ\Ý[KÛˆ]\È[™YØØ[ÛÝ\˜Ù\È\ÈX[šY™\Ý[�šY\ÈÈ\ØY[œÝXY‚ˆˆˆ‚ˆÐ�PÒÑS‘ÖÛ˜[YWHH˜XÚÙ[™ˆYˆÝ\Ü�רš[™Û[Ý[�΂ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘˘Y
-˜[YJBˆ[ÙN‚ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘Ë™\ØØ\™
-˜[YJBˆÙÙÙ\‹š[™›Ê”™YÚ\Ý\™YØ[™›Þ˜XÚÙ[™ˆ \È
-š[™[Ý[�Έ \ÊH‹˜[YKÝ\Ü�רš[™Û[Ý[�ÊB‚‚™Yˆ˜XÚÙ[™ÜÝ\Ü�רš[™Û[Ý[�ʘ[YNˆÝŠHOˆ›ÛÛ‚ˆ™]\›ˆ˜[YH[ˆÐ’S‘ÓSÕS•Ð�PÒÑS‘‚‚™YˆÝ\Ü�YؘXÚÙ[™Ê
-HOˆ\ÝÜÝ—N‚ˆ™]\›ˆÛÜ�Y
-Ð�PÒÑS‘ÊB
\ No newline at end of file
+
+if TYPE_CHECKING:
+ from agents.sandbox.manifest import Manifest
+
+
+logger = logging.getLogger(__name__)
+
+
+SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
+
+
+async def _docker_backend(
+ *,
+ image: str,
+ manifest: Manifest,
+ exposed_ports: tuple[int, ...],
+ bind_mounts: list[dict[str, Any]] | None = None,
+) -> tuple[Any, Any]:
+ """Bring up a session backed by the local Docker daemon.
+
+ Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN /
+ NET_RAW caps + ``host.docker.internal`` host-gateway. Imports
+ ``docker`` lazily so deployments that target a non-Docker
+ backend don't need the docker-py library installed.
+
+ ``session.start()`` is what materializes the manifest into the running
+ container — the SDK's ``client.create()`` only builds the inner session
+ object without applying it. ``async with session:`` would call it too, but
+ Strix manages session lifetime explicitly via ``client.delete()`` so we
+ trigger ``start()`` ourselves.
+ """
+ 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()
+ return client, session
+
+
+_BACKENDS: dict[str, SandboxBackend] = {
+ "docker": _docker_backend,
+}
+
+_BIND_MOUNT_BACKENDS: set[str] = {"docker"}
+
+
+def get_backend(name: str) -> SandboxBackend:
+ """Return the backend factory for ``name`` or raise.
+
+ Args:
+ name: Backend identifier (e.g. ``"docker"``). Match is exact;
+ no fallback. Unknown values raise so config typos surface
+ immediately instead of silently picking a default.
+ """
+ backend = _BACKENDS.get(name)
+ if backend is None:
+ supported = ", ".join(sorted(_BACKENDS))
+ raise ValueError(
+ f"Unknown STRIX_RUNTIME_BACKEND: {name!r} (supported: {supported})",
+ )
+ logger.debug("Selected sandbox backend: %s", name)
+ return backend
+
+
+def register_backend(
+ name: str,
+ backend: SandboxBackend,
+ *,
+ supports_bind_mounts: bool = False,
+) -> None:
+ """Register a custom backend under ``name``.
+
+ Intended for downstream users who ship their own runtime — register
+ before any ``session_manager.create_or_reuse`` call. Re-registering
+ an existing name overwrites the prior entry. ``supports_bind_mounts``
+ defaults to False: a remote runtime cannot see the caller's filesystem, so
+ it is handed local sources as manifest entries to upload instead.
+ """
+ _BACKENDS[name] = backend
+ if supports_bind_mounts:
+ _BIND_MOUNT_BACKENDS.add(name)
+ else:
+ _BIND_MOUNT_BACKENDS.discard(name)
+ logger.info("Registered sandbox backend: %s (bind mounts: %s)", name, supports_bind_mounts)
+
+
+def backend_supports_bind_mounts(name: str) -> bool:
+ return name in _BIND_MOUNT_BACKENDS
+
+
+def supported_backends() -> list[str]:
+ return sorted(_BACKENDS)
diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py
index fc3a56e5..c1ff251c 100644
--- a/strix/runtime/docker_client.py
+++ b/strix/runtime/docker_client.py
@@ -1,4 +1,4 @@
-r‡^Ñf¥–ئ{O,yÊ'vî¶›"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
+"""StrixDockerSandboxClient — preserves the image's ENTRYPOINT and adds
NET_ADMIN/NET_RAW capabilities + host-gateway.
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index 6a3a3a84..7848592b 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -1,66 +1,225 @@
-r‡^Ñf¥–ئ{[r‰Ý°ë¦ëHˆˆ•\Ýț܈ݚ^˜ÛÛ™šYË›ØY\Žˆ”Óӈݙ\œšY\Ë[X\È™\ÛÛ][Û‹\œÚ\Ý[˜ÙKˆˆˆ‚‚™œ›ÛH×Ù�]\™W×È[\Ü�[››Ý][ۜ‚š[\Ü�œÛÛ‚™œ›ÛH\[™È[\Ü�TWÐÒPÒÒS‘‚š[\Ü�]\Ý™œ›ÛHY[�XÈ[\Ü�[X\ÐÚÚXÙ\ËšY[˜[Y][Û‘\œ›Ü‚™œ›ÛHY[�XË™šY[È[\Ü�šY[[™›Â‚™œ›ÛHÝš^˜ÛÛ™šYÈ[\Ü�ØY\‚™œ›ÛHÝš^˜ÛÛ™šYËœÙ][™ÜÈ[\Ü�ÛÛ�^Ù][™Ü‚‚šYˆTWÐÒPÒÒS‘΂ˆœ›ÛH]Xˆ[\Ü�]‚‚—ÓWÑS•—ÒÑVTÈHˆ”Õ’VÓH‹ˆ“WÐTWÒÑVH‹ˆ“ÔS�RWÐTWÒÑVH‹ˆ“WÐTWÐ�TÑH‹ˆ“ÔS�RWÐTWÐ�TÑH‹ˆ“ÔS�RWÐ�TÑWÕT“‹ˆ“USWÐ�TÑWÕT“‹ˆ“ÓSPWÐTWÐ�TÑH‹ˆ”Õ’VÔ‘PTÓÓ’S‘×ÑQ‘“Ô•‹ˆ”Õ’VÑ“Ô�ÑWÔ‘TURT‘QÕÓÓÐÒÒPÑH‹ˆ“WÕSQSÕU‹ˆ”T”VUWÐTWÒÑVH‹ˆÈ�[�[YTÙ][™Üˆ”Õ’VÒSPQÑH‹ˆ”Õ’VÒSPQÑWÔSÔÓPÖH‹ˆ”Õ’VÔ•S•SQWÐ�PÒÑS‘‹ˆ”Õ’VÐÐRQ×ГÓÕÕÐRUÔÈ‹ˆÈ[[Y]žTÙ][™Üˆ”Õ’VÕSSQU–H‹—B‚‚�]\Ý™š^\™J]]Ý\ÙOU�YJB™YˆÜ™\Ù]ÛØY\—ÜÝ]J[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
-HOˆ›Û™N‚ˆˆˆ”™\Ù][Ù[HÛØ˜[È[™ÛX\ˆÛ›ÝÛˆ[�ˆ˜\œÈ›Üˆ]\›Z[š\ÝXÈ�[œËˆˆˆ‚ˆ›ÜˆÙ^H[ˆÓWÑS•—ÒÑVT΂ˆ[ÛšÙ^\]Ú™[[�ŠÙ^K˜Z\Ú[™ÏQ˜[ÙJBˆ[ÛšÙ^\]ÚœÙ]]ŠØY\‹—ØØXÚY‹›Û™JBˆ[ÛšÙ^\]ÚœÙ]]ŠØY\‹—ÛÝ™\œšYH‹›Û™JB‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈÜ™XYÚœÛÛ—ÛÝ™\œšY\ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛZ\ÜÚ[™×Ùš[J\Ü]ˆ]
-HOˆ›Û™N‚ˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê\Ü]È››ÜKšœÛÛˆŠHOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\רÛÜœ�\ÚœÛÛŠ\Ü]ˆ]
-HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-žÛ›Ý˜[YœÛÛˆ‹[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Û›Û—ÙXÝÙ[�Š\Ü]ˆ]
-HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ››Ý‹˜H‹™XÝ—_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÛX\×Ý×Û™\ÝYÜÙ][™ÜÊ\Ü]ˆ]
-HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-ˆœÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ›^K[[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH�]‹N‹ˆ
-Bˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHˆ›HŽˆÈ›[Ù[Žˆ›^K[[Ù[ŸKˆš[�Yܘ][ۜȎˆÈœ\œ^]WØ\WÚÙ^HŽˆœÈŸKˆB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×ÜÚÚ\×ÚÙ^\ר[™XYWÚ[—Ù[�š\›ÛŠˆ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹™œ›ÛKY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆÈ[�ˆÚ[œÈOˆH”ÓÓˆ˜[YH\È›ÝÝ\™˜XÙY\È[ˆ[š]ÝØ\™Ë‚ˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[�—ÝÚ[œ×ØXÜ›ÜÜ×ÙšY[Ø[X\Ù\ʈ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆÈ\WÚÙ^H™\ÛÛ™\Èœ›ÛH[X\ÐÚÚXÙ\Ê“WÐTWÒÑVH‹“ÔS�RWÐTWÒÑVHŠKˆH[�‚ˆÈÙ]ÈÛ™H[X\ÈÚ[HH\œÚ\ÝYš[HÛÈ[›Ý\‹ˆ[�ˆ]\ÝÝ[Ú[‹ÛˆÈHÝ[Hš[H˜[YH]\ݛݙHÝ\™˜XÙY\È[ˆ[š]ÝØ\™È
-ÚXÚÝ]˜[šÜÈ[�ŠK‚ˆ[ÛšÙ^\]ÚœÙ][�Š“ÔS�RWÐTWÒÑVH‹œÚËY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ“WÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ù[�—ÝÚ[œ×ØØ\ÙWÚ[œÙ[œÚ]]™[Jˆ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]ÚŠHOˆ›Û™N‚ˆÈÙ][™ÜÈ\ÙHØ\ÙWÜÙ[œÚ]]™OQ˜[ÙKÛÈHÝÙ\˜Ø\ÙH[�ˆ˜\ˆ[ÛÈÛÝ[�È\ÈÙ]‚ˆ[ÛšÙ^\]ÚœÙ][�ŠœÝš^ÛH‹™œ›ÛKY[�ˆŠBˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™œ›ÛKYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHßB‚‚™Yˆ\ÝÜ™XYÚœÛÛ—ÛÝ™\œšY\×Ý\Ù\×ÚœÛÛ—ÝÚ[—Û›×Ø[X\×Ú[—Ù[�š\›ÛŠ\Ü]ˆ]
-HOˆ›Û™N‚ˆÈ›È[X\ÈÙˆ\WÚÙ^H\ÈÙ][ˆH[�š\›Û›Y[�OˆHš[H˜[YH\È\ÙY]™[‚ˆÈÚ[ˆ]\ÈÝÜ™Y[™\ˆH›Û‹Yš\œÝ[X\Ë‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ“ÔS�RWÐTWÒÑVHŽˆœÚËYš[HŸ_JK[˜ÛÙ[™ÏH�]‹NŠBˆ\ÜÙ\�ØY\‹—Ü™XYÚœÛÛ—ÛÝ™\œšY\Ê]
-HOHÈ›HŽˆÈ˜\WÚÙ^HŽˆœÚËYš[HŸ_B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\×Ü™Z™XÝ×ÜÝX—Û›ÝXÙWݘ[Y\Ê
-HOˆ›Û™N‚ˆÚ]]\Ýœ˜Z\Ù\ʘ[Y][Û‘\œ›ÜŠN‚ˆÛÛ�^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏM�
-B‚‚™Yˆ\ÝÝÛÛÛÝ]]ÛX^Øž]\רXØÙ\×Ù›ÛÜŠ
-HOˆ›Û™N‚ˆ\ÜÙ\�ÛÛ�^Ù][™ÜÊÕ’VÕÓÓÓÕUUÓPVЖUTÏLL�
-K�ÛÛÛÝ]]ÛX^Øž]\ÈOHL�‚‚™Yˆ\ÝÜ�[�[YWØØZYר›ÛÝÝØZ]Ùœ›ÛWÙ[�Š[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
-HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÐÐRQ×ГÓÕÕÐRUÔÈ‹ŽLŠB‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
-Kœ�[�[YK˜ØZYר›ÛÝÝØZ]ÜÈOHL‚‚™Yˆ\ÝÜ�[�[YWÚ[XYÙWÜ[ÜÛXÞWÙœ›ÛWÙ[�Š[ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
-HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÒSPQÑWÔSÔÓPÖH‹›™]™\ˆŠB‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
-Kœ�[�[YKš[XYÙWÜ[ÜÛXÞHOH›™]™\ˆ‚‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHÂˆÈØ[X\Ù\×ٛ܂ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÚ[\WØ[X\Ê
-HOˆ›Û™N‚ˆš[™›ÈHšY[[™›Ê[X\ÏH”ÒSTWÐSPTÈŠBˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ”ÒSTWÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗØ[X\רÚÚXÙ\Ê
-HOˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
-È\NˆYۛܙVØ\ÜÚYÛ›Y[�BˆY˜][S›Û™Kˆ˜[Y][Û—Ø[X\ÏP[X\ÐÚÚXÙ\Ê‘’T”Õ‹”ÑPÓÓ‘ŠKˆ
-Bˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHÈ‘’T”Õ‹”ÑPÓÓ‘—B‚‚™Yˆ\ÝØ[X\Ù\×ٛܗÜÝš[™×ݘ[Y][Û—Ø[X\Ê
-HOˆ›Û™N‚ˆš[™›ÎˆšY[[™›ÈHšY[
-Y˜][S›Û™K˜[Y][Û—Ø[X\ÏH”Õ—ÐSPTÈŠHÈ\NˆYۛܙVØ\ÜÚYÛ›Y[�Bˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊š[™›ÊHOHȔ՗ÐSPTÈ—B‚‚™Yˆ\ÝØ[X\Ù\×Ù›Ü—Û›×Ø[X\Ê
-HOˆ›Û™N‚ˆ\ÜÙ\�ØY\‹—Ø[X\Ù\×ٛ܊šY[[™›Ê
-JHOH×B‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\WØÛÛ™šY×ÛÝ™\œšYH
-ÈØYÜÙ][™ÜÈ›Ý[™]š\ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝØ\WÛÝ™\œšYWØ[™ÛØYÜÙ][™Ü×Ü›Ý[™Ýš\
-\Ü]ˆ]
-HOˆ›Û™N‚ˆ]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆ]�Üš]WÝ^
-ˆœÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆœ›Ý[™]š\[[Ù[‹”T”VUWÐTWÒÑVHŽˆœÈŸ_JKˆ[˜ÛÙ[™ÏH�]‹N‹ˆ
-B‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ]
-BˆÙ][™ÜÈHØY\‹›ØYÜÙ][™ÜÊ
-B‚ˆ\ÜÙ\�Ù][™ÜË›K›[Ù[OHœ›Ý[™]š\[[Ù[‚ˆ\ÜÙ\�Ù][™ÜËš[�Yܘ][ۜ˜\œ^]WØ\WÚÙ^HOHœÈ‚ˆÈÙXÛÛ™Ø[\ÈY[[Ú^™YOˆØ[YHØš™XÝ‚ˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
-H\ÈÙ][™Ü‚‚™Yˆ\ÝØ\WØÛÛ™šY×ÛÝ™\œšYWÚ[�˜[Y]\רØXÚJ\Ü]ˆ]
-HOˆ›Û™N‚ˆš\œÝH\Ü]È™š\œÝšœÛÛˆ‚ˆš\œÝ�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆ™š\œÝ[[Ù[Ÿ_JK[˜ÛÙ[™ÏH�]‹NŠBˆÙXÛÛ™H\Ü]ÈœÙXÛÛ™šœÛÛˆ‚ˆÙXÛÛ™�Üš]WÝ^
-œÛÛ‹™[\ÊÈ™[�ˆŽˆÈ”Õ’VÓHŽˆœÙXÛÛ™[[Ù[Ÿ_JK[˜ÛÙ[™ÏH�]‹NŠB‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJš\œÝ
-Bˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
-K›K›[Ù[OH™š\œÝ[[Ù[‚‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJÙXÛÛ™
-Bˆ\ÜÙ\�ØY\‹›ØYÜÙ][™ÜÊ
-K›K›[Ù[OHœÙXÛÛ™[[Ù[‚‚‚ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKHˆÈ\œÚ\ÝØÝ\œ™[�ˆÈKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKH‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[�ÝÜš]\×Ù[�—Ø›ØÚÊ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
-HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹œ\œÚ\ÝY[[Ù[ŠBˆ\™Ù]H\Ü]ÈœÝXˆˆÈ˜ÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
-B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[�
+"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
-B‚ˆ\ÜÙ\�\™Ù]™^\ÝÊ
-Bˆ\ÜÙ\�œÛÛ‹›ØYÊ\™Ù]œ™XYÝ^
-[˜ÛÙ[™ÏH�]‹NŠJHOHˆ™[�ˆŽˆÈ”Õ’VÓHŽˆœ\œÚ\ÝY[[Ù[ŸBˆB‚‚™Yˆ\ÝÜ\œÚ\ÝØÝ\œ™[�ÜÙ]×Ì
ŒÛ[ÙJ\Ü]ˆ][ÛšÙ^\]Úˆ]\Ý“[ÛšÙ^T]Ú
-HOˆ›Û™N‚ˆ[ÛšÙ^\]ÚœÙ][�ДՒVÓH‹œ\œÚ\ÝY[[Ù[ŠBˆ\™Ù]H\Ü]ȘÛKXÛÛ™šYËšœÛÛˆ‚ˆØY\‹˜\WØÛÛ™šY×ÛÝ™\œšYJ\™Ù]
-B‚ˆØY\‹œ\œÚ\ÝØÝ\œ™[�
+from __future__ import annotations
-B‚ˆ\ÜÙ\�\™Ù]œÝ]
+import json
+from typing import TYPE_CHECKING
-KœÝÛ[ÙH ˆÍÍÍÈOHÍŒ
\ No newline at end of file
+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_IMAGE_PULL_POLICY",
+ "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
+
+
+def test_runtime_image_pull_policy_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("STRIX_IMAGE_PULL_POLICY", "never")
+
+ assert loader.load_settings().runtime.image_pull_policy == "never"
+
+
+# --------------------------------------------------------------------------- #
+# _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
diff --git a/tests/test_image_pull_policy.py b/tests/test_image_pull_policy.py
index a9184833..3c46d46f 100644
--- a/tests/test_image_pull_policy.py
+++ b/tests/test_image_pull_policy.py
@@ -1,4 +1,4 @@
-r‡^Ñf¥–ئ{O,yÊ'vî¶›"""Regression tests for sandbox image acquisition policy."""
+"""Regression tests for sandbox image acquisition policy."""
from __future__ import annotations