This commit is contained in:
maxi-maxima 2026-10-15 01:06:55 +08:00 • committed by GitHub
commit a48f31ad53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 132 additions and 5 deletions

View file

@ -149,6 +149,13 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
Docker image to use for the sandbox container.
</ParamField>
<ParamField path="STRIX_IMAGE_PULL_POLICY" default="auto" type="string">
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.
</ParamField>
<ParamField path="DOCKER_HOST" type="string">
Docker daemon address, for example `unix:///var/run/docker.sock` or `tcp://10.0.0.5:2375`. When not set, Strix connects to the same daemon as the `docker` CLI: the current docker context (`docker context use`), then the default socket. Docker Desktop, OrbStack and Colima register their sockets as contexts, so no extra configuration is needed for them.
</ParamField>

View file

@ -144,6 +144,10 @@ 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")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")

View file

@ -194,12 +194,19 @@ def pull_docker_image() -> None:
console = Console()
client = check_docker_connection()
image = load_settings().runtime.image
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}")

View file

@ -40,11 +40,13 @@ async def _docker_backend(
"""
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
from strix.config.loader import load_settings
from strix.runtime.docker_client import StrixDockerSandboxClient
from strix.runtime.docker_connection import connect_docker
client = StrixDockerSandboxClient(connect_docker())
client.strix_bind_mounts = bind_mounts or []
client.image_pull_policy = load_settings().runtime.image_pull_policy
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await client.create(options=options, manifest=manifest)
await session.start()

View file

@ -26,7 +26,7 @@ import contextlib
import logging
import os
import uuid
from typing import Any, cast
from typing import Any, Literal, cast
from agents.sandbox.errors import ExposedPortUnavailableError
from agents.sandbox.manifest import Manifest
@ -54,6 +54,22 @@ logger = logging.getLogger(__name__)
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
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
@ -165,6 +181,7 @@ 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,
@ -176,9 +193,7 @@ class StrixDockerSandboxClient(DockerSandboxClient):
) -> 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)
_ensure_image_available(self, image, self.image_pull_policy)
assert self.image_exists(image)
environment: dict[str, str] | None = None

View file

@ -34,6 +34,7 @@ _LLM_ENV_KEYS = [
"STRIX_WEB_SEARCH_PROVIDER",
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_IMAGE_PULL_POLICY",
"STRIX_RUNTIME_BACKEND",
# TelemetrySettings
"STRIX_TELEMETRY",
@ -142,6 +143,12 @@ 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
# --------------------------------------------------------------------------- #

View file

@ -0,0 +1,85 @@
"""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()