feat(sandbox): allow labels on Docker sandbox containers (#4564)

This commit is contained in:
Sean
2026-08-22 06:53:52 +08:00
committed by GitHub
parent 707457064e
commit 4c2810c11c
11 changed files with 518 additions and 7 deletions
+2 -1
View File
@@ -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)
+31 -1
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+14
View File
@@ -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,))
+2 -1
View File
@@ -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",
),
),
(
+231 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
}
)