feat(sandbox): allow labels on Docker sandbox containers (#4564)
This commit is contained in:
@@ -179,7 +179,7 @@ def _default_run_state_validation_error(
|
||||
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
|
||||
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported
|
||||
# versions).
|
||||
CURRENT_SCHEMA_VERSION = "1.16"
|
||||
CURRENT_SCHEMA_VERSION = "1.17"
|
||||
_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13"
|
||||
_HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14"
|
||||
# Keep this mapping in chronological order. Every schema bump must add a one-line summary here.
|
||||
@@ -213,6 +213,7 @@ SCHEMA_VERSION_SUMMARIES: dict[str, str] = {
|
||||
"Persists Docker network-isolation state and lets an exact call approval decision "
|
||||
"override a sticky decision for the same tool."
|
||||
),
|
||||
"1.17": "Persists Docker container labels across sandbox resume and replacement.",
|
||||
}
|
||||
SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from docker.api.container import DEFAULT_DATA_CHUNK_SIZE # type: ignore[import-
|
||||
from docker.models.containers import Container # type: ignore[import-untyped]
|
||||
from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped]
|
||||
from docker.utils import parse_repository_tag
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from .._mount_security import (
|
||||
@@ -185,6 +185,7 @@ class DockerSandboxSessionState(SandboxSessionState):
|
||||
image: str
|
||||
container_id: str
|
||||
network_mode: Literal["none"] | None = None
|
||||
labels: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_network_configuration(self) -> Self:
|
||||
@@ -214,6 +215,7 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions):
|
||||
image: str
|
||||
exposed_ports: tuple[int, ...] = ()
|
||||
network_mode: Literal["none"] | None = None
|
||||
labels: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_network_configuration(self) -> Self:
|
||||
@@ -230,12 +232,14 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions):
|
||||
*,
|
||||
type: Literal["docker"] = "docker",
|
||||
network_mode: Literal["none"] | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
type=type,
|
||||
image=image,
|
||||
exposed_ports=exposed_ports,
|
||||
network_mode=network_mode,
|
||||
labels={} if labels is None else labels,
|
||||
)
|
||||
|
||||
|
||||
@@ -1535,6 +1539,7 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
exposed_ports=options.exposed_ports,
|
||||
network_mode=options.network_mode,
|
||||
session_id=session_id,
|
||||
labels=options.labels,
|
||||
)
|
||||
container.start()
|
||||
container_id = container.id
|
||||
@@ -1549,6 +1554,7 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
container_id=container_id,
|
||||
exposed_ports=options.exposed_ports,
|
||||
network_mode=options.network_mode,
|
||||
labels=options.labels,
|
||||
)
|
||||
inner = DockerSandboxSession(
|
||||
docker_client=self.docker_client,
|
||||
@@ -1653,6 +1659,7 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
container,
|
||||
state.network_mode,
|
||||
)
|
||||
_assert_existing_container_labels_match(container, state.labels)
|
||||
owns_replacement = container is None
|
||||
replacement_session_id = (
|
||||
uuid.uuid4()
|
||||
@@ -1681,6 +1688,7 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
exposed_ports=state.exposed_ports,
|
||||
network_mode=state.network_mode,
|
||||
session_id=replacement_session_id,
|
||||
labels=state.labels,
|
||||
)
|
||||
container_id = container.id
|
||||
assert container_id is not None
|
||||
@@ -1715,6 +1723,7 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
network_mode: Literal["none"] | None = None,
|
||||
session_id: uuid.UUID | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> Container:
|
||||
if manifest is not None:
|
||||
_validate_docker_path_grants(manifest)
|
||||
@@ -1736,6 +1745,8 @@ class DockerSandboxClient(BaseSandboxClient[DockerSandboxClientOptions]):
|
||||
}
|
||||
if network_mode is not None:
|
||||
create_kwargs["network_mode"] = network_mode
|
||||
if labels:
|
||||
create_kwargs["labels"] = labels
|
||||
if manifest is not None:
|
||||
docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id)
|
||||
if docker_mounts:
|
||||
@@ -1895,6 +1906,25 @@ def _assert_existing_container_network_configuration_matches(
|
||||
)
|
||||
|
||||
|
||||
def _assert_existing_container_labels_match(
|
||||
container: Container,
|
||||
labels: dict[str, str],
|
||||
) -> None:
|
||||
if not labels:
|
||||
return
|
||||
|
||||
container.reload()
|
||||
attrs = getattr(container, "attrs", {}) or {}
|
||||
config = attrs.get("Config")
|
||||
actual_labels = config.get("Labels") if isinstance(config, dict) else None
|
||||
actual_labels = actual_labels if isinstance(actual_labels, dict) else {}
|
||||
if any(actual_labels.get(key) != value for key, value in labels.items()):
|
||||
raise ValueError(
|
||||
"Existing Docker sandbox labels do not match persisted labels; "
|
||||
"create a fresh sandbox session"
|
||||
)
|
||||
|
||||
|
||||
def _assert_existing_container_path_grants_match(
|
||||
container: Container,
|
||||
manifest: Manifest,
|
||||
|
||||
Vendored
+2
-2
@@ -1,6 +1,6 @@
|
||||
# RunState compatibility corpus
|
||||
|
||||
The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.16. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.
|
||||
The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.17. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.
|
||||
|
||||
Regenerate the feature corpus from the recorded historical source trees with:
|
||||
|
||||
@@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/
|
||||
|
||||
The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout.
|
||||
|
||||
Versions 1.7, 1.8, and 1.16 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.
|
||||
Versions 1.7, 1.8, 1.16, and 1.17 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.
|
||||
|
||||
Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"$schemaVersion": "1.17",
|
||||
"auto_previous_response_id": false,
|
||||
"context": {
|
||||
"approvals": {},
|
||||
"context": {},
|
||||
"context_meta": {
|
||||
"omitted": false,
|
||||
"original_type": "mapping",
|
||||
"requires_deserializer": false,
|
||||
"serialized_via": "mapping"
|
||||
},
|
||||
"tool_invocations": {},
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": [
|
||||
{
|
||||
"cache_write_tokens": 0,
|
||||
"cached_tokens": 0
|
||||
}
|
||||
],
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": [
|
||||
{
|
||||
"reasoning_tokens": 0
|
||||
}
|
||||
],
|
||||
"request_usage_entries": [],
|
||||
"requests": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
},
|
||||
"conversation_id": null,
|
||||
"current_agent": {
|
||||
"name": "compat-agent"
|
||||
},
|
||||
"current_step": null,
|
||||
"current_turn": 0,
|
||||
"current_turn_persisted_item_count": 0,
|
||||
"generated_items": [],
|
||||
"generated_prompt_cache_key": null,
|
||||
"generated_session_item_indexes": [],
|
||||
"input_guardrail_results": [],
|
||||
"last_model_response": null,
|
||||
"last_processed_response": null,
|
||||
"max_turns": 10,
|
||||
"model_responses": [],
|
||||
"nested_history_owned_session_item_refs": [],
|
||||
"no_active_agent_run": true,
|
||||
"original_input": "historical input",
|
||||
"output_guardrail_results": [],
|
||||
"pending_input": [],
|
||||
"previous_response_id": null,
|
||||
"reasoning_item_id_policy": null,
|
||||
"sandbox": {
|
||||
"backend_id": "docker",
|
||||
"current_agent_name": "compat-agent",
|
||||
"session_state": {
|
||||
"container_id": "container",
|
||||
"exposed_ports": [],
|
||||
"image": "python:3.14-slim",
|
||||
"labels": {
|
||||
"com.example.owner": "worker-123"
|
||||
},
|
||||
"manifest": {
|
||||
"entries": {},
|
||||
"environment": {
|
||||
"value": {}
|
||||
},
|
||||
"extra_path_grants": [],
|
||||
"groups": [],
|
||||
"remote_mount_command_allowlist": [
|
||||
"ls",
|
||||
"find",
|
||||
"stat",
|
||||
"cat",
|
||||
"less",
|
||||
"head",
|
||||
"tail",
|
||||
"du",
|
||||
"grep",
|
||||
"rg",
|
||||
"wc",
|
||||
"sort",
|
||||
"cut",
|
||||
"cp",
|
||||
"tee",
|
||||
"echo",
|
||||
"mkdir",
|
||||
"rm"
|
||||
],
|
||||
"root": "/workspace",
|
||||
"users": [],
|
||||
"version": 1
|
||||
},
|
||||
"network_mode": null,
|
||||
"session_id": "00000000-0000-0000-0000-000000000117",
|
||||
"snapshot": {
|
||||
"id": "snapshot",
|
||||
"type": "noop"
|
||||
},
|
||||
"type": "docker",
|
||||
"workspace_root_ready": false
|
||||
}
|
||||
},
|
||||
"session_items": [],
|
||||
"tool_input_guardrail_results": [],
|
||||
"tool_output_guardrail_results": [],
|
||||
"tool_use_tracker": {},
|
||||
"trace": null
|
||||
}
|
||||
+47
@@ -426,6 +426,40 @@ state.reject(approval("exception-call"), rejection_message="Denied exactly")
|
||||
"changed to exercise the canonical compatibility branch."
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
"1.17",
|
||||
"2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
|
||||
"docker_labels",
|
||||
"""
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
|
||||
session_state = {
|
||||
"type": "docker",
|
||||
"session_id": "00000000-0000-0000-0000-000000000117",
|
||||
"snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"),
|
||||
"manifest": Manifest().model_dump(mode="json"),
|
||||
"exposed_ports": [],
|
||||
"workspace_root_ready": False,
|
||||
"image": "python:3.14-slim",
|
||||
"container_id": "container",
|
||||
"network_mode": None,
|
||||
"labels": {"com.example.owner": "worker-123"},
|
||||
}
|
||||
state._sandbox = {
|
||||
"backend_id": "docker",
|
||||
"current_agent_name": agent.name,
|
||||
"session_state": session_state,
|
||||
}
|
||||
""",
|
||||
provenance="canonical_compatibility",
|
||||
emitted_version="1.16",
|
||||
note=(
|
||||
"The labels implementation was first emitted with the unreleased 1.16 writer. "
|
||||
"The fixture changes only the schema label to exercise the 1.17 compatibility "
|
||||
"reader while preserving the Docker session payload."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -443,6 +477,19 @@ MINIMAL_SCENARIOS = (
|
||||
"changed to exercise the canonical compatibility branch."
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
"1.17",
|
||||
"2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
|
||||
"minimal",
|
||||
"",
|
||||
provenance="canonical_compatibility",
|
||||
emitted_version="1.16",
|
||||
note=(
|
||||
"The labels implementation was first emitted with the unreleased 1.16 writer. "
|
||||
"The fixture changes only the schema label to exercise the 1.17 compatibility "
|
||||
"reader while preserving older payload compatibility."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schemaVersion": "1.17",
|
||||
"auto_previous_response_id": false,
|
||||
"context": {
|
||||
"approvals": {},
|
||||
"context": {},
|
||||
"context_meta": {
|
||||
"omitted": false,
|
||||
"original_type": "mapping",
|
||||
"requires_deserializer": false,
|
||||
"serialized_via": "mapping"
|
||||
},
|
||||
"tool_invocations": {},
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": [
|
||||
{
|
||||
"cache_write_tokens": 0,
|
||||
"cached_tokens": 0
|
||||
}
|
||||
],
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": [
|
||||
{
|
||||
"reasoning_tokens": 0
|
||||
}
|
||||
],
|
||||
"request_usage_entries": [],
|
||||
"requests": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
},
|
||||
"conversation_id": null,
|
||||
"current_agent": {
|
||||
"name": "compat-agent"
|
||||
},
|
||||
"current_step": null,
|
||||
"current_turn": 0,
|
||||
"current_turn_persisted_item_count": 0,
|
||||
"generated_items": [],
|
||||
"generated_prompt_cache_key": null,
|
||||
"generated_session_item_indexes": [],
|
||||
"input_guardrail_results": [],
|
||||
"last_model_response": null,
|
||||
"last_processed_response": null,
|
||||
"max_turns": 10,
|
||||
"model_responses": [],
|
||||
"nested_history_owned_session_item_refs": [],
|
||||
"no_active_agent_run": true,
|
||||
"original_input": "historical input",
|
||||
"output_guardrail_results": [],
|
||||
"pending_input": [],
|
||||
"previous_response_id": null,
|
||||
"reasoning_item_id_policy": null,
|
||||
"session_items": [],
|
||||
"tool_input_guardrail_results": [],
|
||||
"tool_output_guardrail_results": [],
|
||||
"tool_use_tracker": {},
|
||||
"trace": null
|
||||
}
|
||||
+16
@@ -118,6 +118,15 @@
|
||||
"note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.",
|
||||
"provenance": "canonical_compatibility",
|
||||
"version": "1.16"
|
||||
},
|
||||
{
|
||||
"commit": "2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
|
||||
"emitted_version": "1.16",
|
||||
"feature": "docker_labels",
|
||||
"fixture": "features/v1_17_docker_labels.json",
|
||||
"note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving the Docker session payload.",
|
||||
"provenance": "canonical_compatibility",
|
||||
"version": "1.17"
|
||||
}
|
||||
],
|
||||
"resume": {
|
||||
@@ -179,6 +188,13 @@
|
||||
"note": "The schema transition introduced this reader version without a retained writer commit that emitted it. The recorded writer emitted 1.15; only the schema label is changed to exercise the canonical compatibility branch.",
|
||||
"provenance": "canonical_compatibility"
|
||||
},
|
||||
"1.17": {
|
||||
"commit": "2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
|
||||
"emitted_version": "1.16",
|
||||
"fixture": "minimal/v1_17.json",
|
||||
"note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving older payload compatibility.",
|
||||
"provenance": "canonical_compatibility"
|
||||
},
|
||||
"1.2": {
|
||||
"commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c",
|
||||
"fixture": "minimal/v1_2.json"
|
||||
|
||||
@@ -27,6 +27,20 @@ def test_sandbox_client_options_parse_uses_registered_builtin_type() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_docker_client_options_roundtrip_preserves_labels() -> None:
|
||||
options = DockerSandboxClientOptions(
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
labels={"com.example.owner": "worker-123"},
|
||||
)
|
||||
|
||||
payload = options.model_dump(mode="json")
|
||||
restored = BaseSandboxClientOptions.parse(payload)
|
||||
|
||||
assert restored == options
|
||||
assert isinstance(restored, DockerSandboxClientOptions)
|
||||
assert restored.labels == {"com.example.owner": "worker-123"}
|
||||
|
||||
|
||||
def test_sandbox_client_options_parse_passthrough_existing_instance() -> None:
|
||||
options = UnixLocalSandboxClientOptions(exposed_ports=(8080,))
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable(
|
||||
(
|
||||
"agents.sandbox.sandboxes.docker",
|
||||
"DockerSandboxClientOptions",
|
||||
("image", "exposed_ports", "network_mode"),
|
||||
("image", "exposed_ports", "network_mode", "labels"),
|
||||
),
|
||||
(
|
||||
"agents.extensions.sandbox.e2b",
|
||||
@@ -576,6 +576,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable(
|
||||
"image",
|
||||
"container_id",
|
||||
"network_mode",
|
||||
"labels",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -20,6 +20,9 @@ import pytest
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
import agents.sandbox.sandboxes.docker as docker_sandbox
|
||||
from agents import Agent
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_state import CURRENT_SCHEMA_VERSION, RunState
|
||||
from agents.sandbox import SandboxPathGrant
|
||||
from agents.sandbox._mount_security import REDACTED_MOUNT_AUTHORITY_KEY
|
||||
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
|
||||
@@ -1830,6 +1833,142 @@ async def test_docker_create_container_publishes_exposed_ports(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_create_container_applies_labels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
container = _ResumeContainer(status="created")
|
||||
docker_client = _FakeCreateDockerClient(container)
|
||||
client = DockerSandboxClient(docker_client=cast(object, docker_client))
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
|
||||
monkeypatch.setattr(client, "image_exists", lambda _image: True)
|
||||
|
||||
created = await client._create_container(
|
||||
DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
assert created is container
|
||||
assert docker_client.containers.calls[0]["labels"] == labels
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_create_container_omits_empty_labels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
container = _ResumeContainer(status="created")
|
||||
docker_client = _FakeCreateDockerClient(container)
|
||||
client = DockerSandboxClient(docker_client=cast(object, docker_client))
|
||||
|
||||
monkeypatch.setattr(client, "image_exists", lambda _image: True)
|
||||
|
||||
await client._create_container(DEFAULT_PYTHON_SANDBOX_IMAGE, labels={})
|
||||
|
||||
assert "labels" not in docker_client.containers.calls[0]
|
||||
|
||||
|
||||
def test_docker_session_state_roundtrip_preserves_labels() -> None:
|
||||
client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient()))
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
state = DockerSandboxSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id="container",
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
restored = client.deserialize_session_state(client.serialize_session_state(state))
|
||||
|
||||
assert isinstance(restored, DockerSandboxSessionState)
|
||||
assert restored.labels == labels
|
||||
|
||||
|
||||
def test_docker_session_state_without_labels_preserves_old_payloads() -> None:
|
||||
client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient()))
|
||||
state = DockerSandboxSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id="container",
|
||||
)
|
||||
payload = client.serialize_session_state(state)
|
||||
payload.pop("labels", None)
|
||||
|
||||
restored = client.deserialize_session_state(payload)
|
||||
|
||||
assert isinstance(restored, DockerSandboxSessionState)
|
||||
assert restored.labels == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_labels_roundtrip_through_run_state() -> None:
|
||||
agent = Agent(name="sandbox")
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
run_state = RunState(
|
||||
context=RunContextWrapper(context={}),
|
||||
original_input="resume sandbox",
|
||||
starting_agent=agent,
|
||||
)
|
||||
run_state._sandbox = {
|
||||
"backend_id": "docker",
|
||||
"current_agent_name": agent.name,
|
||||
"session_state": DockerSandboxSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id="container",
|
||||
labels=labels,
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
|
||||
serialized = run_state.to_json()
|
||||
restored = await RunState.from_json(agent, serialized)
|
||||
|
||||
assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION == "1.17"
|
||||
assert restored._sandbox is not None
|
||||
restored_session_state = restored._sandbox["session_state"]
|
||||
assert isinstance(restored_session_state, dict)
|
||||
assert restored_session_state["labels"] == labels
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_create_persists_configured_labels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
container = _StartedContainer()
|
||||
client = DockerSandboxClient(docker_client=cast(object, _FakeDockerClient()))
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
forwarded_labels: list[dict[str, str] | None] = []
|
||||
|
||||
async def _fake_create_container(
|
||||
image: str,
|
||||
*,
|
||||
manifest: Manifest | None = None,
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
network_mode: str | None = None,
|
||||
session_id: uuid.UUID | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> _StartedContainer:
|
||||
_ = (image, manifest, exposed_ports, network_mode, session_id)
|
||||
forwarded_labels.append(labels)
|
||||
return container
|
||||
|
||||
monkeypatch.setattr(client, "_create_container", _fake_create_container)
|
||||
|
||||
session = await client.create(
|
||||
options=DockerSandboxClientOptions(
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
labels=labels,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(session._inner, DockerSandboxSession)
|
||||
assert session._inner.state.labels == labels
|
||||
assert forwarded_labels == [labels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_create_container_mounts_explicit_host_path(
|
||||
tmp_path: Path,
|
||||
@@ -2811,8 +2950,9 @@ async def test_docker_resume_applies_current_authority_with_fresh_volume_identit
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
network_mode: str | None = None,
|
||||
session_id: uuid.UUID | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> _StartedContainer:
|
||||
_ = (image, exposed_ports)
|
||||
_ = (image, exposed_ports, labels)
|
||||
assert network_mode is None
|
||||
assert session_id == replacement_session_id
|
||||
assert stale_volume.remove_calls == 0
|
||||
@@ -3332,6 +3472,7 @@ class _ResumeContainer:
|
||||
workspace_exists: bool = False,
|
||||
published_ports: dict[str, list[dict[str, str]] | None] | None = None,
|
||||
mounts: list[dict[str, object]] | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status = status
|
||||
self.id = container_id
|
||||
@@ -3340,6 +3481,7 @@ class _ResumeContainer:
|
||||
self.attrs = {
|
||||
"NetworkSettings": {"Ports": published_ports or {}},
|
||||
"Mounts": mounts or [],
|
||||
"Config": {"Labels": labels or {}},
|
||||
}
|
||||
|
||||
def reload(self) -> None:
|
||||
@@ -4150,8 +4292,10 @@ async def test_docker_resume_resets_workspace_readiness_when_container_is_recrea
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
network_mode: str | None = None,
|
||||
session_id: uuid.UUID | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
_ = session_id
|
||||
_ = labels
|
||||
create_calls.append((image, manifest, exposed_ports, network_mode))
|
||||
return replacement
|
||||
|
||||
@@ -4177,6 +4321,92 @@ async def test_docker_resume_resets_workspace_readiness_when_container_is_recrea
|
||||
assert create_calls == [(DEFAULT_PYTHON_SANDBOX_IMAGE, inner.state.manifest, (8765,), None)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_resume_forwards_persisted_labels_when_recreating_container(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = DockerSandboxClient(
|
||||
docker_client=cast(object, _ResumeDockerClient(docker.errors.NotFound("missing")))
|
||||
)
|
||||
replacement = _ResumeContainer(status="created", container_id="replacement")
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
forwarded_labels: list[dict[str, str] | None] = []
|
||||
|
||||
async def _fake_create_container(
|
||||
image: str,
|
||||
*,
|
||||
manifest: Manifest | None = None,
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
network_mode: str | None = None,
|
||||
session_id: uuid.UUID | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> _ResumeContainer:
|
||||
_ = (image, manifest, exposed_ports, network_mode, session_id)
|
||||
forwarded_labels.append(labels)
|
||||
return replacement
|
||||
|
||||
monkeypatch.setattr(client, "_create_container", _fake_create_container)
|
||||
|
||||
resumed = await client.resume(
|
||||
DockerSandboxSessionState(
|
||||
manifest=Manifest(root="/workspace"),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id="missing",
|
||||
labels=labels,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(resumed._inner, DockerSandboxSession)
|
||||
assert forwarded_labels == [labels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_resume_reuses_container_with_matching_labels() -> None:
|
||||
labels = {"com.example.owner": "worker-123"}
|
||||
container = _ResumeContainer(
|
||||
status="running",
|
||||
labels={**labels, "com.example.extra": "preserved"},
|
||||
)
|
||||
client = DockerSandboxClient(docker_client=_ResumeDockerClient(container))
|
||||
state = DockerSandboxSessionState(
|
||||
manifest=Manifest(root="/workspace"),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id=container.id,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
resumed = await client.resume(state)
|
||||
|
||||
assert isinstance(resumed._inner, DockerSandboxSession)
|
||||
assert resumed._inner._container is container
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"actual_labels",
|
||||
[{}, {"com.example.owner": "different"}],
|
||||
ids=["missing", "mismatched"],
|
||||
)
|
||||
async def test_docker_resume_rejects_mismatched_existing_labels(
|
||||
actual_labels: dict[str, str],
|
||||
) -> None:
|
||||
expected_labels = {"com.example.owner": "worker-123"}
|
||||
container = _ResumeContainer(status="running", labels=actual_labels)
|
||||
client = DockerSandboxClient(docker_client=_ResumeDockerClient(container))
|
||||
state = DockerSandboxSessionState(
|
||||
manifest=Manifest(root="/workspace"),
|
||||
snapshot=NoopSnapshot(id="snapshot"),
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
container_id=container.id,
|
||||
labels=expected_labels,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="labels"):
|
||||
await client.resume(state)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_resume_recovers_workspace_workdir_for_direct_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -3001,7 +3001,7 @@ class TestRunState:
|
||||
state.approve(approval("exception"))
|
||||
|
||||
serialized = state.to_json()
|
||||
assert serialized["$schemaVersion"] == "1.16"
|
||||
assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION
|
||||
|
||||
restored = await RunState.from_json(agent, serialized)
|
||||
assert restored._context is not None
|
||||
@@ -9136,6 +9136,7 @@ class TestRunStateSerializationEdgeCases:
|
||||
"1.13",
|
||||
"1.14",
|
||||
"1.15",
|
||||
"1.16",
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user