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()