diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index c245b364..1eff8268 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -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. + + 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 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. diff --git a/strix/config/settings.py b/strix/config/settings.py index 5aa6da06..0d29997e 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -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") diff --git a/strix/interface/environment.py b/strix/interface/environment.py index 522067df..2804d426 100644 --- a/strix/interface/environment.py +++ b/strix/interface/environment.py @@ -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}") diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index 0cb2218f..66ec514b 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -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() diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 969267d5..c1ff251c 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -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 diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index d083236a..3d5165bf 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/tests/test_image_pull_policy.py b/tests/test_image_pull_policy.py new file mode 100644 index 00000000..3c46d46f --- /dev/null +++ b/tests/test_image_pull_policy.py @@ -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()