From 85939f7842797d1c2b71ccedbca42769f7c57f8f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 16:14:15 -0700 Subject: [PATCH 1/3] feat(sandbox): a provider that creates VMs loads an agent's image unless it declares otherwise SANDBOX_ACCEPTS, the images an agent's own sandbox runs, now defaults to what a provider's create_sandbox gives: those a VM loads when it implements create_vm (its create_sandbox is a bare VM the deploy loads the image into), and its CONTAINER_ACCEPTS otherwise (its create_sandbox is one of its containers). A provider whose create_sandbox differs declares it, and its subclasses keep the declaration. The local provider, which creates VMs but runs an agent as a container by name, declares NAME; Modal VM, E2B and Sail drop their LOADABLE line, and Modal its duplicate. Every provider here and in the Scale layer ends up with the value it declared. A plugin that implements create_vm and relied on the old NAME default for a container create_sandbox now has to declare it; if it doesn't, the deploy says so as soon as the container comes back for an image it can't run by name. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/a2a_agent/a2a_agent.py | 16 ++++++ .../sandbox_providers/e2b/provider.py | 2 - .../sandbox_providers/local_sandbox.py | 3 ++ .../sandbox_providers/modal_sandbox.py | 2 +- .../sandbox_providers/modal_vm_sandbox.py | 3 +- .../sandbox_providers/sail_vm/provider.py | 2 - .../sandbox_providers/sandbox_provider.py | 20 ++++++-- .../a2a_agent/context_only_deploy_test.py | 50 +++++++++++++++++++ .../provider_capabilities_test.py | 33 +++++++++++- 10 files changed, 118 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a377d8c8..be1458e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ and deserialized through a registry. | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | -| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | +| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index 71742f53..cf59057b 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -462,6 +462,8 @@ async def deploy( ) self._owns_sandbox = True logger.info(f"Sandbox created: {self._sandbox.sandbox_id} (mode={self._sandbox.mode})") + if problem := self._misdeclared(provider, image): + raise ValueError(problem) else: if network_policy is not None and network_policy.restricts_egress: raise NetworkPolicyUnsupportedError( @@ -505,6 +507,20 @@ async def deploy( await self.close() raise + def _misdeclared(self, provider: SandboxProvider, image: DockerImageArtifact) -> str | None: + """Why the sandbox ``provider`` gave can't run ``image`` though it was taken to load it, or None: every provider + it may come from takes images as a VM loads them (``SANDBOX_ACCEPTS``), yet it gave a container, which runs the + image by name, and the image can't be run that way.""" + links = provider.links + if self._sandbox.mode == SANDBOX_MODE_VM or any(self.accepts(link) is not Accepts.LOADABLE for link in links): + return None + if (problem := image.by_name_problem(on_this_machine=self._sandbox.ON_THIS_MACHINE)) is None: + return None + names = " or ".join(dict.fromkeys(type(link).__name__ for link in links)) + return (f"{names} gave agent {self.id!r} a container, which runs its image by name, though it takes images as a VM " + "loads them (SANDBOX_ACCEPTS is LOADABLE, the default for a provider that implements create_vm); declare " + f"SANDBOX_ACCEPTS = Accepts.NAME on it: {problem}") + def _refusal(self, image: DockerImageArtifact, link: SandboxProvider) -> str | None: """Why ``link`` can't run ``image`` as this agent's own sandbox, as a clause on its name, or None when it can.""" accepts = self.accepts(link) diff --git a/src/agent_env/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index d2f25479..050d74a4 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -20,7 +20,6 @@ from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, - Accepts, SandboxProvider, apply_default_attribution, ) @@ -38,7 +37,6 @@ class E2BSandboxProvider(SandboxProvider): interpolation in ``[sandbox.providers.e2b.config]``. """ - SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name # AsyncSandbox.get_host(port) returns ``-.e2b.app``. # Workloads receive those public URLs, so an allowlisted sandbox must be diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 1b34b309..3ece7364 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -42,6 +42,7 @@ from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM, + Accepts, SandboxProvider, refuse_unenforceable_policy, ) @@ -538,6 +539,8 @@ class LocalSandboxProvider(SandboxProvider): EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS) ON_THIS_MACHINE = True + # An agent's sandbox is a container from its image's name, though a VM it creates (create_vm) loads images. + SANDBOX_ACCEPTS = Accepts.NAME def url_from_sandbox(self, url: str) -> str: return self.get_external_url(url) diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 0aab7a45..af1ad617 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -191,7 +191,7 @@ class ModalSandboxProvider(SandboxProvider): # Each image in a container of its own, built first when it is only a build context (prepare_image); its # containers share i6pn, Modal's private network. - SANDBOX_ACCEPTS = CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True @classmethod diff --git a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py index d0762293..0c44aa02 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -38,7 +38,7 @@ from agent_env.attribution import Attribution from agent_env.config import get_config from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox, push_object_over_stdin -from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, SandboxProvider if TYPE_CHECKING: from agent_env.store.object_store import ObjectStore @@ -274,7 +274,6 @@ async def _download_ranged(self, url_factory: Callable[[int], Awaitable[str]], v class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") - SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py index 25d2e562..27116548 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -25,7 +25,6 @@ from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, - Accepts, SandboxProvider, apply_default_attribution, ) @@ -134,7 +133,6 @@ class SailVmSandboxProvider(SandboxProvider): Sail adds it to the agent's requests to the model endpoint (see ``model_key``).""" EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) - SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 5df448c7..ef641b6f 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -166,9 +166,9 @@ def from_config(cls, **config: Any) -> Self: # Internal→external host rewrites for URLs issued on this platform ({} = none). URL_REWRITES: ClassVar[dict[str, str]] = {} - # What it declares, which nothing outside the providers reads any other way. A provider that declares nothing runs - # each image from its name, in a sandbox that isn't on this machine, on linux/amd64, and creates no VM and no - # private network. + # What it declares, which nothing outside the providers reads any other way. A provider that declares nothing has + # its sandboxes off this machine, runs its containers' images by name on no private network, and gives an agent's + # sandbox the images that follow from what it implements (SANDBOX_ACCEPTS). # # Whether its sandboxes run on this machine: an image only this machine holds (in a registry or object store on # it) reaches them, its containers need this machine's Docker, and a gateway's infra envs are built here for it. @@ -176,9 +176,14 @@ def from_config(cls, **config: Any) -> Self: # The sandbox modes it creates, derived for each provider class from what it implements (``__init_subclass__``): # SANDBOX_MODE_CONTAINER when it implements create_sandbox, SANDBOX_MODE_VM when it implements create_vm. SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset() - # The images create_sandbox and create_container run. - SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + # The images create_container runs. CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + # The images create_sandbox, an agent's sandbox, runs; derived for a provider class that doesn't declare them + # (``__init_subclass__``): those a VM loads when it implements create_vm, since its create_sandbox gives a bare VM + # the deploy loads the image into, and else CONTAINER_ACCEPTS, since its create_sandbox is one of its containers. + # One whose create_sandbox differs declares them, as the local provider does, and its subclasses keep them. + SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + _DECLARES_SANDBOX_ACCEPTS: ClassVar[bool] = False # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False @@ -187,6 +192,11 @@ def __init_subclass__(cls, **kwargs): cls.SUPPORTED_SANDBOX_MODES = frozenset( mode for mode, method in ((SANDBOX_MODE_CONTAINER, "create_sandbox"), (SANDBOX_MODE_VM, "create_vm")) if getattr(cls, method) is not getattr(SandboxProvider, method)) + if "SANDBOX_ACCEPTS" in cls.__dict__: + cls._DECLARES_SANDBOX_ACCEPTS = True + elif not cls._DECLARES_SANDBOX_ACCEPTS: + cls.SANDBOX_ACCEPTS = (Accepts.LOADABLE if SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES + else cls.CONTAINER_ACCEPTS) @property def links(self) -> tuple[SandboxProvider, ...]: diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 63e58bae..b6b400b7 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -190,3 +190,53 @@ async def test_the_local_provider_is_asked_for_a_sandbox_for_an_image_in_its_own with pytest.raises(_Created): await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox_type="any") + + +class _Container: + """A container sandbox, recording whether it was terminated.""" + + sandbox_id, mode, type, tunnel_urls, vnc_url, network_policy = "sb-agent", "container", "fake", {}, None, None + ON_THIS_MACHINE = False + + def __init__(self): + self.terminated = False + + async def terminate(self): + self.terminated = True + + +class _ContainerAgentsUndeclared(SandboxProvider): + """One that creates VMs, so is taken to load an agent's image on one, but gives the agent a container.""" + + def __init__(self): + self.sandbox = _Container() + + async def create_vm(self, **_): + raise AssertionError("an agent's sandbox comes from create_sandbox") + + async def create_sandbox(self, **_): + return self.sandbox + + +@pytest.mark.asyncio +async def test_a_provider_taken_to_load_the_agents_image_that_gives_a_container_names_the_declaration_it_lacks( + monkeypatch, local_stores, litellm): + provider = _ContainerAgentsUndeclared() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(ValueError, match=r"_ContainerAgentsUndeclared gave agent 'solver' a container, which runs its " + r"image by name, though it takes images as a VM loads them \(SANDBOX_ACCEPTS is " + r"LOADABLE, the default for a provider that implements create_vm\); declare " + r"SANDBOX_ACCEPTS = Accepts.NAME on it: 'solver-image' v2 is only a build context"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert provider.sandbox.terminated + + +@pytest.mark.asyncio +async def test_a_container_that_can_run_the_agents_image_by_name_goes_on_to_serve_it(monkeypatch, local_stores, litellm): + """Its provider should say so, but the deploy it gave works, so it isn't refused.""" + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: _ContainerAgentsUndeclared()) + pulled = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="registry.example/solver:v2") + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=pulled).deploy(sandbox_type="any") diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 35b3cb7b..3870748e 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -64,10 +64,10 @@ async def create_vm(self, **_): raise NotImplementedError -def test_a_provider_that_declares_nothing_runs_images_by_name_and_supports_vms_by_implementing_create_vm(): +def test_a_provider_that_declares_nothing_has_what_it_implements_and_loads_an_agents_image_on_a_vm(): assert (_Vms.ON_THIS_MACHINE, _Vms.PRIVATE_NETWORK) == (False, False) assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} - assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (NAME, NAME) + assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (LOADABLE, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" @@ -94,6 +94,35 @@ def test_a_provider_supports_only_the_modes_it_implements(): assert _Declared.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} +class _BuildingContainers(_ContainersOnly): + CONTAINER_ACCEPTS = NAME_OR_CONTEXT + + +class _ContainerAgents(_Vms): + """One whose agents run as containers by name although it creates VMs, as the local provider's do.""" + + SANDBOX_ACCEPTS = NAME + + +class _ContainerAgentsAgain(_ContainerAgents): + pass + + +class _VmsAgain(_Vms): + CONTAINER_ACCEPTS = NAME_OR_CONTEXT + + +@pytest.mark.parametrize("cls, accepts", [ + (_Vms, LOADABLE), (_VmsOnly, LOADABLE), # create_sandbox gives a bare VM the deploy loads the image into + (_ContainersOnly, NAME), (_BuildingContainers, NAME_OR_CONTEXT), # create_sandbox is one of its containers + (_ContainerAgents, NAME), (_ContainerAgentsAgain, NAME), # declared, and kept by a subclass + (_VmsAgain, LOADABLE), # derived again for a subclass that declares nothing + (LocalSandboxProvider, NAME), +], ids=lambda value: getattr(value, "__name__", getattr(value, "name", value))) +def test_an_agents_sandbox_takes_what_create_sandbox_gives_unless_the_provider_declares_otherwise(cls, accepts): + assert cls.SANDBOX_ACCEPTS is accepts + + def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): first, second = _Vms(), ModalSandboxProvider() From 397c890edb07d77da55d4c05ca504f7755088fc8 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 17:22:05 -0700 Subject: [PATCH 2/3] fix(sandbox): a chain checks each provider's sandbox before giving it, and a container is checked against what its provider's containers run The deploy check looked at every provider in a chain rather than the one whose sandbox came back, so a chain could pass a container that can't run the image (when another link's containers could) or refuse a deploy its next provider would have served. It also asked whether the image runs by name, which refuses a container whose provider built the image first, as Modal's do, and named the wrong fix. SandboxProvider.sandbox_problem(sandbox, image) now says why a sandbox create_sandbox gave can't run the image: a VM that can't load it, or a container its CONTAINER_ACCEPTS leaves it out of, naming the SANDBOX_ACCEPTS to declare when that differs. A chain asks it of each provider's sandbox as it tries it, closes one that can't and tries the next (replacing its VM-only check); a lone provider is asked by the agent's deploy. A chain answers None, having checked its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 16 +--- .../chained_sandbox_provider.py | 18 +++-- .../sandbox_providers/sandbox_provider.py | 18 +++++ .../a2a_agent/context_only_deploy_test.py | 81 +++++++++++++++++-- tst/unit/a2a_agent/deploy_cleanup_test.py | 2 +- .../sandbox_provider_test.py | 12 ++- 6 files changed, 114 insertions(+), 33 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index cf59057b..fe5644a6 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -462,7 +462,7 @@ async def deploy( ) self._owns_sandbox = True logger.info(f"Sandbox created: {self._sandbox.sandbox_id} (mode={self._sandbox.mode})") - if problem := self._misdeclared(provider, image): + if problem := provider.sandbox_problem(self._sandbox, image): raise ValueError(problem) else: if network_policy is not None and network_policy.restricts_egress: @@ -507,20 +507,6 @@ async def deploy( await self.close() raise - def _misdeclared(self, provider: SandboxProvider, image: DockerImageArtifact) -> str | None: - """Why the sandbox ``provider`` gave can't run ``image`` though it was taken to load it, or None: every provider - it may come from takes images as a VM loads them (``SANDBOX_ACCEPTS``), yet it gave a container, which runs the - image by name, and the image can't be run that way.""" - links = provider.links - if self._sandbox.mode == SANDBOX_MODE_VM or any(self.accepts(link) is not Accepts.LOADABLE for link in links): - return None - if (problem := image.by_name_problem(on_this_machine=self._sandbox.ON_THIS_MACHINE)) is None: - return None - names = " or ".join(dict.fromkeys(type(link).__name__ for link in links)) - return (f"{names} gave agent {self.id!r} a container, which runs its image by name, though it takes images as a VM " - "loads them (SANDBOX_ACCEPTS is LOADABLE, the default for a provider that implements create_vm); declare " - f"SANDBOX_ACCEPTS = Accepts.NAME on it: {problem}") - def _refusal(self, image: DockerImageArtifact, link: SandboxProvider) -> str | None: """Why ``link`` can't run ``image`` as this agent's own sandbox, as a clause on its name, or None when it can.""" accepts = self.accepts(link) diff --git a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py index dce3fb38..5fadf549 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING from agent_env.attribution import Attribution -from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox, VmSandbox -from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider if TYPE_CHECKING: from agent_env.artifact.artifacts.docker_image import DockerImageArtifact @@ -48,6 +48,11 @@ def filter_sandbox_providers( f"(providers: {[type(p).__name__ for p in providers]})"), ) + def sandbox_problem(self, sandbox: Sandbox, image: DockerImageArtifact) -> str | None: + """None: create_sandbox checks each provider's sandbox as it tries it, and gives only one that runs + the image.""" + return None + def supports_network_policy(self, policy: NetworkPolicy) -> bool: return any(p.supports_network_policy(policy) for p in self._providers) @@ -76,11 +81,10 @@ async def create_sandbox( logger.warning(f"{name}.create_sandbox failed: {e}; trying next provider") errors.append((name, e)) continue - # The deploy loads the image into a VM, which refuses one it can't get or run; the next provider's may not. - if (image is not None and sandbox.mode == SANDBOX_MODE_VM and isinstance(sandbox, VmSandbox) - and (problems := sandbox.load_problems([image]))): - problem = "; ".join(problems) - logger.warning(f"{name}.create_sandbox gave a VM that can't load the image: {problem}; trying next provider") + # A VM refuses an image it can't get or run, and a container runs only what its provider's containers do; + # the next provider's sandbox may run it. + if image is not None and (problem := p.sandbox_problem(sandbox, image)): + logger.warning(f"{problem}; trying next provider") errors.append((name, ValueError(problem))) try: await sandbox.terminate() diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index ef641b6f..6074e9b5 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -203,6 +203,24 @@ def links(self) -> tuple[SandboxProvider, ...]: """The providers a deploy on this one may run on, in the order it tries them: itself, or a chain's.""" return (self,) + def sandbox_problem(self, sandbox: Sandbox, image: DockerImageArtifact) -> str | None: + """Why ``sandbox``, which create_sandbox gave to run ``image``, can't run it, or None when it can: a VM that + can't load it, or a container that doesn't run it (CONTAINER_ACCEPTS), which create_sandbox gives only when + SANDBOX_ACCEPTS is wrong.""" + name = type(self).__name__ + if sandbox.mode == SANDBOX_MODE_VM: + problems = sandbox.load_problems([image]) if isinstance(sandbox, VmSandbox) else [] + return f"{name} gave a VM that can't load the image: {'; '.join(problems)}" if problems else None + if (problem := self.CONTAINER_ACCEPTS.problem(image, on_this_machine=sandbox.ON_THIS_MACHINE)) is None: + return None + refusal = f"{name} gave a container, which can't run the image: {problem}" + if self.SANDBOX_ACCEPTS is self.CONTAINER_ACCEPTS: + return refusal + loadable = self.SANDBOX_ACCEPTS is Accepts.LOADABLE + default = ", the default for a provider that implements create_vm" if loadable else "" + return (f"{refusal}; its SANDBOX_ACCEPTS is {self.SANDBOX_ACCEPTS.name}{default}, so declare the images its " + f"containers run instead: SANDBOX_ACCEPTS = Accepts.{self.CONTAINER_ACCEPTS.name}") + def url_from_sandbox(self, url: str) -> str: """``url``, of a service this process reaches, as its sandboxes reach it.""" return url diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index b6b400b7..85be83ad 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -218,25 +218,90 @@ async def create_sandbox(self, **_): return self.sandbox +class _BuildingContainerAgentsUndeclared(_ContainerAgentsUndeclared): + """The same, but its containers run a build context it builds first (prepare_image), as Modal's do.""" + + CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + + +class _ModalContainer(_ModalProvider): + """Modal with its build faked, giving a container.""" + + async def create_container(self, **kwargs): + self.steps.append(("create", kwargs["image_name"])) + return _Container() + + +PULLED = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="registry.example/solver:v2") + + +MISDECLARED = (r"_ContainerAgentsUndeclared gave a container, which can't run the image: 'solver-image' v2 is only a " + r"build context, which has to be built, not pulled; its SANDBOX_ACCEPTS is LOADABLE, the default for a " + r"provider that implements create_vm, so declare the images its containers run instead: " + r"SANDBOX_ACCEPTS = Accepts.NAME") + + @pytest.mark.asyncio async def test_a_provider_taken_to_load_the_agents_image_that_gives_a_container_names_the_declaration_it_lacks( monkeypatch, local_stores, litellm): provider = _ContainerAgentsUndeclared() monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) - with pytest.raises(ValueError, match=r"_ContainerAgentsUndeclared gave agent 'solver' a container, which runs its " - r"image by name, though it takes images as a VM loads them \(SANDBOX_ACCEPTS is " - r"LOADABLE, the default for a provider that implements create_vm\); declare " - r"SANDBOX_ACCEPTS = Accepts.NAME on it: 'solver-image' v2 is only a build context"): + with pytest.raises(ValueError, match=f"^{MISDECLARED}$"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") assert provider.sandbox.terminated @pytest.mark.asyncio -async def test_a_container_that_can_run_the_agents_image_by_name_goes_on_to_serve_it(monkeypatch, local_stores, litellm): +async def test_a_chain_closes_a_container_that_cant_run_the_agents_image_and_tries_the_next_provider( + monkeypatch, local_stores, litellm, caplog): + misdeclared, modal = _ContainerAgentsUndeclared(), _ModalContainer() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([misdeclared, modal])) + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert misdeclared.sandbox.terminated + assert modal.steps == [("prepare", IMAGE.image_name), ("create", IMAGE.image_name)] + assert f"{MISDECLARED.replace(chr(92), '')}; trying next provider" in caplog.text + + +@pytest.mark.asyncio +async def test_a_chain_none_of_whose_sandboxes_runs_the_agents_image_names_each_refusal(monkeypatch, local_stores, + litellm): + """The provider after one that gives a container it can't run is tried, a VM provider here.""" + misdeclared = _ContainerAgentsUndeclared() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([misdeclared, _VmProvider()])) + + with pytest.raises(RuntimeError, match=r"All 2 providers failed: _ContainerAgentsUndeclared: ValueError\(.*declare " + r"the images its containers run instead: " + r"SANDBOX_ACCEPTS = Accepts.NAME.*\); _VmProvider: _Created\(\)$"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert misdeclared.sandbox.terminated + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_class, image", [ + (_ContainerAgentsUndeclared, PULLED), + (_BuildingContainerAgentsUndeclared, IMAGE), +], ids=["pulled-by-name", "built-first"]) +async def test_a_container_that_can_run_the_agents_image_goes_on_to_serve_it(monkeypatch, local_stores, litellm, + provider_class, image): """Its provider should say so, but the deploy it gave works, so it isn't refused.""" - monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: _ContainerAgentsUndeclared()) - pulled = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="registry.example/solver:v2") + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider_class()) with pytest.raises(RuntimeError, match="No tunnel URL for port"): - await A2AAgent(id="solver", version=1, docker_image_artifact=pulled).deploy(sandbox_type="any") + await A2AAgent(id="solver", version=1, docker_image_artifact=image).deploy(sandbox_type="any") + + +@pytest.mark.asyncio +async def test_a_chain_that_falls_back_from_a_vm_provider_to_modal_serves_the_agent_from_modals_container( + monkeypatch, local_stores, litellm): + modal = _ModalContainer() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([_VmProvider(), modal])) + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert modal.steps == [("prepare", IMAGE.image_name), ("create", IMAGE.image_name)] diff --git a/tst/unit/a2a_agent/deploy_cleanup_test.py b/tst/unit/a2a_agent/deploy_cleanup_test.py index 4b5b584c..1c0f3a24 100644 --- a/tst/unit/a2a_agent/deploy_cleanup_test.py +++ b/tst/unit/a2a_agent/deploy_cleanup_test.py @@ -13,7 +13,7 @@ class _Sandbox: - sandbox_id, type, mode, network_policy = "sb-agent", "fake", SANDBOX_MODE_CONTAINER, None + sandbox_id, type, mode, network_policy, ON_THIS_MACHINE = "sb-agent", "fake", SANDBOX_MODE_CONTAINER, None, False tunnel_urls = {DEFAULT_A2A_PORT: "http://agent"} terminated = False diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index dd94579d..40f97cbd 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -17,7 +17,12 @@ from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox, _pull -from agent_env.providers.sandbox_providers.sandbox_provider import _BUILTIN_SANDBOX_PROVIDERS, SandboxProvider, build_sandbox_provider +from agent_env.providers.sandbox_providers.sandbox_provider import ( + _BUILTIN_SANDBOX_PROVIDERS, + Accepts, + SandboxProvider, + build_sandbox_provider, +) from agent_env.store import ImageStore, RegistryAuth from tst.util.exec_scripts import script_run @@ -357,7 +362,10 @@ async def test_a_chain_none_of_whose_vms_runs_the_image_names_each_refusal(): class _PreparingProvider(_StubProvider): - """A stub whose prepare_image takes ``prepare_seconds``, or raises ``prepare_raises``, recording each image.""" + """A stub whose prepare_image takes ``prepare_seconds``, or raises ``prepare_raises``, recording each image, and + whose containers run the build contexts it prepares, as Modal's do.""" + + CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT def __init__(self, *, prepare_seconds: float = 0, prepare_raises: Exception | None = None, **kwargs): super().__init__(**kwargs) From 4abcae65b76e5c845af396559873b592459aa4bd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 17:37:23 -0700 Subject: [PATCH 3/3] fix(sandbox): only a provider that says its agent sandboxes load images like a VM has their containers checked A provider can declare that create_sandbox's containers run other images than create_container's (say SANDBOX_ACCEPTS = NAME_OR_CONTEXT, CONTAINER_ACCEPTS = NAME); checking those containers against CONTAINER_ACCEPTS refused sandboxes that work. sandbox_problem now checks a container only when the provider's SANDBOX_ACCEPTS is LOADABLE, the case where it said it gives a VM; a provider that declares the images its containers run is taken at its word, as preflight already checked against them. The two test fakes changed for the wider check are back as they were. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../sandbox_providers/sandbox_provider.py | 16 +++++++--------- tst/unit/a2a_agent/context_only_deploy_test.py | 11 +++++++++-- tst/unit/a2a_agent/deploy_cleanup_test.py | 2 +- .../sandbox_providers/sandbox_provider_test.py | 12 ++---------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 6074e9b5..39854700 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -205,21 +205,19 @@ def links(self) -> tuple[SandboxProvider, ...]: def sandbox_problem(self, sandbox: Sandbox, image: DockerImageArtifact) -> str | None: """Why ``sandbox``, which create_sandbox gave to run ``image``, can't run it, or None when it can: a VM that - can't load it, or a container that doesn't run it (CONTAINER_ACCEPTS), which create_sandbox gives only when - SANDBOX_ACCEPTS is wrong.""" + can't load it, or a container from a provider whose SANDBOX_ACCEPTS takes images as a VM loads them, which runs + only the images its containers do (CONTAINER_ACCEPTS). One that declares other images is taken at its word.""" name = type(self).__name__ if sandbox.mode == SANDBOX_MODE_VM: problems = sandbox.load_problems([image]) if isinstance(sandbox, VmSandbox) else [] return f"{name} gave a VM that can't load the image: {'; '.join(problems)}" if problems else None + if self.SANDBOX_ACCEPTS is not Accepts.LOADABLE: + return None if (problem := self.CONTAINER_ACCEPTS.problem(image, on_this_machine=sandbox.ON_THIS_MACHINE)) is None: return None - refusal = f"{name} gave a container, which can't run the image: {problem}" - if self.SANDBOX_ACCEPTS is self.CONTAINER_ACCEPTS: - return refusal - loadable = self.SANDBOX_ACCEPTS is Accepts.LOADABLE - default = ", the default for a provider that implements create_vm" if loadable else "" - return (f"{refusal}; its SANDBOX_ACCEPTS is {self.SANDBOX_ACCEPTS.name}{default}, so declare the images its " - f"containers run instead: SANDBOX_ACCEPTS = Accepts.{self.CONTAINER_ACCEPTS.name}") + return (f"{name} gave a container, which can't run the image: {problem}; its SANDBOX_ACCEPTS is LOADABLE, the " + "default for a provider that implements create_vm, so declare the images its containers run instead: " + f"SANDBOX_ACCEPTS = Accepts.{self.CONTAINER_ACCEPTS.name}") def url_from_sandbox(self, url: str) -> str: """``url``, of a service this process reaches, as its sandboxes reach it.""" diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 85be83ad..97e32f04 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -224,6 +224,12 @@ class _BuildingContainerAgentsUndeclared(_ContainerAgentsUndeclared): CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT +class _ContainerAgentsDeclared(_ContainerAgentsUndeclared): + """The same, but declaring that its agents' containers run a build context it builds, unlike create_container's.""" + + SANDBOX_ACCEPTS = Accepts.NAME_OR_CONTEXT + + class _ModalContainer(_ModalProvider): """Modal with its build faked, giving a container.""" @@ -285,10 +291,11 @@ async def test_a_chain_none_of_whose_sandboxes_runs_the_agents_image_names_each_ @pytest.mark.parametrize("provider_class, image", [ (_ContainerAgentsUndeclared, PULLED), (_BuildingContainerAgentsUndeclared, IMAGE), -], ids=["pulled-by-name", "built-first"]) + (_ContainerAgentsDeclared, IMAGE), +], ids=["pulled-by-name", "built-first", "declared"]) async def test_a_container_that_can_run_the_agents_image_goes_on_to_serve_it(monkeypatch, local_stores, litellm, provider_class, image): - """Its provider should say so, but the deploy it gave works, so it isn't refused.""" + """Its provider should say so, unless it declared it, but the deploy it gave works, so it isn't refused.""" monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider_class()) with pytest.raises(RuntimeError, match="No tunnel URL for port"): diff --git a/tst/unit/a2a_agent/deploy_cleanup_test.py b/tst/unit/a2a_agent/deploy_cleanup_test.py index 1c0f3a24..4b5b584c 100644 --- a/tst/unit/a2a_agent/deploy_cleanup_test.py +++ b/tst/unit/a2a_agent/deploy_cleanup_test.py @@ -13,7 +13,7 @@ class _Sandbox: - sandbox_id, type, mode, network_policy, ON_THIS_MACHINE = "sb-agent", "fake", SANDBOX_MODE_CONTAINER, None, False + sandbox_id, type, mode, network_policy = "sb-agent", "fake", SANDBOX_MODE_CONTAINER, None tunnel_urls = {DEFAULT_A2A_PORT: "http://agent"} terminated = False diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index 40f97cbd..dd94579d 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -17,12 +17,7 @@ from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox, _pull -from agent_env.providers.sandbox_providers.sandbox_provider import ( - _BUILTIN_SANDBOX_PROVIDERS, - Accepts, - SandboxProvider, - build_sandbox_provider, -) +from agent_env.providers.sandbox_providers.sandbox_provider import _BUILTIN_SANDBOX_PROVIDERS, SandboxProvider, build_sandbox_provider from agent_env.store import ImageStore, RegistryAuth from tst.util.exec_scripts import script_run @@ -362,10 +357,7 @@ async def test_a_chain_none_of_whose_vms_runs_the_image_names_each_refusal(): class _PreparingProvider(_StubProvider): - """A stub whose prepare_image takes ``prepare_seconds``, or raises ``prepare_raises``, recording each image, and - whose containers run the build contexts it prepares, as Modal's do.""" - - CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + """A stub whose prepare_image takes ``prepare_seconds``, or raises ``prepare_raises``, recording each image.""" def __init__(self, *, prepare_seconds: float = 0, prepare_raises: Exception | None = None, **kwargs): super().__init__(**kwargs)