mirror of
https://github.com/usestrix/strix.git
synced 2026-10-16 04:28:23 +00:00
Merge 46bbdcacb0 into 87f2d783d6
This commit is contained in:
commit
a48f31ad53
7 changed files with 132 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
85
tests/test_image_pull_policy.py
Normal file
85
tests/test_image_pull_policy.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue