From 2baa1b1bcc4cebc64e197debd4c59e4bee1093be Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 21 Aug 2026 21:53:54 +0800 Subject: [PATCH 1/2] Add labels to Docker sandbox containers --- src/agents/sandbox/sandboxes/docker.py | 10 ++ tests/sandbox/test_client_options.py | 14 +++ tests/sandbox/test_compatibility_guards.py | 3 +- tests/sandbox/test_docker.py | 118 ++++++++++++++++++++- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 70ce1a96da..fbae25d840 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -185,6 +185,7 @@ class DockerSandboxSessionState(SandboxSessionState): image: str container_id: str network_mode: Literal["none"] | None = None + labels: dict[str, str] | None = None @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] | None = None @model_validator(mode="after") def _validate_network_configuration(self) -> Self: @@ -230,12 +232,14 @@ def __init__( *, 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=labels, ) @@ -1535,6 +1539,7 @@ async def create( 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 @@ async def create( container_id=container_id, exposed_ports=options.exposed_ports, network_mode=options.network_mode, + labels=options.labels, ) inner = DockerSandboxSession( docker_client=self.docker_client, @@ -1681,6 +1687,7 @@ async def resume( 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 +1722,7 @@ async def _create_container( 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 +1744,8 @@ async def _create_container( } if network_mode is not None: create_kwargs["network_mode"] = network_mode + if labels is not None: + create_kwargs["labels"] = labels if manifest is not None: docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id) if docker_mounts: diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 5659541767..fff08732f3 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -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,)) diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index a358f76ea7..ee4a9f13c7 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -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", ), ), ( diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 5c535e57bf..a2d1a86567 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -1830,6 +1830,79 @@ 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 + + +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 + + +@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 +2884,9 @@ async def create_container( 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 @@ -4150,8 +4224,10 @@ async def _fake_create_container( 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 +4253,46 @@ async def _fake_create_container( 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_recovers_workspace_workdir_for_direct_state( monkeypatch: pytest.MonkeyPatch, From bcd34f454be8366b9f3a338b112114fcf820a432 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 21 Aug 2026 23:00:21 +0800 Subject: [PATCH 2/2] Address Docker label persistence feedback --- src/agents/run_state.py | 3 +- src/agents/sandbox/sandboxes/docker.py | 30 ++++- tests/fixtures/run_state/README.md | 4 +- .../features/v1_17_docker_labels.json | 111 +++++++++++++++++ tests/fixtures/run_state/generate_corpus.py | 47 ++++++++ tests/fixtures/run_state/minimal/v1_17.json | 60 +++++++++ tests/fixtures/run_state/sources.json | 16 +++ tests/sandbox/test_docker.py | 114 ++++++++++++++++++ tests/test_run_state.py | 3 +- 9 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/run_state/features/v1_17_docker_labels.json create mode 100644 tests/fixtures/run_state/minimal/v1_17.json diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 74e5bcbf04..b3a0af49b4 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -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 @@ def _default_run_state_validation_error( "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) diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index fbae25d840..8ca4febe85 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -25,7 +25,7 @@ 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,7 +185,7 @@ class DockerSandboxSessionState(SandboxSessionState): image: str container_id: str network_mode: Literal["none"] | None = None - labels: dict[str, str] | None = None + labels: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") def _validate_network_configuration(self) -> Self: @@ -215,7 +215,7 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions): image: str exposed_ports: tuple[int, ...] = () network_mode: Literal["none"] | None = None - labels: dict[str, str] | None = None + labels: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") def _validate_network_configuration(self) -> Self: @@ -239,7 +239,7 @@ def __init__( image=image, exposed_ports=exposed_ports, network_mode=network_mode, - labels=labels, + labels={} if labels is None else labels, ) @@ -1659,6 +1659,7 @@ async def resume( container, state.network_mode, ) + _assert_existing_container_labels_match(container, state.labels) owns_replacement = container is None replacement_session_id = ( uuid.uuid4() @@ -1744,7 +1745,7 @@ async def _create_container( } if network_mode is not None: create_kwargs["network_mode"] = network_mode - if labels is not None: + if labels: create_kwargs["labels"] = labels if manifest is not None: docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id) @@ -1905,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, diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md index 485f1f62ac..82836b310d 100644 --- a/tests/fixtures/run_state/README.md +++ b/tests/fixtures/run_state/README.md @@ -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. diff --git a/tests/fixtures/run_state/features/v1_17_docker_labels.json b/tests/fixtures/run_state/features/v1_17_docker_labels.json new file mode 100644 index 0000000000..6638df0757 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_17_docker_labels.json @@ -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 +} diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py index f287a6e323..ef7a33ee2e 100644 --- a/tests/fixtures/run_state/generate_corpus.py +++ b/tests/fixtures/run_state/generate_corpus.py @@ -426,6 +426,40 @@ def approval(call_id): "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 @@ def approval(call_id): "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." + ), + ), ) diff --git a/tests/fixtures/run_state/minimal/v1_17.json b/tests/fixtures/run_state/minimal/v1_17.json new file mode 100644 index 0000000000..fe4a0f00bc --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_17.json @@ -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 +} diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json index 21d3d30bc1..fc7be3fb55 100644 --- a/tests/fixtures/run_state/sources.json +++ b/tests/fixtures/run_state/sources.json @@ -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" diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index a2d1a86567..e4c7cc812f 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -20,6 +20,9 @@ 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 @@ -1850,6 +1853,21 @@ async def test_docker_create_container_applies_labels( 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"} @@ -1867,6 +1885,54 @@ def test_docker_session_state_roundtrip_preserves_labels() -> None: 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, @@ -3406,6 +3472,7 @@ def __init__( 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 @@ -3414,6 +3481,7 @@ def __init__( self.attrs = { "NetworkSettings": {"Ports": published_ports or {}}, "Mounts": mounts or [], + "Config": {"Labels": labels or {}}, } def reload(self) -> None: @@ -4293,6 +4361,52 @@ async def _fake_create_container( 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, diff --git a/tests/test_run_state.py b/tests/test_run_state.py index f65999c3e9..309171584f 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -3001,7 +3001,7 @@ def approval(call_id: str) -> ToolApprovalItem: 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 @@ def test_supported_schema_versions_match_released_boundary(self): "1.13", "1.14", "1.15", + "1.16", CURRENT_SCHEMA_VERSION, } )