From 9dabea1becf139efc15a2758b280149f707e2f2b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 10 Oct 2026 15:52:52 -0700 Subject: [PATCH 01/13] fix(preflight): check images the way deploys do, so tar.gzs only this machine holds reach remote VMs - Accepts.problem takes on_this_machine: a sandbox elsewhere can't pull from this machine's registry (pull_problem). Preflight, the VM loader, agent deploys, lone servers, gateway containers and container-mode deploy_sandbox all make that one check, so preflight refuses what a deploy would, in its words. - Preflight no longer refuses a tar.gz or build context that only this machine's object store holds: the VM loads it once agent-env pushes it. - An agent placed on a VM sandbox is checked the way that VM loads its image, and deploy_agent's checks on the sandbox it links (VM mode, the A2A port exposed) are shared with preflight. - install_agent refuses an agent with no build context at save too. - Each build in a VM gets a work folder of its own, so two builds of one context on one host can't delete each other's. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 26 ++-- .../artifact/artifacts/docker_image.py | 25 +++- src/agent_env/bundle/preflight.py | 117 ++++++++------- .../env_providers/env_gateway_provider.py | 9 +- .../env_providers/env_server_provider.py | 2 +- .../providers/sandbox_providers/sandbox.py | 20 +-- .../sandbox_providers/sandbox_provider.py | 13 +- .../task_step/task_steps/deploy_agent.py | 34 +++-- .../task_step/task_steps/deploy_sandbox.py | 5 + .../task_step/task_steps/install_agent.py | 29 +++- .../a2a_agent/context_only_deploy_test.py | 40 +++++- tst/unit/artifact/test_docker_image_pulled.py | 16 +++ tst/unit/bundle/preflight_test.py | 135 +++++++++++++++--- .../env_gateway_provider_test.py | 28 ++++ .../env_providers/env_server_provider_test.py | 14 ++ .../provider_capabilities_test.py | 25 +++- .../sandbox_providers/vm_sandbox_test.py | 61 +++++++- .../deploy_agent_linked_sandbox_test.py | 32 +++++ .../task_step/deploy_sandbox_image_test.py | 38 +++++ .../task_step/install_agent_guards_test.py | 33 ++++- 20 files changed, 558 insertions(+), 144 deletions(-) create mode 100644 tst/unit/task_step/deploy_agent_linked_sandbox_test.py create mode 100644 tst/unit/task_step/deploy_sandbox_image_test.py diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index cba14870..e1ca7bef 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -28,6 +28,7 @@ from agent_env.a2a_agent.staging import transfer_store from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, Accepts, SandboxProvider, all_sandbox_container_env, @@ -120,8 +121,11 @@ class A2AAgent: SNAPSHOT_METHOD_APPLY_CHANGELOG: ClassVar[str] = "apply-changelog" @staticmethod - def accepts(sandbox_provider: SandboxProvider) -> Accepts: - """The images an agent deploys from on ``sandbox_provider``: those its sandboxes run (``create_sandbox``).""" + def accepts(sandbox_provider: SandboxProvider, *, sandbox_mode: str | None = None) -> Accepts: + """The images an agent deploys from on ``sandbox_provider``: those its own sandboxes run (``create_sandbox``), + or, placed on a VM sandbox a task deployed there (``sandbox_mode`` "vm"), any that VM loads.""" + if sandbox_mode == SANDBOX_MODE_VM: + return Accepts.LOADABLE return sandbox_provider.SANDBOX_ACCEPTS @staticmethod @@ -412,7 +416,6 @@ async def deploy( attribution: Optional[Attribution] = None, ) -> DeployedA2AAgent: from agent_env.providers.sandbox_providers.sandbox_provider import ( - SANDBOX_MODE_VM, build_sandbox_provider, get_agent_sandbox_provider, ) @@ -434,15 +437,18 @@ async def deploy( resolved_env["LITELLM_BASE_URL"] = (provider or sandbox).url_from_sandbox(resolved_env["LITELLM_BASE_URL"]) merged_env = await asyncio.to_thread(self._build_merged_env, resolved_env, a2a_port) - image_name = self.docker_image_artifact.image_name - if problem := self.docker_image_artifact.by_name_problem(): - if provider is None and sandbox.mode != SANDBOX_MODE_VM: + image = self.docker_image_artifact + image_name = image.image_name + if provider is None: + if sandbox.mode != SANDBOX_MODE_VM and (problem := image.by_name_problem()): raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " f"runs its own image, never the agent's: {problem}") - links = provider.links if provider else () - if by_name := [link for link in links if self.accepts(link).problem(self.docker_image_artifact)]: - raise ValueError(f"Can't deploy agent {self.id!r} with {type(by_name[0]).__name__}, which runs an " - f"agent's image by name: {problem}") + elif refused := [(link, problem) for link in provider.links + if (problem := self.accepts(link).problem(image, on_this_machine=link.ON_THIS_MACHINE))]: + link, problem = refused[0] + how = ("whose VMs can't get it" if self.accepts(link) is Accepts.LOADABLE + else "which runs an agent's image by name") + raise ValueError(f"Can't deploy agent {self.id!r} with {type(link).__name__}, {how}: {problem}") try: if provider is not None: diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 6ea1d6e9..df74ab45 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -274,21 +274,24 @@ def context_only(self) -> bool: tag the image's build gives it.""" return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name) - def load_problem(self) -> str | None: - """Why no VM sandbox can get this image, or None when one can: a tar.gz is loaded, an image with none is pulled - when its name names a registry, and built from its build context when it has only that.""" - if self.tar_gz_object_url or names_registry(self.image_name) or self.context_only: + def load_problem(self, *, on_this_machine: bool = True) -> str | None: + """Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled + when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build + context when it has only that.""" + if self.tar_gz_object_url or self.context_only: return None + if names_registry(self.image_name): + return pull_problem(self.image_name, on_this_machine=on_this_machine) return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a " "registry to pull it from") - def by_name_problem(self) -> str | None: + def by_name_problem(self, *, on_this_machine: bool = True) -> str | None: """Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on - Docker Hub.""" + Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" - return None + return pull_problem(self.image_name, on_this_machine=on_this_machine) @classmethod async def put_from_github( @@ -466,6 +469,14 @@ def _parse_github_url(url: str) -> DockerImageArtifact._GitHubURLParts: return DockerImageArtifact._GitHubURLParts(owner=owner, repo=repo, ref=ref, path=path) +def pull_problem(image_name: str, *, on_this_machine: bool) -> str | None: + """Why a sandbox pulling ``image_name`` can't, or None when it can: one that isn't ``on_this_machine`` can't reach a + registry on this machine.""" + if on_this_machine or not is_loopback_host(registry_host_from_ref(image_name)): + return None + return f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from" + + def refuse_local_github_build(entity_id: str) -> None: """A GitHub build publishes to the configured registry, so it takes a registry id; an ``@local`` image is built from its own Dockerfile.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 759031ac..3ea3b72c 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -3,11 +3,12 @@ Each deploy, an env's, an agent's, a sandbox's or a rubrics judge's, runs on its effective sandbox provider: the run's ``--sandbox``, else the step's own field, else the config default, resolved as the step resolves it when it runs. A comma-separated provider is a chain whose deploys fall back from one provider to the next, so every provider in it must -pass. A run is refused when a deploy would fail on its provider: an image only this machine has (in its local registry, -or saved in its local object store) on a provider that isn't the local one, a VM asked of a provider that can't create -one, or containers on the local provider without a Docker daemon. A deploy_agent step that names no agent, and a judge -that names none, deploy the configured default agent, which must be in the store. An env the bundle writes is -checked from its planned env.toml. +pass. A run is refused when a deploy would fail on its provider: an image the provider can't run, by the check the +deploy itself makes, such as one a sandbox elsewhere would pull from this machine's registry; a VM asked of a provider +that can't create one; or containers on the local provider without a Docker daemon. An image's tar.gz or build context +that only this machine's object store holds isn't refused: agent-env pushes it to the sandbox. A deploy_agent step that +names no agent, and a judge that names none, deploy the configured default agent, which must be in the store. An env the +bundle writes is checked from its planned env.toml. An image the bundle builds is written in the form its deploys can run: built on this machine when they deploy on the local provider, and as its build context alone when they deploy on providers that build it, in a VM or, as Modal does, @@ -21,12 +22,12 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, field -from urllib.parse import urlparse +import functools +from collections.abc import Callable, Iterable +from dataclasses import dataclass from agent_env.a2a_agent import A2AAgent -from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, pull_problem from agent_env.config import get_config from agent_env.entity_refs import EntityKind, parse_toml_ref from agent_env.env.bootstrap import ( @@ -60,8 +61,7 @@ get_sandbox_provider, ) from agent_env.store.base import NotFoundError -from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, registry_host_from_ref -from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep, linked_sandbox_problem from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep @@ -98,8 +98,7 @@ class _Image: """An image a deploy runs, as its message names it.""" what: str - local_only: str | None # why only this machine has it, or None - refused: Mapping[Accepts, str] = field(default_factory=dict) # why a provider accepting each form can't run it + artifact: DockerImageArtifact | None = None # the store's image, checked as each provider runs it built: str | None = None # the id of the image when the bundle builds it, whose form the walk decides at the end @@ -188,7 +187,7 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: else: # a plugin's provider, or one deploy_env's own preflight refuses for this env return env_id, images, env_provider = step.env_id, deployment.images, deployment.provider - self._check(where, provider, env_provider.accepts, images) + self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds) if _local_link(provider): self.infra |= kinds self.docker_users.append(where) @@ -260,11 +259,11 @@ def _planned_images(self, env_id: str) -> tuple[list[_Image], bool]: def _planned_image(self, env_id: str, ref: Reference) -> _Image | None: what = f"env {env_id!r}'s image {ref.id!r}" if ref.id in self.built: - return _Image(what, None, built=ref.id) + return _Image(what, built=ref.id) if ref.id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first try: - return _image(what, DockerImageArtifact.get(ref.id, self._planned_version(ref.kind, ref.id, ref.version))) + return _Image(what, DockerImageArtifact.get(ref.id, self._planned_version(ref.kind, ref.id, ref.version))) except NotFoundError: return None # the plan reports a store image that isn't there @@ -273,14 +272,19 @@ def _planned_version(self, kind: EntityKind, entity_id: str, version: int | None return version if version is not None else self.plan.store_latest.get((kind, entity_id)) def _agent(self, where: str, step: DeployAgentTaskStep, sandboxes: dict[str, DeploySandboxTaskStep]) -> None: + accepts: Callable[[SandboxProvider], Accepts] = A2AAgent.accepts if step.sandbox_name: linked = sandboxes.get(step.sandbox_name) if linked is None: return # the step itself refuses an agent linked to a sandbox the task doesn't deploy + if problem := linked_sandbox_problem(step.sandbox_name, linked.sandbox_mode, linked.exposed_ports or ()): + self._problem(where, problem) + return provider = _provider(self.sandbox or linked.sandbox_type, get_sandbox_provider) + accepts = functools.partial(A2AAgent.accepts, sandbox_mode=linked.sandbox_mode) # the VM loads its image else: provider = _provider(self.sandbox or step.sandbox_type, get_agent_sandbox_provider) - self._agent_deploy(where, provider, step.a2a_agent_id, step.a2a_agent_version, "names no agent") + self._agent_deploy(where, provider, step.a2a_agent_id, step.a2a_agent_version, "names no agent", accepts) def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: if not step.use_agent_judge or step.agent_name is not None: @@ -289,14 +293,14 @@ def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: self._agent_deploy(where, provider, step.judge_a2a_agent_id, None, "names no judge agent") def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | None, version: int | None, - unnamed: str) -> None: + unnamed: str, accepts: Callable[[SandboxProvider], Accepts] = A2AAgent.accepts) -> None: if _local_link(provider): self.docker_users.append(where) if agent_id is None: self.default_agent_users.append((where, unnamed)) agent_id = get_config().get_default_a2a_agent_id() if (image := self._agent_image(agent_id, version)) is not None: - self._check(where, provider, A2AAgent.accepts, [image]) + self._check(where, provider, accepts, [image], vms_load=True) def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: provider = _provider(self.sandbox or step.sandbox_type, get_sandbox_provider) @@ -308,7 +312,9 @@ def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: if _local_link(provider): self.docker_users.append(where) if step.image and (remote := _remote_links(provider)): - self._reachable(where, remote, [_Image(f"the image {step.image}", _local_only(step.image))]) + if problem := pull_problem(step.image, on_this_machine=False): + self._problem(where, f"deploys the image {step.image} on the {_shown(remote[0])} sandbox provider: " + f"{problem}; run it with --sandbox local") # What a deploy runs @@ -319,37 +325,49 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: if (agent := self.agents.get(agent_id)) is not None: image_id, image_version = parse_toml_ref(EntityKind.ARTIFACT, agent.config.get("image")) if image_id in self.built: - return _Image(what, None, built=image_id) + return _Image(what, built=image_id) if image_id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first planned = self._planned_version(EntityKind.ARTIFACT, image_id, image_version) - return _image(what, DockerImageArtifact.get(image_id, planned)) + return _Image(what, DockerImageArtifact.get(image_id, planned)) try: - return _image(what, A2AAgent.get(agent_id, version).docker_image_artifact) + return _Image(what, A2AAgent.get(agent_id, version).docker_image_artifact) except (NotFoundError, ValueError, KeyError, TypeError): return None def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], - images: list[_Image]) -> None: + images: list[_Image], *, vms_load: bool) -> None: """Check ``images`` on each provider a deploy may run on, as ``accepts`` says the deploy runs them there, and - that one not on this machine reaches each. An image the bundle builds is noted with them, for ``_contexts`` to - choose its form.""" + that one not on this machine reaches each; ``vms_load`` when the deploy loads images on a VM provider, as an + agent's and a gateway's do. An image the bundle builds is noted with them, for ``_contexts`` to choose its + form.""" links = list(provider.links) - for form in Accepts: - if accepting := [link for link in links if accepts(link) is form]: - self._accepted(where, accepting[0], form, images) - if remote := _remote_links(provider): - self._reachable(where, remote, images) + checked: set[tuple[Accepts, bool]] = set() + for link in links: # the first link of each form, on this machine and off it + if (key := (accepts(link), link.ON_THIS_MACHINE)) not in checked: + checked.add(key) + self._accepted(where, link, key[0], images, vms_load=vms_load) for image in images: if image.built is not None: self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) - def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: list[_Image]) -> None: - """Refuse each of ``images`` a deploy can't run on ``link``, which takes images in ``form``: a VM loads an - image's tar.gz, or pulls an image with none when its name names a registry, and a provider that runs an image - by name can't run one that is only a build context, whose name is a tag only its build gives it.""" + def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: list[_Image], *, + vms_load: bool) -> None: + """Refuse each of ``images`` a deploy can't run on ``link``, which takes images in ``form``, with the check the + deploy makes (``Accepts.problem``): a VM loads an image's tar.gz, or pulls an image with none when its name + names a registry it reaches, and a provider that runs an image by name can't run one that is only a build + context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's + registry.""" for image in images: - if (refusal := image.refused.get(form)) is None: + if image.artifact is None: + continue + if (refusal := form.problem(image.artifact, on_this_machine=link.ON_THIS_MACHINE)) is None: + continue + if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine + loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" + if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE else "") + self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it " + f"with --sandbox local{loads}") continue if form is Accepts.LOADABLE: self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which can't load " @@ -361,12 +379,6 @@ def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: li self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which runs it by name: " f"{refusal}; run it on a provider {builds}") - def _reachable(self, where: str, remote: list[SandboxProvider], images: list[_Image]) -> None: - for image in images: - if image.local_only: - self._problem(where, f"deploys {image.what} on the {_shown(remote[0])} sandbox provider, which can't " - f"reach it: {image.local_only}; run it with --sandbox local") - def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) -> None: for kind in sorted(kinds): env_id = default_env_id(kind) @@ -379,10 +391,9 @@ def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) continue artifacts = ([env.db_docker_image_artifact, env.db_web_docker_image_artifact, env.db_mcp_docker_image_artifact] if isinstance(env, ServiceDBEnv) else [env.docker_image_artifact]) - images = [_image(f"the {kind} env {env_id!r}'s image {image.id!r}", image) for image in artifacts] + images = [_Image(f"the {kind} env {env_id!r}'s image {image.id!r}", image) for image in artifacts] accepts = EnvironmentGatewayProvider.accepts(provider, prepared=kind == GATEWAY) - self._accepted(where, provider, accepts, images) - self._reachable(where, [provider], images) + self._accepted(where, provider, accepts, images, vms_load=True) def _contexts(self) -> frozenset[str]: """The images the bundle builds that it writes as build contexts: those every deploy runs on providers that @@ -443,7 +454,7 @@ def _stored_images(env: Env) -> tuple[list[_Image], bool]: """The images a store env runs through a gateway, and whether it has websites.""" topology = _gateway_topology(env) images = [*topology.mcp_server_images, *(topology.website_images or [])] - return [_image(f"env {env.id!r}'s image {image.id!r}", image) for image in images], bool(topology.website_configs) + return [_Image(f"env {env.id!r}'s image {image.id!r}", image) for image in images], bool(topology.website_configs) def _state_type(step: DeployEnvTaskStep) -> str | None: @@ -485,17 +496,3 @@ def _name(provider: SandboxProvider) -> str: return name return cls.__name__ - -def _image(what: str, image: DockerImageArtifact) -> _Image: - return _Image(what, _local_only(image), {form: problem for form in Accepts if (problem := form.problem(image))}) - - -def _local_only(image: DockerImageArtifact | str) -> str | None: - """Why only this machine has ``image``: its reference names a registry on this machine, or it's saved in this - machine's object store. None when neither.""" - ref = image if isinstance(image, str) else image.image_name - if is_loopback_host(registry_host_from_ref(ref)): - return f"{ref} is in a registry on this machine" - if not isinstance(image, str) and image.tar_gz_object_url and urlparse(image.tar_gz_object_url).scheme == "file": - return f"{image.id!r} is saved in this machine's object store" - return None diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 3ff128e8..aca07ebd 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -882,9 +882,14 @@ async def _deploy_via_containers( config = get_config() gateway_env = Env.get(config.default_gateway_env_id) - # Each image is prepared (one that is only a build context, built) before anything is created, so a failed - # build leaves nothing. + # Each image is checked, and prepared (one that is only a build context, built), before anything is created, so + # an image the provider can't run, or a failed build, leaves nothing. images = [gateway_env.docker_image_artifact, *mcp_server_images] + accepts = self.accepts(sandbox_provider) + if problems := [problem for image in images + if (problem := accepts.problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE))]: + raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, which runs its images by " + f"name: {'; '.join(problems)}") await asyncio.gather(*(sandbox_provider.prepare_image(image, attribution=attribution) for image in images)) deploy = _ContainerDeploy( diff --git a/src/agent_env/providers/env_providers/env_server_provider.py b/src/agent_env/providers/env_providers/env_server_provider.py index 09a76c96..61f31ec1 100644 --- a/src/agent_env/providers/env_providers/env_server_provider.py +++ b/src/agent_env/providers/env_providers/env_server_provider.py @@ -75,7 +75,7 @@ async def _deploy_server( name = env.environment_name image = env.docker_image_artifact - if problem := self.accepts(sandbox_provider).problem(image): + if problem := self.accepts(sandbox_provider).problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE): raise ValueError(f"Can't deploy {name!r} as a server on {type(sandbox_provider).__name__}, which runs its " f"image by name: {problem}") await sandbox_provider.prepare_image(image, attribution=attribution) diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index d0b778b0..a8507a41 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -290,11 +290,13 @@ async def wait_for_vm(self) -> None: async def load_docker_images(self, artifacts: list) -> None: """Put each DockerImageArtifact's image on the VM: those with a tar.gz are loaded from it, in parallel, those - with only a build context are built from it, and the rest are pulled by image name. An image no sandbox can get - is refused before anything is loaded.""" + with only a build context are built from it, and the rest are pulled by image name. An image this VM can't get, + such as one to pull from a registry on this machine when the VM isn't on it, is refused before anything is + loaded.""" if not artifacts: return - if problems := [problem for artifact in artifacts if (problem := artifact.load_problem())]: + if problems := [problem for artifact in artifacts + if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE))]: raise RuntimeError(f"Can't load images: {'; '.join(problems)}") if tarballs := [artifact for artifact in artifacts if artifact.tar_gz_object_url]: await self._load_tarballs(tarballs) @@ -311,14 +313,16 @@ async def build_images(self, artifacts: list) -> None: builds: dict[str, list] = {} for artifact in artifacts: builds.setdefault(artifact.source_digest or artifact.build_context_object_url, []).append(artifact) - for source, images in builds.items(): - await self._build_image(source, images) + for images in builds.values(): + await self._build_image(images) - async def _build_image(self, source: str, images: list) -> None: - """Download the context ``images`` share, extract it, and ``docker build`` it, tagged with each image's name.""" + async def _build_image(self, images: list) -> None: + """Download the context ``images`` share, extract it, and ``docker build`` it, tagged with each image's name. + The work folder is this build's own, since a host can run builds of one context at once: two of the local + provider's sandboxes, or two deploys onto one VM.""" first = images[0] names = list(dict.fromkeys(image.image_name for image in images)) - work = f"/tmp/agent-env-build-{hashlib.sha256(source.encode()).hexdigest()[:16]}" + work = f"/tmp/agent-env-build-{uuid.uuid4().hex}" platform = first.platform or DEFAULT_BUILD_PLATFORM tags = " ".join(f"-t {shlex.quote(name)}" for name in names) logger.info(f"Building {', '.join(names)} from its build context, for {platform}...") diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 994d96fc..f19475fd 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -44,13 +44,14 @@ class Accepts(Enum): NAME = "name" # one it starts by its name, which an image that is only a build context gets only from a build NAME_OR_CONTEXT = "name_or_context" # one it starts by its name, or a build context it builds first (prepare_image) - def problem(self, image: DockerImageArtifact) -> str | None: - """Why a provider that accepts these can't run ``image``, or None when it can.""" + def problem(self, image: DockerImageArtifact, *, on_this_machine: bool = True) -> str | None: + """Why a provider that accepts these, and whose sandboxes are ``on_this_machine`` or not, can't run ``image``, + or None when it can.""" if self is Accepts.LOADABLE: - return image.load_problem() - if self is Accepts.NAME: - return image.by_name_problem() - return None + return image.load_problem(on_this_machine=on_this_machine) + if self is Accepts.NAME_OR_CONTEXT and image.context_only: + return None + return image.by_name_problem(on_this_machine=on_this_machine) class SandboxProvider(ABC): diff --git a/src/agent_env/task_step/task_steps/deploy_agent.py b/src/agent_env/task_step/task_steps/deploy_agent.py index 9555458a..4cc81176 100644 --- a/src/agent_env/task_step/task_steps/deploy_agent.py +++ b/src/agent_env/task_step/task_steps/deploy_agent.py @@ -3,6 +3,7 @@ import asyncio import logging +from collections.abc import Iterable from typing import ClassVar, Optional import httpx @@ -28,7 +29,12 @@ from agent_env.env.env import DeployedEnv, DeployedSandboxEnv, Env from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER from agent_env.providers.sandbox_providers.local_sandbox import host_url_for -from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url, sandbox_request_headers_for_url +from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, + reachable_url, + sandbox_request_headers_for_url, +) from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity_refs import EntityRef @@ -39,6 +45,18 @@ logger = logging.getLogger(__name__) +def linked_sandbox_problem(sandbox_name: str, sandbox_mode: str, ports: Iterable[int | str]) -> str | None: + """Why an agent can't be placed on the sandbox ``sandbox_name``, deployed in ``sandbox_mode`` with ``ports`` + reachable, or None when it can: only a VM can host another container, and the agent is reached on its A2A port.""" + if sandbox_mode != SANDBOX_MODE_VM: + return (f"Cannot link agent to sandbox '{sandbox_name}' (mode={sandbox_mode!r}); only VM-mode sandboxes can " + "host an additional agent container") + if str(DEFAULT_A2A_PORT) not in {str(port) for port in ports}: + return (f"Sandbox '{sandbox_name}' does not expose port {DEFAULT_A2A_PORT}; re-deploy DeploySandboxTaskStep " + f"with exposed_ports=[{DEFAULT_A2A_PORT}]") + return None + + def _mcp_add_request(mcp_ext: dict) -> dict: """The request contract the card declares for mcp-config `add`.""" request = mcp_ext.get("params", {}).get("methods", {}).get("add", {}).get("request", {}) @@ -252,9 +270,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: async def _execute(self, context: TaskStepContext, deployed_agents: list) -> TaskStepContext: from agent_env.a2a_agent import A2AAgent - from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT from agent_env.providers.sandbox_providers.sandbox_provider import ( - SANDBOX_MODE_VM, build_sandbox_provider, get_sandbox_provider, ) @@ -274,16 +290,8 @@ async def _execute(self, context: TaskStepContext, deployed_agents: list) -> Tas f"Sandbox '{self.sandbox_name}' not found in context.deployed_sandboxes; " f"deploy it with DeploySandboxTaskStep before this step" ) - if ds.sandbox_mode != SANDBOX_MODE_VM: - raise RuntimeError( - f"Cannot link agent to sandbox '{self.sandbox_name}' (mode={ds.sandbox_mode!r}); " - f"only VM-mode sandboxes can host an additional agent container" - ) - if not ds.tunnel_urls or str(DEFAULT_A2A_PORT) not in ds.tunnel_urls: - raise RuntimeError( - f"Sandbox '{self.sandbox_name}' does not expose port {DEFAULT_A2A_PORT}; " - f"re-deploy DeploySandboxTaskStep with exposed_ports=[{DEFAULT_A2A_PORT}]" - ) + if problem := linked_sandbox_problem(self.sandbox_name, ds.sandbox_mode, ds.tunnel_urls or ()): + raise RuntimeError(problem) provider = ( build_sandbox_provider(ds.sandbox_type) if ds.sandbox_type else get_sandbox_provider() ) diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index 46a90237..abc5bb94 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -5,6 +5,7 @@ import logging from typing import ClassVar, Optional +from agent_env.artifact.artifacts.docker_image import pull_problem from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import DeployedSandbox, TaskStepContext from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -140,6 +141,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: else: if self.image is None or self.port is None: raise ValueError("container-mode sandbox requires `image` and `port`") + if problems := [(link, problem) for link in provider.links + if (problem := pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE))]: + link, problem = problems[0] + raise ValueError(f"Can't deploy sandbox '{self.sandbox_name}' with {type(link).__name__}: {problem}") sandbox = await provider.create_sandbox( image_name=self.image, port=self.port, diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 1c5f6b6b..a8aae484 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -21,7 +21,9 @@ import httpx +from agent_env.a2a_agent import A2AAgent from agent_env.providers.sandbox_providers.local_sandbox import host_url_for +from agent_env.store.base import NotFoundError from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -34,6 +36,16 @@ REQUIRED_PARAMS_HOST_KEY = "required_params_host" A2A_PORT_KEY = "a2a_port" + +def _install_problem(agent: A2AAgent) -> str | None: + """Why ``agent`` can't be installed, or None: install/v1 copies the agent's source files from its image's build + context, which an image registered by reference has none of.""" + if agent.docker_image_artifact.build_context_object_url: + return None + return (f"Agent {agent.id} v{agent.version} has no build_context_object_url on its docker_image_artifact; " + "install/v1 needs the build context (agent source files) to install into the task container") + + def _build_param_resolvers( *, container_name: Optional[str], @@ -140,8 +152,16 @@ def from_dict(cls, data: dict) -> InstallAgentTaskStep: workspace_dir=data.get("workspace_dir"), ) + def preflight(self) -> list[str]: + """An agent with no build context to install, reported at save instead.""" + try: + agent = A2AAgent.get(self.a2a_agent_id, self.a2a_agent_version) + except (NotFoundError, ValueError, KeyError, TypeError) as e: + return [f"install_agent '{self.id}': agent '{self.a2a_agent_id}' can't be loaded: {e}"] + problem = _install_problem(agent) + return [f"install_agent '{self.id}': {problem}"] if problem else [] + async def execute(self, context: TaskStepContext) -> TaskStepContext: - from agent_env.a2a_agent import A2AAgent from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, build_sandbox_provider, @@ -187,11 +207,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id = user_overrides.get("a2a_agent_id") or self.a2a_agent_id agent = A2AAgent.get(a2a_agent_id, self.a2a_agent_version) - if not agent.docker_image_artifact.build_context_object_url: - raise RuntimeError( - f"Agent {agent.id} v{agent.version} has no build_context_object_url on its docker_image_artifact; " - f"install/v1 needs the build context (agent source files) to install into the task container" - ) + if problem := _install_problem(agent): + raise RuntimeError(problem) logger.info(f"Loading agent image {agent.docker_image_artifact.image_name} onto VM (needed to read agent card)...") await sandbox.load_docker_images([agent.docker_image_artifact]) diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 9469736b..947c1635 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -1,5 +1,7 @@ """An agent whose image is only a build context deploys only where it's built, in a VM or by Modal: a provider that runs an -agent in a container by its image's name refuses it before creating anything, and Modal is told of it first.""" +agent in a container by its image's name refuses it before creating anything, and Modal is told of it first. An image a +provider can't get otherwise, such as one a sandbox elsewhere would pull from this machine's registry, is refused before +anything is created too.""" import pytest @@ -129,3 +131,39 @@ async def test_an_existing_container_sandbox_refuses_it_since_it_never_runs_the_ with pytest.raises(ValueError, match="Can't deploy agent 'solver' on the container sandbox 'sb-container', which runs its " "own image, never the agent's: 'solver-image' v2 is only a build context"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox=_ContainerSandbox()) + + +PULLED_HERE = DockerImageArtifact(id="solver-image", version=2, description="d", + image_name="localhost:5000/local/solver:v2") +HERE = "localhost:5000/local/solver:v2 is in a registry on this machine, which a sandbox elsewhere can't pull from" +NO_REGISTRY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="solver:v2") + + +class _LocalPulls(LocalSandboxProvider): + async def create_sandbox(self, **_): + raise _Created + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider, image, refusal", [ + (_VmProvider(), PULLED_HERE, f"with _VmProvider, whose VMs can't get it: {HERE}"), + (_ModalProvider(), PULLED_HERE, f"with _ModalProvider, which runs an agent's image by name: {HERE}"), + (_VmProvider(), NO_REGISTRY, "with _VmProvider, whose VMs can't get it: 'solver-image' v2 has no tar.gz, and its " + "image name 'solver:v2' doesn't name a registry to pull it from"), +], ids=["vm-elsewhere", "modal", "vm-nothing-to-pull"]) +async def test_an_image_a_provider_cant_get_is_refused_before_anything_is_created(monkeypatch, local_stores, litellm, + provider, image, refusal): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(ValueError, match=f"Can't deploy agent 'solver' {refusal}"): + await A2AAgent(id="solver", version=1, docker_image_artifact=image).deploy(sandbox_type="any") + assert getattr(provider, "steps", []) == [] + + +@pytest.mark.asyncio +async def test_the_local_provider_is_asked_for_a_sandbox_for_an_image_in_its_own_registry(monkeypatch, local_stores, + litellm): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: _LocalPulls()) + + with pytest.raises(_Created): + await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox_type="any") diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index 8c37eee9..b9cf2620 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -42,6 +42,22 @@ def test_an_image_loads_from_its_tar_gz_or_by_pulling_a_name_that_names_its_regi assert image.load_problem() == problem +@pytest.mark.parametrize("image_name", ["localhost:5000/img:v1", "127.0.0.1:5000/img:v1"]) +def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): + image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name) + here = f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from" + + assert (image.load_problem(), image.by_name_problem()) == (None, None) + assert image.load_problem(on_this_machine=False) == image.by_name_problem(on_this_machine=False) == here + + +def test_a_vm_elsewhere_loads_the_tar_gz_of_an_image_named_for_this_machines_registry(): + image = DockerImageArtifact(id="img", version=1, description="d", image_name="localhost:5000/img:v1", + tar_gz_object_url="file:///state/img.tar.gz") + + assert image.load_problem(on_this_machine=False) is None + + def test_one_with_no_tar_gz_has_no_bytes_to_load(): with pytest.raises(ValueError, match="has no tar.gz; its image is pulled from ghcr.io/example/image@sha256:"): DockerImageArtifact(id="img", version=1, description="d", image_name=REF).load() diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 507c8a3e..31d8cf16 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -1,6 +1,7 @@ -"""Where a bundle run's tasks deploy, checked before anything is written: images only this machine has on another -provider, VMs asked of a provider that can't create one, containers on the local provider without Docker, the default -agent, and the infra envs a gateway deploy on the local provider needs.""" +"""Where a bundle run's tasks deploy, checked before anything is written: images a provider can't run as it runs them +(one a sandbox elsewhere would pull from this machine's registry among them), VMs asked of a provider that can't create +one, containers on the local provider without Docker, the default agent, and the infra envs a gateway deploy on the +local provider needs.""" import asyncio import json @@ -39,6 +40,8 @@ LOCAL = ("localhost:5000/local/img:v1", "file:///state/img-v1.tar.gz") REMOTE = ("123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v1", "s3://bucket/img-v1.tar.gz") +PULLED_HERE = ("localhost:5000/local/img:v1", None) # no tar.gz, so a sandbox pulls it from this machine's registry +HERE = "localhost:5000/local/img:v1 is in a registry on this machine, which a sandbox elsewhere can't pull from" @pytest.fixture @@ -103,24 +106,56 @@ def _problems(fn): @pytest.mark.parametrize("sandbox", ["modal", "local,modal"]) -def test_an_image_only_this_machine_has_is_refused_on_another_provider_before_anything_is_written(bundle_dir, sandbox): +def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere_pulls_it_before_anything_is_written( + bundle_dir, sandbox): _agent("solver") _task(bundle_dir, [AGENT]) assert _problems(lambda: run_bundle(bundle_dir, sandbox=sandbox)) == [ - "tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider, which can't reach it: " - "localhost:5000/local/img:v1 is in a registry on this machine; run it with --sandbox local", + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider: {HERE}; run it " + "with --sandbox local, or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm", ] assert not local_store().path.exists() -def test_an_image_saved_only_in_this_machines_object_store_is_refused_too(bundle_dir): +@pytest.mark.parametrize("where", [LOCAL, (REMOTE[0], LOCAL[1])]) +def test_a_vm_elsewhere_loads_a_tar_gz_only_this_machine_holds_which_agent_env_pushes_it(bundle_dir, where): + """It never pulls the image, so a registry on this machine doesn't matter; the tar.gz goes over the VM's exec.""" + _agent("solver", where) + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] + + +@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("modal", True), ("local", False)]) +def test_an_image_with_no_tar_gz_in_this_machines_registry_runs_only_on_the_local_provider(bundle_dir, sandbox, refused): + _agent("solver", PULLED_HERE) + _task(bundle_dir, [AGENT]) + + if refused: + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {sandbox!r} sandbox provider: {HERE}; " + "run it with --sandbox local"] + else: + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] + + +@pytest.mark.parametrize("sandbox", ["modal", "modal_vm"]) +def test_a_lone_server_runs_its_image_by_name_wherever_it_deploys_so_no_vm_provider_is_offered(bundle_dir, sandbox): + """A VM provider's containers pull by name too: only an agent or a gateway loads a tar.gz in a VM.""" + _env("crm", LOCAL, env_provider_type="server") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'env': deploys env 'crm''s image 'crm-image' on the {sandbox!r} sandbox provider: {HERE}; " + "run it with --sandbox local"] + + +def test_a_remote_image_with_a_tar_gz_only_this_machine_holds_is_pulled_where_a_sandbox_runs_it_by_name(bundle_dir): _agent("solver", (REMOTE[0], LOCAL[1])) _task(bundle_dir, [AGENT]) - (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) - assert problem.endswith("can't reach it: 'solver-image' is saved in this machine's object store; run it with " - "--sandbox local") + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal").runs] == ["t"] def test_a_remote_image_runs_anywhere_and_a_local_one_on_the_local_provider(bundle_dir): @@ -223,8 +258,9 @@ def test_a_sandbox_image_in_this_machines_registry_is_refused_on_another_provide "image": "python:3.12"}]) assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ - "tasks/t.json: step 'box': deploys the image localhost:5000/box:v1 on the 'modal' sandbox provider, which can't " - "reach it: localhost:5000/box:v1 is in a registry on this machine; run it with --sandbox local", + "tasks/t.json: step 'box': deploys the image localhost:5000/box:v1 on the 'modal' sandbox provider: " + "localhost:5000/box:v1 is in a registry on this machine, which a sandbox elsewhere can't pull from; run it with " + "--sandbox local", ] @@ -343,6 +379,65 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser "built, not pulled; run it on a provider whose VMs build it, such as --sandbox modal_vm"] +# Agents placed on a sandbox the task deploys + +BOX = {"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", "exposed_ports": [8000]} +PLACED = {**AGENT, "sandbox_name": "box"} + + +@pytest.mark.parametrize("sandbox", ["local", "modal_vm"]) +def test_an_agent_placed_on_a_vm_sandbox_runs_any_image_that_vm_loads(bundle_dir, sandbox): + """The VM loads the agent's image, so one that is only a build context runs there, on the local provider too, whose + agents run by image name only on sandboxes of their own.""" + with namespace_routing(): + A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) + _task(bundle_dir, [BOX, PLACED]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] + + +def test_an_agent_placed_on_a_vm_sandbox_elsewhere_is_refused_an_image_that_vm_cant_pull(bundle_dir): + _agent("solver", PULLED_HERE) + _task(bundle_dir, [BOX, PLACED]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal_vm' sandbox provider: {HERE}; run it " + "with --sandbox local"] + + +@pytest.mark.parametrize("box, problem", [ + ({**BOX, "sandbox_mode": "container", "image": "python:3.12", "port": 8000}, + "Cannot link agent to sandbox 'box' (mode='container'); only VM-mode sandboxes can host an additional agent " + "container"), + ({**BOX, "exposed_ports": [9000]}, + "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with exposed_ports=[8000]"), +], ids=["container", "no-a2a-port"]) +def test_an_agent_is_refused_a_sandbox_deploy_agent_cant_place_it_on_in_the_steps_own_words(bundle_dir, box, problem): + _agent("solver", REMOTE) + _task(bundle_dir, [box, PLACED]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [f"tasks/t.json: step 'agent': {problem}"] + + +# Preflight refuses with the words the deploy itself would + + +@pytest.mark.parametrize("where, sandbox", [(PULLED_HERE, "modal_vm"), (PULLED_HERE, "modal"), (LOCAL, "modal"), + (NO_REGISTRY, "modal_vm")]) +def test_preflight_refuses_an_agent_for_the_reason_its_deploy_would(bundle_dir, monkeypatch, where, sandbox): + """Both make the one check (``Accepts.problem``), so a deploy preflight passes isn't refused for its image.""" + agent = _agent("solver", where) + _task(bundle_dir, [AGENT]) + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) + monkeypatch.setenv("LITELLM_API_KEY", "k") + monkeypatch.setenv("LITELLM_BASE_URL", "http://litellm.example/v1") + + with pytest.raises(ValueError) as refused: + asyncio.run(agent.deploy(sandbox_type=sandbox)) + + assert str(refused.value).split(": ", 1)[1] in problem + + # What a provider can create @@ -556,12 +651,13 @@ def test_a_gateway_deploy_on_another_provider_needs_infra_it_can_reach(bundle_di (problem, *_) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) assert problem.startswith("tasks/t.json: step 'env': deploys on the 'modal_vm' sandbox provider, which needs the " "gateway env 'default', and the store doesn't hold it") - _infra() + _infra(PULLED_HERE) problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " - "'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " - "registry on this machine; run it with --sandbox local") + f"'modal_vm' sandbox provider: {HERE}; run it with --sandbox local") assert len(problems) == 4 # the gateway's image, and the service-db's three + _infra() # tar.gzs only this machine holds, which the gateway's VM loads once agent-env pushes them + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] def test_infra_on_another_provider_with_no_tarball_and_no_registry_to_pull_it_from_is_refused(bundle_dir): @@ -579,17 +675,16 @@ def test_infra_on_another_provider_with_no_tarball_and_no_registry_to_pull_it_fr def test_a_problem_several_deploys_share_is_reported_once_naming_the_first(bundle_dir): - _env("crm") + _env("crm", PULLED_HERE) with namespace_routing(): - GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default")) - db = _image("db") # one image for all three of the service-db's, so each deploy finds its problem three times + GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default", PULLED_HERE)) + db = _image("db", PULLED_HERE) # one for all three of the service-db's, so each deploy finds its problem thrice ServiceDBEnv.put(id="default-db", db_docker_image_artifact=db, db_web_docker_image_artifact=db, db_mcp_docker_image_artifact=db) for name in ("a", "b", "c"): _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}], name=name) - unreachable = ("on the 'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " - "registry on this machine; run it with --sandbox local") + unreachable = f"on the 'modal_vm' sandbox provider: {HERE}; run it with --sandbox local" assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [ f"tasks/a.json: step 'env' (and 2 more deploys): deploys env 'crm''s image 'crm-image' {unreachable}", f"tasks/a.json: step 'env' (and 2 more deploys): deploys the gateway env 'default''s image 'gateway-default' " diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 63d8dc84..9d8c73df 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -190,6 +190,34 @@ async def build(image, *, attribution=None): gp._build_local_store.assert_not_awaited() +_IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="slack-image", version=2, description="d", + image_name="localhost:5000/local/slack:v2") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("gateway_image, server_image", [(_IN_THIS_MACHINES_REGISTRY, _image("mcp-slack")), + (_image("agent-gateway"), _IN_THIS_MACHINES_REGISTRY)], + ids=["gateway", "server"]) +async def test_container_mode_refuses_an_image_in_this_machines_registry_before_preparing_anything(gateway_image, + server_image): + provider = ModalSandboxProvider() + gp = EnvironmentGatewayProvider() + gp._build_local_store = AsyncMock() + + with patch.object(provider, "prepare_image", side_effect=AssertionError("prepared an image")), \ + patch.object(provider, "create_container", side_effect=AssertionError("created a container")), \ + patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=gateway_image)): + with pytest.raises(ValueError, match="Can't deploy a gateway on ModalSandboxProvider, which runs its images by " + "name: localhost:5000/local/slack:v2 is in a registry on this machine, " + "which a sandbox elsewhere can't pull from"): + await gp._deploy_via_containers( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image=server_image.image_name, environment_name="slack")], + mcp_server_images=[server_image], gateway_port=18765, website_configs=None, + gateway_mode=MagicMock(value="performance"), ttl_seconds=60, disk_size_gb=10, + ) + gp._build_local_store.assert_not_awaited() + + @pytest.mark.asyncio async def test_container_mode_constructs_internal_mcp_servers_url_format(): """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" diff --git a/tst/unit/providers/env_providers/env_server_provider_test.py b/tst/unit/providers/env_providers/env_server_provider_test.py index 451332ee..ed501a79 100644 --- a/tst/unit/providers/env_providers/env_server_provider_test.py +++ b/tst/unit/providers/env_providers/env_server_provider_test.py @@ -75,6 +75,20 @@ async def test_a_server_whose_image_is_only_a_build_context_is_refused_before_an assert run.calls == [] +_IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="slack-image", version=3, description="d", + image_name="localhost:5000/local/slack:v3") + + +@pytest.mark.asyncio +async def test_a_server_whose_image_is_in_this_machines_registry_is_refused_by_a_provider_elsewhere(): + run = await _run_server_deploy(env=SimpleNamespace(**{**vars(_ENV), "docker_image_artifact": _IN_THIS_MACHINES_REGISTRY})) + + assert ("Can't deploy 'slack' as a server on _FakeSandboxProvider, which runs its image by name: " + "localhost:5000/local/slack:v3 is in a registry on this machine, which a sandbox elsewhere can't pull from" + ) in str(run.result) + assert run.calls == [] + + class _ModalProvider(ModalSandboxProvider): """Modal with its build and its container faked, recording the order they're asked for.""" diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 877131ea..a0b9fbd8 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -97,13 +97,34 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p NO_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2") +IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", + image_name="localhost:5000/local/img:v2") +WITH_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="localhost:5000/local/img:v2", + tar_gz_object_url="file:///state/img.tar.gz") +HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" + + @pytest.mark.parametrize("image, accepts, problem", [ (CONTEXT_ONLY, LOADABLE, None), (CONTEXT_ONLY, NAME_OR_CONTEXT, None), (CONTEXT_ONLY, NAME, "only a build context"), (NO_REGISTRY, LOADABLE, "doesn't name a registry"), (NO_REGISTRY, NAME, None), (NO_REGISTRY, NAME_OR_CONTEXT, None), ]) -def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem): - found = accepts.problem(image) +@pytest.mark.parametrize("on_this_machine", [True, False]) +def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on_this_machine): + found = accepts.problem(image, on_this_machine=on_this_machine) + assert (found is None) if problem is None else (problem in found) + + +@pytest.mark.parametrize("image, accepts, problem", [ + (IN_THIS_MACHINES_REGISTRY, LOADABLE, HERE), (IN_THIS_MACHINES_REGISTRY, NAME, HERE), + (IN_THIS_MACHINES_REGISTRY, NAME_OR_CONTEXT, HERE), + (WITH_TAR_GZ, LOADABLE, None), (WITH_TAR_GZ, NAME, HERE), (WITH_TAR_GZ, NAME_OR_CONTEXT, HERE), +]) +def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it_can(image, accepts, problem): + """A VM loads an image's tar.gz, which agent-env pushes to it, and pulls one only when it has none.""" + found = accepts.problem(image, on_this_machine=False) assert (found is None) if problem is None else (problem in found) + assert accepts.problem(image, on_this_machine=True) is None + assert accepts.problem(image) is None # a caller that doesn't say gets the check as it was def _concrete_classes() -> set[str]: diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 6a741fdc..85065ea3 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -1,6 +1,7 @@ import asyncio import hashlib import io +import re import threading import time from concurrent.futures import ThreadPoolExecutor @@ -185,6 +186,36 @@ async def test_an_image_no_sandbox_can_get_is_refused_before_anything_is_loaded( assert sandbox.scripts == [] +class _VmOnThisMachine(_RecordingVmSandbox): + """A VM sandbox on this machine, as the local provider's are.""" + + ON_THIS_MACHINE = True + + +@pytest.mark.asyncio +async def test_a_vm_elsewhere_refuses_an_image_to_pull_from_this_machines_registry_before_anything_is_loaded( + signing_store): + sandbox = _RecordingVmSandbox(images_stdout="a\n") + + with pytest.raises(RuntimeError, match="Can't load images: localhost:5000/local/img:v1 is in a registry on this " + "machine, which a sandbox elsewhere can't pull from"): + await sandbox.load_docker_images([_image("s3://bucket/a.tar.gz", "a:1"), + _image(None, "localhost:5000/local/img:v1")]) + + assert sandbox.scripts == [] + + +@pytest.mark.asyncio +async def test_a_vm_on_this_machine_pulls_from_its_registry_and_one_elsewhere_loads_a_tar_gz_named_for_it(signing_store): + here = _VmOnThisMachine(images_stdout="localhost:5000/local/img\n") + await here.load_docker_images([_image(None, "localhost:5000/local/img:v1")]) + assert here.scripts == ["docker pull localhost:5000/local/img:v1"] + + elsewhere = _RecordingVmSandbox(images_stdout="localhost:5000/local/img\n") + await elsewhere.load_docker_images([_image("s3://bucket/a.tar.gz", "localhost:5000/local/img:v1")]) + assert "docker load" in elsewhere.scripts[0] and "docker pull" not in elsewhere.scripts[0] + + @pytest.mark.asyncio async def test_a_login_is_minted_once_per_registry(): minted = [] @@ -604,7 +635,7 @@ async def push(sandbox, store, object_url, vm_path): await sandbox.load_docker_images([image]) - work = _work(image.source_digest) + (work,) = _works(sandbox) assert image.build_context_object_url.startswith("file://") assert pushed == [(image.build_context_object_url, f"{work}/context.tar.gz")] assert f"docker build --platform linux/amd64 -f Dockerfile -t {image.image_name} ." in sandbox.scripts[-2] @@ -617,17 +648,19 @@ def _context_image(id: str, digest: str | None, *, platform: str | None = "linux platform=platform, source_digest=digest) -def _work(source: str) -> str: - return f"/tmp/agent-env-build-{hashlib.sha256(source.encode()).hexdigest()[:16]}" +def _works(sandbox: _RecordingVmSandbox) -> list[str]: + """The work folder of each build ``sandbox`` ran, in order.""" + return [match[1] for script in sandbox.scripts + if (match := re.fullmatch(r"rm -rf (/tmp/agent-env-build-[0-9a-f]{32}) && mkdir -p \1/context", script))] @pytest.mark.asyncio async def test_a_context_only_image_is_built_in_the_vm_from_its_context(signing_store): sandbox, digest = _RecordingVmSandbox(), "sha256:" + "1" * 64 - work = _work(digest) await sandbox.load_docker_images([_context_image("a", digest)]) + (work,) = _works(sandbox) assert sandbox.scripts == [ f"rm -rf {work} && mkdir -p {work}/context", f"curl -fsSL {sandbox_module.CURL_RETRY_FLAGS} https://signed/build-context.tar.gz -o {work}/context.tar.gz", @@ -648,8 +681,21 @@ async def test_images_with_one_source_share_a_build_and_builds_run_one_at_a_time assert len(builds) == 2 assert builds[0].endswith("-t local/a-0123456789ab:v1 -t local/c-0123456789ab:v1 .") assert builds[1].endswith("-t local/b-0123456789ab:v1 .") - assert sandbox.scripts.index(f"rm -rf {_work(shared)}") < sandbox.scripts.index(f"rm -rf {_work(other)} && mkdir -p " - f"{_work(other)}/context") + first, second = _works(sandbox) + assert sandbox.scripts.index(f"rm -rf {first}") < sandbox.scripts.index(f"rm -rf {second} && mkdir -p {second}/context") + + +@pytest.mark.asyncio +async def test_two_builds_of_one_context_on_one_host_each_have_a_work_folder_of_their_own(signing_store): + """Two of the local provider's sandboxes share this machine's /tmp, and two deploys can build on one VM: a folder + named after the context alone let one build's cleanup delete the other's context mid-build.""" + sandbox, digest = _RecordingVmSandbox(), "sha256:" + "1" * 64 + + await sandbox.load_docker_images([_context_image("a", digest)]) + await sandbox.load_docker_images([_context_image("a", digest)]) + + first, second = _works(sandbox) + assert first != second @pytest.mark.asyncio @@ -680,7 +726,8 @@ async def test_a_failed_build_names_its_image_and_still_removes_its_folder(signi with pytest.raises(RuntimeError, match="(?s)Building local/a-0123456789ab:v1 from its build context failed: .*not found"): await sandbox.load_docker_images([_context_image("a", digest)]) - assert sandbox.scripts[-1] == f"rm -rf {_work(digest)}" + (work,) = _works(sandbox) + assert sandbox.scripts[-1] == f"rm -rf {work}" @pytest.mark.asyncio diff --git a/tst/unit/task_step/deploy_agent_linked_sandbox_test.py b/tst/unit/task_step/deploy_agent_linked_sandbox_test.py new file mode 100644 index 00000000..82cb77a4 --- /dev/null +++ b/tst/unit/task_step/deploy_agent_linked_sandbox_test.py @@ -0,0 +1,32 @@ +"""deploy_agent places an agent only on a VM sandbox that exposes its A2A port, and refuses any other before deploying +anything, in the words a bundle run's preflight refuses it with too.""" + +import re + +import pytest + +from agent_env.task_step.context import DeployedSandbox, TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep, linked_sandbox_problem + + +@pytest.mark.parametrize("mode, tunnel_urls, problem", [ + ("container", {"8000": "https://t-8000"}, "Cannot link agent to sandbox 'box' (mode='container'); only VM-mode " + "sandboxes can host an additional agent container"), + ("vm", {"9000": "https://t-9000"}, "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with " + "exposed_ports=[8000]"), + ("vm", None, "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with exposed_ports=[8000]"), +], ids=["container", "other-port", "no-ports"]) +@pytest.mark.asyncio +async def test_an_agent_is_refused_a_sandbox_it_cant_be_placed_on(mode, tunnel_urls, problem): + step = DeployAgentTaskStep(id="agent", version=1, env_ids=[], a2a_agent_id="solver", sandbox_name="box") + context = TaskStepContext() + context.deployed_sandboxes.append(DeployedSandbox(sandbox_name="box", sandbox_id="sb-1", sandbox_mode=mode, + tunnel_urls=tunnel_urls)) + + with pytest.raises(RuntimeError, match=f"^{re.escape(problem)}$"): + await step.execute(context) + + +def test_a_vm_sandbox_exposing_the_a2a_port_takes_the_agent(): + assert linked_sandbox_problem("box", "vm", {"8000": "https://t-8000"}) is None + assert linked_sandbox_problem("box", "vm", [8000]) is None # as a deploy_sandbox step's exposed_ports names it diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py new file mode 100644 index 00000000..0c889387 --- /dev/null +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -0,0 +1,38 @@ +"""A container-mode deploy_sandbox runs its image by name, so a provider whose sandboxes are elsewhere can't pull one from +this machine's registry; one on this machine can.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep + + +def _provider(on_this_machine: bool): + sandbox = AsyncMock() + sandbox.sandbox_id, sandbox.mode, sandbox.type = "sb-1", "container", "local" + sandbox.tunnel_urls, sandbox.vnc_url, sandbox.network_policy = {}, None, NetworkPolicy() + provider = AsyncMock(ON_THIS_MACHINE=on_this_machine) + provider.links = (provider,) + provider.create_sandbox.return_value = sandbox + return provider + + +@pytest.mark.asyncio +@pytest.mark.parametrize("on_this_machine", [False, True], ids=["elsewhere", "on-this-machine"]) +async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_only_on_this_machine(on_this_machine): + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="localhost:5000/box:v1", port=80) + provider = _provider(on_this_machine) + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", return_value=provider): + if on_this_machine: + await step.execute(TaskStepContext()) + provider.create_sandbox.assert_awaited_once() + else: + with pytest.raises(ValueError, match="Can't deploy sandbox 'box' with AsyncMock: localhost:5000/box:v1 is in a " + "registry on this machine, which a sandbox elsewhere can't pull from"): + await step.execute(TaskStepContext()) + provider.create_sandbox.assert_not_awaited() diff --git a/tst/unit/task_step/install_agent_guards_test.py b/tst/unit/task_step/install_agent_guards_test.py index b6a521a8..5fd12bf3 100644 --- a/tst/unit/task_step/install_agent_guards_test.py +++ b/tst/unit/task_step/install_agent_guards_test.py @@ -1,5 +1,9 @@ -"""Same-sandbox tunnel-collision guard for multi-agent host installs (no I/O in the guard).""" +"""Same-sandbox tunnel-collision guard for multi-agent host installs (no I/O in the guard), and preflight's refusal of an +agent with no build context to install.""" import pytest +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep @@ -25,3 +29,30 @@ def test_distinct_port_passes(): def test_other_sandbox_ignored(): _step("solver-b")._check_tunnel_collision(_ctx(sandbox_id="vm-2"), "vm-1", "https://t-8000.gw", 8000) + + +# What preflight refuses at save: install/v1 copies an agent's source files from its image's build context + +def _agent_with(**image_fields): + image = get_artifact_store().put_document(DockerImageArtifact(id="agent-x-image", description="d", **image_fields)) + return A2AAgent.put(id="agent-x", docker_image_artifact=image) + + +def test_preflight_refuses_an_agent_whose_image_has_no_build_context(local_stores): + _agent_with(image_name="ghcr.io/team/agent@sha256:" + "0" * 64) + + assert _step("solver").preflight() == [ + "install_agent 's': Agent agent-x v1 has no build_context_object_url on its docker_image_artifact; install/v1 " + "needs the build context (agent source files) to install into the task container"] + + +def test_preflight_passes_an_agent_with_a_build_context(local_stores): + _agent_with(image_name="local/agent-x-0123456789ab:v1", build_context_object_url="s3://bucket/ctx.tar.gz") + + assert _step("solver").preflight() == [] + + +def test_preflight_reports_an_agent_the_store_doesnt_hold(local_stores): + (problem,) = _step("solver").preflight() + + assert problem.startswith("install_agent 's': agent 'agent-x' can't be loaded: ") From 129cbb64903c6173cd27221f75857dacf433c6b7 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 10 Oct 2026 16:15:20 -0700 Subject: [PATCH 02/13] fix(gateway): check every image a gateway runs before creating anything The VM path now assembles its images and checks them before the VM is created, and the container path also checks the servicedb and sidecar images the state provider picks (LocalPostgresStateProvider.container_images). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../env_providers/env_gateway_provider.py | 50 +++++++++------ .../providers/env_state/local_postgres.py | 7 ++ .../env_gateway_provider_test.py | 64 ++++++++++++++++--- .../env_state/test_env_state_provider.py | 10 +++ 4 files changed, 103 insertions(+), 28 deletions(-) diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index aca07ebd..2479b212 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional +from agent_env.artifact.artifacts.docker_image import pull_problem from agent_env.attribution import Attribution from agent_env.env.env import DeployedGatewayEnv, _mcp_url from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode @@ -687,23 +688,6 @@ async def _deploy_via_vm( website_images = website_images or [] sidecars = sidecars or [] - if existing_sandbox is not None: - self._sandbox = existing_sandbox - else: - vm_kwargs: dict = {} - if cpu is not None: - vm_kwargs["cpu"] = cpu - if memory_mb is not None: - vm_kwargs["memory"] = memory_mb - self._sandbox = await sandbox_provider.create_vm( - exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars], - timeout=ttl_seconds, - disk_size_gb=disk_size_gb, - attribution=attribution, - **vm_kwargs, - ) - sandbox = self._sandbox - config = get_config() gateway_env = Env.get(config.default_gateway_env_id) @@ -727,6 +711,29 @@ async def _deploy_via_vm( ] if self._needs_local_postgres: all_images += self._state_provider.store_images_to_load() + # Every image is checked before the VM is created, so one it can't get leaves nothing to close. + on_this_machine = (existing_sandbox or sandbox_provider).ON_THIS_MACHINE + if problems := [problem for image in all_images + if (problem := image.load_problem(on_this_machine=on_this_machine))]: + raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, whose VM can't get its " + f"images: {'; '.join(problems)}") + + if existing_sandbox is not None: + self._sandbox = existing_sandbox + else: + vm_kwargs: dict = {} + if cpu is not None: + vm_kwargs["cpu"] = cpu + if memory_mb is not None: + vm_kwargs["memory"] = memory_mb + self._sandbox = await sandbox_provider.create_vm( + exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars], + timeout=ttl_seconds, + disk_size_gb=disk_size_gb, + attribution=attribution, + **vm_kwargs, + ) + sandbox = self._sandbox await sandbox.load_docker_images(all_images) # Compose declares the local_state service by name, so "standing up" is just naming the host; @@ -885,9 +892,12 @@ async def _deploy_via_containers( # Each image is checked, and prepared (one that is only a build context, built), before anything is created, so # an image the provider can't run, or a failed build, leaves nothing. images = [gateway_env.docker_image_artifact, *mcp_server_images] - accepts = self.accepts(sandbox_provider) - if problems := [problem for image in images - if (problem := accepts.problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE))]: + on_this_machine, accepts = sandbox_provider.ON_THIS_MACHINE, self.accepts(sandbox_provider) + problems = [problem for image in images if (problem := accepts.problem(image, on_this_machine=on_this_machine))] + if self._needs_local_postgres: # the servicedb and its sidecars, by the names the state provider picks + problems += [problem for name in self._state_provider.container_images() + if (problem := pull_problem(name, on_this_machine=on_this_machine))] + if problems: raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, which runs its images by " f"name: {'; '.join(problems)}") await asyncio.gather(*(sandbox_provider.prepare_image(image, attribution=attribution) for image in images)) diff --git a/src/agent_env/providers/env_state/local_postgres.py b/src/agent_env/providers/env_state/local_postgres.py index 34a19b98..52e600c3 100644 --- a/src/agent_env/providers/env_state/local_postgres.py +++ b/src/agent_env/providers/env_state/local_postgres.py @@ -335,6 +335,13 @@ def container_store_image(self) -> str: logger.warning(f"servicedb image {db_image} is not in the image store; running {_STOCK_POSTGRES_IMAGE}") return _STOCK_POSTGRES_IMAGE + def container_images(self) -> list[str]: + """The images the container path starts by name: the servicedb's, and each sidecar's it provisions (those the + image store owns, as ``sidecar_specs`` decides).""" + cfg = self.service_db_config + return [self.container_store_image(), + *(image for image in (cfg.db_web_image, cfg.db_mcp_image) if self._in_image_store(image))] + def sidecar_specs(self, *, instance: EnvStateInstance | None = None) -> list[ContainerSpec]: """pgweb + db-mcp container specs (Modal/container path), pointed at the stood-up servicedb. Only sidecar images the image store owns are provisionable in container mode; diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 9d8c73df..9ae07916 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -54,6 +54,12 @@ def _image(image_name: str) -> DockerImageArtifact: return DockerImageArtifact(id=image_name, description="d", image_name=image_name) +def _loadable(image_name: str) -> DockerImageArtifact: + """An image a VM loads from its tar.gz, as a gateway's VM checks every image it runs before it's created.""" + return DockerImageArtifact(id=image_name, description="d", image_name=image_name, + tar_gz_object_url="s3://bucket/image.tar.gz") + + @pytest.fixture(autouse=True) def _fake_env_state_store(): """Keep acquire()'s persist step off a real backend — real store over an in-memory DocumentStore.""" @@ -218,6 +224,48 @@ async def test_container_mode_refuses_an_image_in_this_machines_registry_before_ gp._build_local_store.assert_not_awaited() +@pytest.mark.asyncio +async def test_container_mode_refuses_a_servicedb_image_in_this_machines_registry_before_preparing_anything(): + provider = ModalSandboxProvider() + gp = EnvironmentGatewayProvider() + gp._state_provider = MagicMock(spec=LocalPostgresStateProvider) + gp._state_provider.container_images.return_value = ["localhost:5000/local/servicedb:v1"] + + with patch.object(provider, "prepare_image", side_effect=AssertionError("prepared an image")), \ + patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=_image("agent-gateway"))): + with pytest.raises(ValueError, match="localhost:5000/local/servicedb:v1 is in a registry on this machine"): + await gp._deploy_via_containers( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], + mcp_server_images=[_image("mcp-slack")], gateway_port=18765, website_configs=None, + gateway_mode=MagicMock(value="performance"), ttl_seconds=60, disk_size_gb=10, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unloadable", ["gateway", "server", "sidecar"]) +async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the_vm(unloadable): + """A VM elsewhere can't pull from this machine's registry; nothing is created to find that out.""" + from agent_env.env.gateway import GatewayMode + + here = DockerImageArtifact(id="here", description="d", image_name="localhost:5000/local/here:v1") + pick = {name: here if name == unloadable else _loadable(name) for name in ("gateway", "server", "sidecar")} + gp = EnvironmentGatewayProvider() + gp._state_provider = MagicMock() + provider = _vm_provider(create_vm=AsyncMock(side_effect=AssertionError("created a VM"))) + + with patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=pick["gateway"])): + with pytest.raises(ValueError, match="whose VM can't get its images: localhost:5000/local/here:v1 is in a " + "registry on this machine"): + await gp._deploy_via_vm( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="server", environment_name="slack")], + mcp_server_images=[pick["server"]], gateway_port=18765, website_configs=None, website_images=None, + gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, + sidecars=[SidecarConfig(environment_name="relay", image_artifact=pick["sidecar"], container_port=8000, + host_port=18768)], + ) + provider.create_vm.assert_not_awaited() + + @pytest.mark.asyncio async def test_container_mode_constructs_internal_mcp_servers_url_format(): """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" @@ -1119,7 +1167,7 @@ class _Stop(Exception): """Abort right after prepare — the rest of the VM deploy needs a real VM.""" def _fake_env_get(env_id, *a, **k): - env = MagicMock() + env = MagicMock(docker_image_artifact=_loadable(env_id)) env.environment_name = "website-browser" if env_id == "wb-id" else env_id return env @@ -1131,10 +1179,10 @@ def _fake_env_get(env_id, *a, **k): await gp._deploy_via_vm( sandbox_provider=sandbox_provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], - mcp_server_images=[_image("mcp-slack")], + mcp_server_images=[_loadable("mcp-slack")], gateway_port=18765, website_configs=[WebsiteConfig(backend_image="b", frontend_image="f", environment_name="shop")], - website_images=[MagicMock(), MagicMock()], + website_images=[_loadable("b"), _loadable("f")], gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, @@ -1164,13 +1212,13 @@ async def test_deploy_via_vm_side_loads_sidecar_image(): sandbox_provider = MagicMock() sandbox_provider.create_vm = AsyncMock(return_value=sandbox) - sidecar_art = MagicMock(image_name="ecr/relay:1.0.4") + sidecar_art = _loadable("ecr/relay:1.0.4") class _Stop(Exception): pass def _fake_env_get(env_id, *a, **k): - env = MagicMock(); env.environment_name = env_id; return env + env = MagicMock(docker_image_artifact=_loadable(env_id)); env.environment_name = env_id; return env with patch("agent_env.env.env.Env.get", side_effect=_fake_env_get), \ patch("agent_env.config.get_config", return_value=MagicMock(default_gateway_env_id="gw-id")), \ @@ -1179,7 +1227,7 @@ def _fake_env_get(env_id, *a, **k): await gp._deploy_via_vm( sandbox_provider=sandbox_provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], - mcp_server_images=[_image("mcp-slack")], + mcp_server_images=[_loadable("mcp-slack")], gateway_port=18765, website_configs=None, website_images=None, @@ -1598,13 +1646,13 @@ async def test_deploy_returns_the_gateway_record_for_the_env(caplog): state_provider = MagicMock(spec=DatabaseStateProvider, prepare=AsyncMock(), teardown=AsyncMock()) vm = MagicMock(sandbox_id="vm-1", type="modal_vm", tunnel_urls={18765: "https://vm.example"}, host_port=lambda port: port, host_ips=(), load_docker_images=AsyncMock(), exec_script=AsyncMock(), exec_with_output=AsyncMock(return_value=(0, "gateway", ""))) - env = MagicMock(id="crm-env", version=3, environment_name="slack", docker_image_artifact=MagicMock(image_name="mcp-slack")) + env = MagicMock(id="crm-env", version=3, environment_name="slack", docker_image_artifact=_loadable("mcp-slack")) card = {"name": "env1234", "children_environments": []} acquire = AsyncMock(return_value=external) with patch("agent_env.providers.env_state.acquire_state_for_deploy", acquire), \ patch("agent_env.providers.env_state.build_state_provider", return_value=state_provider), \ - patch("agent_env.env.env.Env.get", MagicMock()), \ + patch("agent_env.env.env.Env.get", MagicMock(return_value=MagicMock(docker_image_artifact=_loadable("gw")))), \ patch("agent_env.config.get_config", return_value=MagicMock(default_gateway_env_id="gw-id")), \ patch.object(gp, "create_docker_compose", return_value="services: {}") as compose, \ patch.object(gp, "_wait_for_gateway", AsyncMock(return_value=True)), \ diff --git a/tst/unit/providers/env_state/test_env_state_provider.py b/tst/unit/providers/env_state/test_env_state_provider.py index bac759bf..99507547 100644 --- a/tst/unit/providers/env_state/test_env_state_provider.py +++ b/tst/unit/providers/env_state/test_env_state_provider.py @@ -286,3 +286,13 @@ def test_sidecar_specs_skip_images_the_image_store_does_not_own(image_store, cap assert none == [] assert "pgweb image sosedoff/pgweb is not in the image store" in caplog.text assert "db-mcp image" not in caplog.text + + +def test_container_images_name_what_the_container_path_starts(image_store): + """The servicedb's image and each sidecar's the provider provisions, so a gateway can check them before it starts + any.""" + provider = LocalPostgresStateProvider(ServiceDBConfig(db_image=f"{_REPOSITORIES}/servicedb:v3", + db_web_image=f"{_REPOSITORIES}/pgweb:v1", + db_mcp_image="crystaldba/postgres-mcp")) + + assert provider.container_images() == [f"{_REPOSITORIES}/servicedb:v3", f"{_REPOSITORIES}/pgweb:v1"] From acb23487ee8abd01a8bc92a95431168379640afc Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 10 Oct 2026 20:46:31 -0700 Subject: [PATCH 03/13] fix(install_agent): leave an agent the store can't give to the run The hub runs Task.preflight on every task save, and a run's a2a_agent_id override can name another agent, so a task saved naming a placeholder must still save. Preflight now refuses only an agent that exists with no build context. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/task_step/task_steps/install_agent.py | 7 ++++--- tst/unit/task_step/install_agent_guards_test.py | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index a8aae484..b38157f4 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -153,11 +153,12 @@ def from_dict(cls, data: dict) -> InstallAgentTaskStep: ) def preflight(self) -> list[str]: - """An agent with no build context to install, reported at save instead.""" + """An agent with no build context to install, reported at save instead. One the store can't give is left to the + run, whose ``a2a_agent_id`` override can name another, so a task saved naming a placeholder still saves.""" try: agent = A2AAgent.get(self.a2a_agent_id, self.a2a_agent_version) - except (NotFoundError, ValueError, KeyError, TypeError) as e: - return [f"install_agent '{self.id}': agent '{self.a2a_agent_id}' can't be loaded: {e}"] + except (NotFoundError, ValueError, KeyError, TypeError): + return [] problem = _install_problem(agent) return [f"install_agent '{self.id}': {problem}"] if problem else [] diff --git a/tst/unit/task_step/install_agent_guards_test.py b/tst/unit/task_step/install_agent_guards_test.py index 5fd12bf3..c1ad01ee 100644 --- a/tst/unit/task_step/install_agent_guards_test.py +++ b/tst/unit/task_step/install_agent_guards_test.py @@ -52,7 +52,6 @@ def test_preflight_passes_an_agent_with_a_build_context(local_stores): assert _step("solver").preflight() == [] -def test_preflight_reports_an_agent_the_store_doesnt_hold(local_stores): - (problem,) = _step("solver").preflight() - - assert problem.startswith("install_agent 's': agent 'agent-x' can't be loaded: ") +def test_preflight_leaves_an_agent_the_store_doesnt_hold_to_the_run(local_stores): + """The run's a2a_agent_id override can name another agent, so a task saved naming a placeholder still saves.""" + assert _step("solver").preflight() == [] From 7b5697847f783d1349a65bfdaa64f296733a3464 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 10 Oct 2026 23:37:45 -0700 Subject: [PATCH 04/13] feat(sandbox): sandboxes declare the platforms they run, and an image for another is refused before anything is created A sandbox provider and its sandboxes declare PLATFORMS beside ON_THIS_MACHINE. Accepts.problem(image, on=...) reads both from the provider or sandbox it's asked about: an image recording a platform the sandbox doesn't declare is refused, at preflight and in every deploy path, before anything is created. Undeclared (None) is never checked: the local provider declares none, since its Docker runs this machine's platform and emulates others where it's set up, nor does a plugin's that hasn't opted in. Modal, Modal VM, E2B and Sail declare linux/amd64 (Modal's containers and VMs checked on live sandboxes, Sail by its amd64 devbox, E2B by its x86-64 Firecracker VMs). A context-only image on a provider that builds it, as Modal does, isn't checked, since that provider builds it for a platform of its own. Lifting the refusal of tar.gzs only this machine's store holds had let linux/arm64 images built on Apple silicon, the infra bootstrap's among them, reach linux/amd64 VMs, whose containers then never started. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/a2a_agent/a2a_agent.py | 4 +- .../artifact/artifacts/docker_image.py | 25 ++++++-- src/agent_env/bundle/preflight.py | 32 ++++++---- .../env_providers/env_gateway_provider.py | 13 ++-- .../env_providers/env_server_provider.py | 2 +- .../sandbox_providers/e2b/provider.py | 3 +- .../sandbox_providers/e2b/sandbox.py | 3 +- .../sandbox_providers/modal_sandbox.py | 4 +- .../sandbox_providers/modal_vm_sandbox.py | 4 +- .../sandbox_providers/sail_vm/provider.py | 3 +- .../sandbox_providers/sail_vm/sandbox.py | 9 ++- .../providers/sandbox_providers/sandbox.py | 11 +++- .../sandbox_providers/sandbox_provider.py | 22 +++++-- .../a2a_agent/context_only_deploy_test.py | 4 +- tst/unit/bundle/preflight_test.py | 64 ++++++++++++++++++- .../env_gateway_provider_test.py | 25 +++++++- .../provider_capabilities_test.py | 63 ++++++++++++++++-- .../sandbox_providers/vm_sandbox_test.py | 24 ++++++- 19 files changed, 257 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e3e27f3..a3029a9a 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. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`), 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. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too and which isn't checked when undeclared), 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 e1ca7bef..8d275353 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -444,9 +444,9 @@ async def deploy( raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " f"runs its own image, never the agent's: {problem}") elif refused := [(link, problem) for link in provider.links - if (problem := self.accepts(link).problem(image, on_this_machine=link.ON_THIS_MACHINE))]: + if (problem := self.accepts(link).problem(image, on=link))]: link, problem = refused[0] - how = ("whose VMs can't get it" if self.accepts(link) is Accepts.LOADABLE + how = ("whose VMs can't run it" if self.accepts(link) is Accepts.LOADABLE else "which runs an agent's image by name") raise ValueError(f"Can't deploy agent {self.id!r} with {type(link).__name__}, {how}: {problem}") diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index df74ab45..4646b281 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -15,6 +15,7 @@ from dataclasses import dataclass from importlib.metadata import version as pkg_version from pathlib import Path +from collections.abc import Collection from typing import Any, Callable, ClassVar, Literal from urllib.parse import urlparse @@ -274,24 +275,34 @@ def context_only(self) -> bool: tag the image's build gives it.""" return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name) - def load_problem(self, *, on_this_machine: bool = True) -> str | None: + def load_problem(self, *, on_this_machine: bool = True, platforms: Collection[str] | None = None) -> str | None: """Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build - context when it has only that.""" + context when it has only that; and a VM declaring ``platforms`` runs only an image of one of them.""" if self.tar_gz_object_url or self.context_only: - return None + return self.platform_problem(platforms) if names_registry(self.image_name): - return pull_problem(self.image_name, on_this_machine=on_this_machine) + return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platforms) return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a " "registry to pull it from") - def by_name_problem(self, *, on_this_machine: bool = True) -> str | None: + def by_name_problem(self, *, on_this_machine: bool = True, platforms: Collection[str] | None = None) -> str | None: """Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on - Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine.""" + Docker Hub; a sandbox not ``on_this_machine`` can't pull from a registry on this machine; and one declaring + ``platforms`` runs only an image of one of them.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" - return pull_problem(self.image_name, on_this_machine=on_this_machine) + return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platforms) + + def platform_problem(self, platforms: Collection[str] | None) -> str | None: + """Why a sandbox that runs only images of ``platforms`` can't run this one, or None when it can: an image runs + on the platform it records, the one it's built for. One that records none, or a sandbox that declares none, + isn't checked.""" + if platforms is None or self.platform is None or self.platform in platforms: + return None + runs = ", ".join(sorted(platforms)) + return f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {runs}" @classmethod async def put_from_github( diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 3ea3b72c..09d0d3c8 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -52,6 +52,7 @@ from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider from agent_env.providers.env_state.env_state_provider import LOCAL_POSTGRES_STATE_TYPE from agent_env.providers.env_state.store import get_env_state_instance_store +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64 from agent_env.providers.sandbox_providers.sandbox_provider import ( Accepts, SandboxProvider, @@ -338,13 +339,13 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], images: list[_Image], *, vms_load: bool) -> None: """Check ``images`` on each provider a deploy may run on, as ``accepts`` says the deploy runs them there, and - that one not on this machine reaches each; ``vms_load`` when the deploy loads images on a VM provider, as an - agent's and a gateway's do. An image the bundle builds is noted with them, for ``_contexts`` to choose its - form.""" + that one not on this machine reaches each and one declaring its platforms runs each; ``vms_load`` when the + deploy loads images on a VM provider, as an agent's and a gateway's do. An image the bundle builds is noted with + them, for ``_contexts`` to choose its form.""" links = list(provider.links) - checked: set[tuple[Accepts, bool]] = set() - for link in links: # the first link of each form, on this machine and off it - if (key := (accepts(link), link.ON_THIS_MACHINE)) not in checked: + checked: set[tuple[Accepts, bool, frozenset[str] | None]] = set() + for link in links: # the first link of each form, where it runs and what it runs + if (key := (accepts(link), link.ON_THIS_MACHINE, link.PLATFORMS)) not in checked: checked.add(key) self._accepted(where, link, key[0], images, vms_load=vms_load) for image in images: @@ -352,20 +353,29 @@ def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[Sandb self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: list[_Image], *, - vms_load: bool) -> None: + vms_load: bool, put: str | None = None) -> None: """Refuse each of ``images`` a deploy can't run on ``link``, which takes images in ``form``, with the check the deploy makes (``Accepts.problem``): a VM loads an image's tar.gz, or pulls an image with none when its name names a registry it reaches, and a provider that runs an image by name can't run one that is only a build context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's - registry.""" + registry; and a provider declaring its platforms runs only an image of one of them. ``put`` is the command that + puts the image again, when there is one.""" for image in images: if image.artifact is None: continue - if (refusal := form.problem(image.artifact, on_this_machine=link.ON_THIS_MACHINE)) is None: + if (refusal := form.problem(image.artifact, on=link)) is None: + continue + if refusal == image.artifact.platform_problem(link.PLATFORMS): + platform = sorted(link.PLATFORMS)[0] + again = f"`{put} --platform {platform}`" if put else f"--platform {platform} on its put command" + self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; put it " + f"again for {' or '.join(sorted(link.PLATFORMS))} ({again}), or run it with " + "--sandbox local") continue if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" - if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE else "") + if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE + and image.artifact.platform_problem(LINUX_AMD64) is None else "") self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it " f"with --sandbox local{loads}") continue @@ -393,7 +403,7 @@ def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) env.db_mcp_docker_image_artifact] if isinstance(env, ServiceDBEnv) else [env.docker_image_artifact]) images = [_Image(f"the {kind} env {env_id!r}'s image {image.id!r}", image) for image in artifacts] accepts = EnvironmentGatewayProvider.accepts(provider, prepared=kind == GATEWAY) - self._accepted(where, provider, accepts, images, vms_load=True) + self._accepted(where, provider, accepts, images, vms_load=True, put=put_command(kind)) def _contexts(self) -> frozenset[str]: """The images the bundle builds that it writes as build contexts: those every deploy runs on providers that diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 2479b212..016b4b68 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -712,10 +712,9 @@ async def _deploy_via_vm( if self._needs_local_postgres: all_images += self._state_provider.store_images_to_load() # Every image is checked before the VM is created, so one it can't get leaves nothing to close. - on_this_machine = (existing_sandbox or sandbox_provider).ON_THIS_MACHINE - if problems := [problem for image in all_images - if (problem := image.load_problem(on_this_machine=on_this_machine))]: - raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, whose VM can't get its " + on = existing_sandbox or sandbox_provider + if problems := [problem for image in all_images if (problem := Accepts.LOADABLE.problem(image, on=on))]: + raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, whose VM can't run its " f"images: {'; '.join(problems)}") if existing_sandbox is not None: @@ -892,11 +891,11 @@ async def _deploy_via_containers( # Each image is checked, and prepared (one that is only a build context, built), before anything is created, so # an image the provider can't run, or a failed build, leaves nothing. images = [gateway_env.docker_image_artifact, *mcp_server_images] - on_this_machine, accepts = sandbox_provider.ON_THIS_MACHINE, self.accepts(sandbox_provider) - problems = [problem for image in images if (problem := accepts.problem(image, on_this_machine=on_this_machine))] + accepts = self.accepts(sandbox_provider) + problems = [problem for image in images if (problem := accepts.problem(image, on=sandbox_provider))] if self._needs_local_postgres: # the servicedb and its sidecars, by the names the state provider picks problems += [problem for name in self._state_provider.container_images() - if (problem := pull_problem(name, on_this_machine=on_this_machine))] + if (problem := pull_problem(name, on_this_machine=sandbox_provider.ON_THIS_MACHINE))] if problems: raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, which runs its images by " f"name: {'; '.join(problems)}") diff --git a/src/agent_env/providers/env_providers/env_server_provider.py b/src/agent_env/providers/env_providers/env_server_provider.py index 61f31ec1..85d95c91 100644 --- a/src/agent_env/providers/env_providers/env_server_provider.py +++ b/src/agent_env/providers/env_providers/env_server_provider.py @@ -75,7 +75,7 @@ async def _deploy_server( name = env.environment_name image = env.docker_image_artifact - if problem := self.accepts(sandbox_provider).problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE): + if problem := self.accepts(sandbox_provider).problem(image, on=sandbox_provider): raise ValueError(f"Can't deploy {name!r} as a server on {type(sandbox_provider).__name__}, which runs its " f"image by name: {problem}") await sandbox_provider.prepare_image(image, attribution=attribution) diff --git a/src/agent_env/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index b0a293a7..7dcb3f0a 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -17,7 +17,7 @@ from agent_env.attribution import Attribution from agent_env.config.errors import ConfigError from agent_env.providers.sandbox_providers.e2b.sandbox import E2B_ALL_TRAFFIC, E2BSandbox -from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, Accepts, @@ -40,6 +40,7 @@ class E2BSandboxProvider(SandboxProvider): CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name + PLATFORMS = LINUX_AMD64 # E2B sandboxes are x86-64 Firecracker VMs # 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/e2b/sandbox.py b/src/agent_env/providers/sandbox_providers/e2b/sandbox.py index c2ef6fd8..982ec797 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/e2b/sandbox.py @@ -19,7 +19,7 @@ from urllib.parse import urlparse from agent_env.config import get_config -from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy, VmSandbox +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy, VmSandbox from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM if TYPE_CHECKING: @@ -91,6 +91,7 @@ class E2BSandbox(VmSandbox): """ type = "e2b" + PLATFORMS = LINUX_AMD64 # ``docker info`` should normally return in well under one second. Keep # each readiness probe short so a wedged daemon cannot consume the entire # VM-ready budget in one await. diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 0aab7a45..b3b32160 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -17,7 +17,7 @@ from agent_env.attribution import PIPELINE_STEP_KEY, RUN_ID_KEY, Attribution from agent_env.config import get_config -from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy, Sandbox +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy, Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_CONTAINER, Accepts, @@ -107,6 +107,7 @@ async def wait(self) -> int: class ModalSandbox(Sandbox): type = "modal" + PLATFORMS = LINUX_AMD64 def __init__(self, sb: modal.Sandbox, tunnel_urls: dict[int, str], i6pn_address: Optional[str] = None, network_policy: NetworkPolicy | None = None): @@ -193,6 +194,7 @@ class ModalSandboxProvider(SandboxProvider): # containers share i6pn, Modal's private network. SANDBOX_ACCEPTS = CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True + PLATFORMS = LINUX_AMD64 # Modal runs x86-64 hosts @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: 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 ca340771..82ae8d53 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -37,7 +37,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 import LINUX_AMD64, NetworkPolicy, VmSandbox, push_object_over_stdin from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, Accepts, SandboxProvider if TYPE_CHECKING: @@ -116,6 +116,7 @@ class ModalVmSandbox(VmSandbox): """ type = "modal_vm" + PLATFORMS = LINUX_AMD64 # Modal rejects an exec whose command exceeds ~48 KB, well under Linux's MAX_ARG_STRLEN. _WFT_CHUNK_BYTES = 32 * 1024 @@ -276,6 +277,7 @@ class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name + PLATFORMS = LINUX_AMD64 # Modal VM sandboxes run x86-64 @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 ba208f07..d8afac66 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -22,7 +22,7 @@ policy_name, release_injection, ) -from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkPolicy, NetworkPolicyUnsupportedError from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, Accepts, @@ -136,6 +136,7 @@ class SailVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name + PLATFORMS = LINUX_AMD64 # every Sailbox is the amd64 devbox image def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py index 8644a777..a7f1a753 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py @@ -21,7 +21,13 @@ carries_model_key, docker_shim, ) -from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS, NetworkMode, NetworkPolicy, VmSandbox +from agent_env.providers.sandbox_providers.sandbox import ( + CURL_RETRY_FLAGS, + LINUX_AMD64, + NetworkMode, + NetworkPolicy, + VmSandbox, +) from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM logger = logging.getLogger(__name__) @@ -230,6 +236,7 @@ class SailVmSandbox(VmSandbox): ``refuse_model_keys``, one that still carries a model key (one Sail doesn't inject here) is refused.""" type = "sail_vm" + PLATFORMS = LINUX_AMD64 _DOCKER_PROBE_TIMEOUT = 10 def __init__( diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index a8507a41..098768d6 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -51,6 +51,8 @@ # Docker label on what a step starts on a sandbox's Docker host (containers, images, networks), valued with the # sandbox id, so a sandbox that shares its host (the local one) can remove its own when it terminates. SANDBOX_LABEL = "agentenv.sandbox" +# The platforms of a sandbox on an x86-64 Linux host, as an image records the one it's built for. +LINUX_AMD64 = frozenset({"linux/amd64"}) # At most this many image signs in flight per event loop: each holds a worker thread, for up to a # remote signer's whole retry window, and a connection from its session's pool. @@ -152,6 +154,8 @@ class Sandbox(ABC): # Whether it runs on this machine, as a local sandbox does. ON_THIS_MACHINE: ClassVar[bool] = False + # The platforms it runs images of, as its provider declares them; None, undeclared, is never checked. + PLATFORMS: ClassVar[frozenset[str] | None] = None # The host other containers on its provider's private network reach it at, ready for a URL; None when it's on none. private_host: str | None = None @@ -291,12 +295,13 @@ async def wait_for_vm(self) -> None: async def load_docker_images(self, artifacts: list) -> None: """Put each DockerImageArtifact's image on the VM: those with a tar.gz are loaded from it, in parallel, those with only a build context are built from it, and the rest are pulled by image name. An image this VM can't get, - such as one to pull from a registry on this machine when the VM isn't on it, is refused before anything is - loaded.""" + such as one to pull from a registry on this machine when the VM isn't on it, or can't run, being for a platform + it doesn't declare, is refused before anything is loaded.""" if not artifacts: return if problems := [problem for artifact in artifacts - if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE))]: + if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE, + platforms=self.PLATFORMS))]: raise RuntimeError(f"Can't load images: {'; '.join(problems)}") if tarballs := [artifact for artifact in artifacts if artifact.tar_gz_object_url]: await self._load_tarballs(tarballs) diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index f19475fd..fe65ba3f 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -44,14 +44,17 @@ class Accepts(Enum): NAME = "name" # one it starts by its name, which an image that is only a build context gets only from a build NAME_OR_CONTEXT = "name_or_context" # one it starts by its name, or a build context it builds first (prepare_image) - def problem(self, image: DockerImageArtifact, *, on_this_machine: bool = True) -> str | None: - """Why a provider that accepts these, and whose sandboxes are ``on_this_machine`` or not, can't run ``image``, - or None when it can.""" + def problem(self, image: DockerImageArtifact, *, on: SandboxProvider | Sandbox | None = None) -> str | None: + """Why a provider that accepts these can't run ``image`` on ``on``, the provider or one of its sandboxes, or + None when it can: one not on this machine can't pull from a registry on it, and one that declares its + platforms runs only images of one of them. With no ``on``, neither is checked.""" + on_this_machine = on is None or on.ON_THIS_MACHINE + platforms = None if on is None else on.PLATFORMS if self is Accepts.LOADABLE: - return image.load_problem(on_this_machine=on_this_machine) + return image.load_problem(on_this_machine=on_this_machine, platforms=platforms) if self is Accepts.NAME_OR_CONTEXT and image.context_only: - return None - return image.by_name_problem(on_this_machine=on_this_machine) + return None # the provider builds it, for a platform of its own + return image.by_name_problem(on_this_machine=on_this_machine, platforms=platforms) class SandboxProvider(ABC): @@ -167,7 +170,8 @@ def from_config(cls, **config: Any) -> Self: 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, and creates no VM and no private network. + # each image from its name, in a sandbox that isn't on this machine, and creates no VM and no private network; its + # images' platforms aren't checked. # # 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. @@ -179,6 +183,10 @@ def from_config(cls, **config: Any) -> Self: CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False + # The platforms its sandboxes run images of (``linux/amd64``), which its sandbox classes declare too; None, + # undeclared, is never checked, as for the local provider, whose Docker runs this machine's and emulates others + # where it's set up. + PLATFORMS: ClassVar[frozenset[str] | None] = None @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 947c1635..401ba60d 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -146,9 +146,9 @@ async def create_sandbox(self, **_): @pytest.mark.asyncio @pytest.mark.parametrize("provider, image, refusal", [ - (_VmProvider(), PULLED_HERE, f"with _VmProvider, whose VMs can't get it: {HERE}"), + (_VmProvider(), PULLED_HERE, f"with _VmProvider, whose VMs can't run it: {HERE}"), (_ModalProvider(), PULLED_HERE, f"with _ModalProvider, which runs an agent's image by name: {HERE}"), - (_VmProvider(), NO_REGISTRY, "with _VmProvider, whose VMs can't get it: 'solver-image' v2 has no tar.gz, and its " + (_VmProvider(), NO_REGISTRY, "with _VmProvider, whose VMs can't run it: 'solver-image' v2 has no tar.gz, and its " "image name 'solver:v2' doesn't name a registry to pull it from"), ], ids=["vm-elsewhere", "modal", "vm-nothing-to-pull"]) async def test_an_image_a_provider_cant_get_is_refused_before_anything_is_created(monkeypatch, local_stores, litellm, diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 31d8cf16..1369515d 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -29,7 +29,7 @@ from agent_env.providers.env_state.env_state_provider import EnvStateInstance from agent_env.providers.env_state.store import register_env_state_instance from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider -from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider from agent_env.store.routing import namespace_routing from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep @@ -41,6 +41,7 @@ LOCAL = ("localhost:5000/local/img:v1", "file:///state/img-v1.tar.gz") REMOTE = ("123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v1", "s3://bucket/img-v1.tar.gz") PULLED_HERE = ("localhost:5000/local/img:v1", None) # no tar.gz, so a sandbox pulls it from this machine's registry +ARM64_HERE = (*LOCAL, "linux/arm64") # built here on Apple silicon, as the infra bootstrap builds it HERE = "localhost:5000/local/img:v1 is in a registry on this machine, which a sandbox elsewhere can't pull from" @@ -64,9 +65,10 @@ def _task(root, steps, name="t"): def _image(id, where=LOCAL): + """An image named ``where[0]``, its tar.gz at ``where[1]``, recording ``where[2]``'s platform when given.""" with namespace_routing(): return get_artifact_store().put_document(DockerImageArtifact( - id=id, description=id, image_name=where[0], tar_gz_s3_url=where[1])) + id=id, description=id, image_name=where[0], tar_gz_s3_url=where[1], platform=(where[2:] or (None,))[0])) def _agent(id, where=LOCAL): @@ -379,6 +381,62 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser "built, not pulled; run it on a provider whose VMs build it, such as --sandbox modal_vm"] +# Images built for a platform a provider's sandboxes don't run + +NOT_AMD64 = "v1 is a linux/arm64 image, and the sandbox runs linux/amd64" + + +class _VmsOfNoDeclaredPlatform(SandboxProvider): + """A plugin's VM provider that declares no platform, which isn't checked.""" + + CREATES_VMS = True + SANDBOX_ACCEPTS = Accepts.LOADABLE + + async def create_sandbox(self, **kwargs): + raise AssertionError("preflight creates nothing") + + +@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("local", False), ("vms", False)]) +def test_an_image_for_a_platform_a_provider_doesnt_declare_is_refused_there(bundle_dir, monkeypatch, sandbox, refused): + """The local provider declares none, since its Docker emulates other platforms; nor does a plugin's here.""" + registry = Config.sandbox_registry + monkeypatch.setattr(Config, "sandbox_registry", lambda self: {**registry(self), "vms": {"impl": _VmsOfNoDeclaredPlatform}}) + _agent("solver", ARM64_HERE) + _task(bundle_dir, [AGENT]) + + if refused: + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal_vm' sandbox provider: 'solver-image' " + f"{NOT_AMD64}; put it again for linux/amd64 (--platform linux/amd64 on its put command), or run it with " + "--sandbox local"] + else: + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] + + +def test_infra_built_here_for_another_platform_is_refused_on_a_vm_provider_naming_the_command_to_put_it_again(bundle_dir): + """The infra bootstrap builds for this machine, linux/arm64 on Apple silicon, and the VM providers run linux/amd64.""" + _env("crm", REMOTE) + _infra(ARM64_HERE) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + + assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " + f"'modal_vm' sandbox provider: 'gateway-default' {NOT_AMD64}; put it again for linux/amd64 " + "(`agent-env env gateway put --id default --platform linux/amd64`), or run it with --sandbox local") + assert len(problems) == 4 # the gateway's image, and the service-db's three + + +def test_a_vm_provider_isnt_offered_for_an_image_its_vms_wouldnt_run(bundle_dir): + """Modal can't pull from this machine's registry; a VM would load the tar.gz, but it's for linux/arm64.""" + _agent("solver", ARM64_HERE) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider: {HERE}; run it with " + "--sandbox local"] + + # Agents placed on a sandbox the task deploys BOX = {"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", "exposed_ports": [8000]} @@ -423,7 +481,7 @@ def test_an_agent_is_refused_a_sandbox_deploy_agent_cant_place_it_on_in_the_step @pytest.mark.parametrize("where, sandbox", [(PULLED_HERE, "modal_vm"), (PULLED_HERE, "modal"), (LOCAL, "modal"), - (NO_REGISTRY, "modal_vm")]) + (NO_REGISTRY, "modal_vm"), (ARM64_HERE, "modal_vm")]) def test_preflight_refuses_an_agent_for_the_reason_its_deploy_would(bundle_dir, monkeypatch, where, sandbox): """Both make the one check (``Accepts.problem``), so a deploy preflight passes isn't refused for its image.""" agent = _agent("solver", where) diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 9ae07916..8cf7bdb8 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -38,7 +38,7 @@ ) from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider -from agent_env.providers.sandbox_providers.sandbox import Sandbox +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import Accepts from agent_env.providers.env_state import LocalPostgresStateProvider from agent_env.config import get_config, set_document_store @@ -254,7 +254,7 @@ async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the provider = _vm_provider(create_vm=AsyncMock(side_effect=AssertionError("created a VM"))) with patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=pick["gateway"])): - with pytest.raises(ValueError, match="whose VM can't get its images: localhost:5000/local/here:v1 is in a " + with pytest.raises(ValueError, match="whose VM can't run its images: localhost:5000/local/here:v1 is in a " "registry on this machine"): await gp._deploy_via_vm( sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="server", environment_name="slack")], @@ -266,6 +266,27 @@ async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the provider.create_vm.assert_not_awaited() +@pytest.mark.asyncio +async def test_a_vm_gateway_refuses_an_image_for_a_platform_its_provider_doesnt_declare_before_creating_the_vm(): + from agent_env.env.gateway import GatewayMode + + arm64 = DockerImageArtifact(id="arm", description="d", image_name="arm:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/arm.tar.gz") + gp = EnvironmentGatewayProvider() + gp._state_provider = MagicMock() + provider = _vm_provider(PLATFORMS=LINUX_AMD64, create_vm=AsyncMock(side_effect=AssertionError("created a VM"))) + + with patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=_loadable("gateway"))): + with pytest.raises(ValueError, match="whose VM can't run its images: 'arm' v0 is a linux/arm64 image, and the " + "sandbox runs linux/amd64"): + await gp._deploy_via_vm( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="arm:v1", environment_name="slack")], + mcp_server_images=[arm64], gateway_port=18765, website_configs=None, website_images=None, + gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, + ) + provider.create_vm.assert_not_awaited() + + @pytest.mark.asyncio async def test_container_mode_constructs_internal_mcp_servers_url_format(): """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index a0b9fbd8..4a0e0ab4 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -4,6 +4,7 @@ import ast from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import modal @@ -12,10 +13,13 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.e2b.provider import E2BSandboxProvider -from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.providers.sandbox_providers.e2b.sandbox import E2BSandbox +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider -from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider +from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider +from agent_env.providers.sandbox_providers.sail_vm.sandbox import SailVmSandbox +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64 from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider SRC = Path(__file__).resolve().parents[4] / "src" / "agent_env" @@ -104,13 +108,18 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" +def _on(on_this_machine: bool = False, platforms: frozenset[str] | None = None) -> SimpleNamespace: + """A sandbox or provider, as far as what it declares about where it runs and what.""" + return SimpleNamespace(ON_THIS_MACHINE=on_this_machine, PLATFORMS=platforms) + + @pytest.mark.parametrize("image, accepts, problem", [ (CONTEXT_ONLY, LOADABLE, None), (CONTEXT_ONLY, NAME_OR_CONTEXT, None), (CONTEXT_ONLY, NAME, "only a build context"), (NO_REGISTRY, LOADABLE, "doesn't name a registry"), (NO_REGISTRY, NAME, None), (NO_REGISTRY, NAME_OR_CONTEXT, None), ]) -@pytest.mark.parametrize("on_this_machine", [True, False]) -def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on_this_machine): - found = accepts.problem(image, on_this_machine=on_this_machine) +@pytest.mark.parametrize("on", [None, _on(True), _on(False), _on(False, LINUX_AMD64)]) +def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on): + found = accepts.problem(image, on=on) assert (found is None) if problem is None else (problem in found) @@ -121,12 +130,52 @@ def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, pr ]) def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it_can(image, accepts, problem): """A VM loads an image's tar.gz, which agent-env pushes to it, and pulls one only when it has none.""" - found = accepts.problem(image, on_this_machine=False) + found = accepts.problem(image, on=_on(False)) assert (found is None) if problem is None else (problem in found) - assert accepts.problem(image, on_this_machine=True) is None + assert accepts.problem(image, on=_on(True)) is None assert accepts.problem(image) is None # a caller that doesn't say gets the check as it was +ARM64_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform="linux/arm64", + tar_gz_object_url="file:///state/img.tar.gz") +ARM64_PULLED = DockerImageArtifact(id="img", version=2, description="d", platform="linux/arm64", + image_name="123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v2") +ARM64_CONTEXT = DockerImageArtifact(id="img", version=2, description="d", image_name="local/img-0123456789ab:v2", + build_context_object_url="s3://bucket/ctx.tar.gz", platform="linux/arm64") +ARM64 = "'img' v2 is a linux/arm64 image, and the sandbox runs linux/amd64" + + +@pytest.mark.parametrize("image, accepts, problem", [ + (ARM64_TAR_GZ, LOADABLE, ARM64), (ARM64_PULLED, LOADABLE, ARM64), (ARM64_PULLED, NAME, ARM64), + (ARM64_PULLED, NAME_OR_CONTEXT, ARM64), + (ARM64_CONTEXT, LOADABLE, ARM64), # a VM builds it for the platform it records + (ARM64_CONTEXT, NAME_OR_CONTEXT, None), # a provider that builds it does so for a platform of its own +]) +def test_a_sandbox_declaring_its_platforms_runs_only_images_of_one_of_them(image, accepts, problem): + assert accepts.problem(image, on=_on(False, LINUX_AMD64)) == problem + assert accepts.problem(image, on=_on(False, frozenset({"linux/amd64", "linux/arm64"}))) is None + assert accepts.problem(image, on=_on(False)) is None # one that declares none isn't checked + + +def test_an_image_that_records_no_platform_isnt_checked(): + image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", + tar_gz_object_url="s3://bucket/img.tar.gz") + assert image.platform is None and LOADABLE.problem(image, on=_on(False, frozenset({"linux/arm64"}))) is None + + +@pytest.mark.parametrize("provider, sandbox", [ + (ModalSandboxProvider, ModalSandbox), (ModalVmSandboxProvider, ModalVmSandbox), (E2BSandboxProvider, E2BSandbox), + (SailVmSandboxProvider, SailVmSandbox), +]) +def test_the_remote_providers_and_their_sandboxes_declare_they_run_linux_amd64(provider, sandbox): + assert provider.PLATFORMS == sandbox.PLATFORMS == LINUX_AMD64 + + +def test_the_local_provider_declares_no_platform_since_its_docker_emulates_others(): + assert LocalSandboxProvider.PLATFORMS is None and LocalSandbox.PLATFORMS is None + assert SandboxProvider.PLATFORMS is None # a plugin's provider that declares nothing isn't checked + + def _concrete_classes() -> set[str]: """The sandbox and sandbox provider classes the providers define, but the base classes everything else may name.""" names = set() diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 85065ea3..1f8a3211 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -10,7 +10,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.providers.sandbox_providers import sandbox as sandbox_module -from agent_env.providers.sandbox_providers.sandbox import VmSandbox +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, VmSandbox from agent_env.store import ImageStore, RegistryAuth, set_object_store from agent_env.config import get_config, reset_config from tst.unit.store.fakes import ConfiguredObjectStore @@ -216,6 +216,28 @@ async def test_a_vm_on_this_machine_pulls_from_its_registry_and_one_elsewhere_lo assert "docker load" in elsewhere.scripts[0] and "docker pull" not in elsewhere.scripts[0] +class _AmdVm(_RecordingVmSandbox): + """A VM sandbox that declares it runs linux/amd64, as the remote providers' do.""" + + PLATFORMS = LINUX_AMD64 + + +@pytest.mark.asyncio +async def test_a_vm_declaring_its_platforms_refuses_a_tar_gz_for_another_before_anything_is_loaded(signing_store): + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + + amd = _AmdVm() + with pytest.raises(RuntimeError, match="Can't load images: 'img' v1 is a linux/arm64 image, and the sandbox runs " + "linux/amd64"): + await amd.load_docker_images([arm64]) + assert amd.scripts == [] + + undeclared = _RecordingVmSandbox(images_stdout="img\n") + await undeclared.load_docker_images([arm64]) + assert "docker load" in undeclared.scripts[0] + + @pytest.mark.asyncio async def test_a_login_is_minted_once_per_registry(): minted = [] From fe9f1d7a383372f4dd74bd653e70ef2c06d52928 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 10 Oct 2026 23:51:36 -0700 Subject: [PATCH 05/13] refactor(sandbox): a sandbox runs linux/amd64 unless it declares otherwise; the local provider runs any PLATFORMS defaults to linux/amd64, which the CLI's --platform default and the in-VM build's fallback already assume of remote sandboxes, so the remote providers' own declarations go and a plugin's or the sdk's provider is checked without declaring. The local provider and its sandboxes declare None: they run any platform, unchecked, since this machine's Docker runs its own and emulates others where it's set up. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- .../sandbox_providers/e2b/provider.py | 3 +-- .../sandbox_providers/e2b/sandbox.py | 3 +-- .../sandbox_providers/local_sandbox.py | 3 +++ .../sandbox_providers/modal_sandbox.py | 4 +-- .../sandbox_providers/modal_vm_sandbox.py | 4 +-- .../sandbox_providers/sail_vm/provider.py | 3 +-- .../sandbox_providers/sail_vm/sandbox.py | 9 +------ .../providers/sandbox_providers/sandbox.py | 4 +-- .../sandbox_providers/sandbox_provider.py | 13 +++++----- tst/unit/bundle/preflight_test.py | 26 ++++++++++++------- .../provider_capabilities_test.py | 8 +++--- .../sandbox_providers/vm_sandbox_test.py | 25 +++++++++++------- 13 files changed, 55 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a3029a9a..11c58d43 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. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too and which isn't checked when undeclared), 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. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too: linux/amd64 unless declared, and any, unchecked, for the local provider), 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/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index 7dcb3f0a..b0a293a7 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -17,7 +17,7 @@ from agent_env.attribution import Attribution from agent_env.config.errors import ConfigError from agent_env.providers.sandbox_providers.e2b.sandbox import E2B_ALL_TRAFFIC, E2BSandbox -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, Accepts, @@ -40,7 +40,6 @@ class E2BSandboxProvider(SandboxProvider): CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name - PLATFORMS = LINUX_AMD64 # E2B sandboxes are x86-64 Firecracker VMs # 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/e2b/sandbox.py b/src/agent_env/providers/sandbox_providers/e2b/sandbox.py index 982ec797..c2ef6fd8 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/e2b/sandbox.py @@ -19,7 +19,7 @@ from urllib.parse import urlparse from agent_env.config import get_config -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy, VmSandbox +from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy, VmSandbox from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM if TYPE_CHECKING: @@ -91,7 +91,6 @@ class E2BSandbox(VmSandbox): """ type = "e2b" - PLATFORMS = LINUX_AMD64 # ``docker info`` should normally return in well under one second. Keep # each readiness probe short so a wedged daemon cannot consume the entire # VM-ready budget in one await. diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 6482aa52..ec9ae9fb 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -135,6 +135,7 @@ class LocalSandbox(VmSandbox): """ ON_THIS_MACHINE = True + PLATFORMS = None # what this machine's Docker runs, emulating other platforms where it's set up def url_from_sandbox(self, url: str) -> str: return LocalSandboxProvider.get_external_url(url) @@ -500,6 +501,8 @@ class LocalSandboxProvider(SandboxProvider): # A sandbox or a container is a container from the image's name, which this machine's Docker pulls or already # holds; a VM it creates (create_vm) loads images of its own. CREATES_VMS = True + # Any platform, unchecked: this machine's Docker runs its own and emulates others where it's set up. + PLATFORMS = None 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 b3b32160..0aab7a45 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -17,7 +17,7 @@ from agent_env.attribution import PIPELINE_STEP_KEY, RUN_ID_KEY, Attribution from agent_env.config import get_config -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkMode, NetworkPolicy, Sandbox +from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy, Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_CONTAINER, Accepts, @@ -107,7 +107,6 @@ async def wait(self) -> int: class ModalSandbox(Sandbox): type = "modal" - PLATFORMS = LINUX_AMD64 def __init__(self, sb: modal.Sandbox, tunnel_urls: dict[int, str], i6pn_address: Optional[str] = None, network_policy: NetworkPolicy | None = None): @@ -194,7 +193,6 @@ class ModalSandboxProvider(SandboxProvider): # containers share i6pn, Modal's private network. SANDBOX_ACCEPTS = CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True - PLATFORMS = LINUX_AMD64 # Modal runs x86-64 hosts @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: 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 82ae8d53..ca340771 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -37,7 +37,7 @@ ) from agent_env.attribution import Attribution from agent_env.config import get_config -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkPolicy, VmSandbox, push_object_over_stdin +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 if TYPE_CHECKING: @@ -116,7 +116,6 @@ class ModalVmSandbox(VmSandbox): """ type = "modal_vm" - PLATFORMS = LINUX_AMD64 # Modal rejects an exec whose command exceeds ~48 KB, well under Linux's MAX_ARG_STRLEN. _WFT_CHUNK_BYTES = 32 * 1024 @@ -277,7 +276,6 @@ class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name - PLATFORMS = LINUX_AMD64 # Modal VM sandboxes run x86-64 @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 d8afac66..ba208f07 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -22,7 +22,7 @@ policy_name, release_injection, ) -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, NetworkPolicy, NetworkPolicyUnsupportedError +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, Accepts, @@ -136,7 +136,6 @@ class SailVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name - PLATFORMS = LINUX_AMD64 # every Sailbox is the amd64 devbox image def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py index a7f1a753..8644a777 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py @@ -21,13 +21,7 @@ carries_model_key, docker_shim, ) -from agent_env.providers.sandbox_providers.sandbox import ( - CURL_RETRY_FLAGS, - LINUX_AMD64, - NetworkMode, - NetworkPolicy, - VmSandbox, -) +from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS, NetworkMode, NetworkPolicy, VmSandbox from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM logger = logging.getLogger(__name__) @@ -236,7 +230,6 @@ class SailVmSandbox(VmSandbox): ``refuse_model_keys``, one that still carries a model key (one Sail doesn't inject here) is refused.""" type = "sail_vm" - PLATFORMS = LINUX_AMD64 _DOCKER_PROBE_TIMEOUT = 10 def __init__( diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 098768d6..67c4cbce 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -154,8 +154,8 @@ class Sandbox(ABC): # Whether it runs on this machine, as a local sandbox does. ON_THIS_MACHINE: ClassVar[bool] = False - # The platforms it runs images of, as its provider declares them; None, undeclared, is never checked. - PLATFORMS: ClassVar[frozenset[str] | None] = None + # The platforms it runs images of, as its provider declares them: linux/amd64 unless declared; None runs any. + PLATFORMS: ClassVar[frozenset[str] | None] = LINUX_AMD64 # The host other containers on its provider's private network reach it at, ready for a URL; None when it's on none. private_host: str | None = None diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index fe65ba3f..cce5f623 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -16,6 +16,7 @@ from agent_env.config import ENV_REF_PREFIX from agent_env.config.errors import ConfigError from agent_env.providers.sandbox_providers.sandbox import ( + LINUX_AMD64, NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox, @@ -170,8 +171,8 @@ def from_config(cls, **config: Any) -> Self: 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, and creates no VM and no private network; its - # images' platforms aren't checked. + # 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. # # 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. @@ -183,10 +184,10 @@ def from_config(cls, **config: Any) -> Self: CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False - # The platforms its sandboxes run images of (``linux/amd64``), which its sandbox classes declare too; None, - # undeclared, is never checked, as for the local provider, whose Docker runs this machine's and emulates others - # where it's set up. - PLATFORMS: ClassVar[frozenset[str] | None] = None + # The platforms its sandboxes run images of, which its sandbox classes declare too: linux/amd64 unless declared. + # None runs any, unchecked, as the local provider does, whose Docker runs this machine's platform and emulates + # others where it's set up. + PLATFORMS: ClassVar[frozenset[str] | None] = LINUX_AMD64 @property def links(self) -> tuple[SandboxProvider, ...]: diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 1369515d..e445fde3 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -386,8 +386,8 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser NOT_AMD64 = "v1 is a linux/arm64 image, and the sandbox runs linux/amd64" -class _VmsOfNoDeclaredPlatform(SandboxProvider): - """A plugin's VM provider that declares no platform, which isn't checked.""" +class _Vms(SandboxProvider): + """A plugin's VM provider that declares no platform, so its VMs run linux/amd64.""" CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE @@ -396,19 +396,27 @@ async def create_sandbox(self, **kwargs): raise AssertionError("preflight creates nothing") -@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("local", False), ("vms", False)]) -def test_an_image_for_a_platform_a_provider_doesnt_declare_is_refused_there(bundle_dir, monkeypatch, sandbox, refused): - """The local provider declares none, since its Docker emulates other platforms; nor does a plugin's here.""" +class _ArmVms(_Vms): + """A plugin's VM provider whose VMs run linux/arm64.""" + + PLATFORMS = frozenset({"linux/arm64"}) + + +@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("vms", True), ("local", False), ("arm-vms", False)]) +def test_an_image_for_a_platform_a_provider_doesnt_run_is_refused_there(bundle_dir, monkeypatch, sandbox, refused): + """A provider runs linux/amd64 unless it declares otherwise; the local provider runs any, since its Docker emulates + other platforms.""" registry = Config.sandbox_registry - monkeypatch.setattr(Config, "sandbox_registry", lambda self: {**registry(self), "vms": {"impl": _VmsOfNoDeclaredPlatform}}) + monkeypatch.setattr(Config, "sandbox_registry", + lambda self: {**registry(self), "vms": {"impl": _Vms}, "arm-vms": {"impl": _ArmVms}}) _agent("solver", ARM64_HERE) _task(bundle_dir, [AGENT]) if refused: assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ - f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal_vm' sandbox provider: 'solver-image' " - f"{NOT_AMD64}; put it again for linux/amd64 (--platform linux/amd64 on its put command), or run it with " - "--sandbox local"] + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {sandbox!r} sandbox provider: " + f"'solver-image' {NOT_AMD64}; put it again for linux/amd64 (--platform linux/amd64 on its put command), or " + "run it with --sandbox local"] else: assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 4a0e0ab4..b5ec8353 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -19,7 +19,7 @@ from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.sandbox import SailVmSandbox -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64 +from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider SRC = Path(__file__).resolve().parents[4] / "src" / "agent_env" @@ -164,16 +164,16 @@ def test_an_image_that_records_no_platform_isnt_checked(): @pytest.mark.parametrize("provider, sandbox", [ + (SandboxProvider, Sandbox), # a plugin's that declares nothing (ModalSandboxProvider, ModalSandbox), (ModalVmSandboxProvider, ModalVmSandbox), (E2BSandboxProvider, E2BSandbox), (SailVmSandboxProvider, SailVmSandbox), ]) -def test_the_remote_providers_and_their_sandboxes_declare_they_run_linux_amd64(provider, sandbox): +def test_a_provider_and_its_sandboxes_run_linux_amd64_unless_they_declare_otherwise(provider, sandbox): assert provider.PLATFORMS == sandbox.PLATFORMS == LINUX_AMD64 -def test_the_local_provider_declares_no_platform_since_its_docker_emulates_others(): +def test_the_local_provider_runs_any_platform_unchecked_since_its_docker_emulates_others(): assert LocalSandboxProvider.PLATFORMS is None and LocalSandbox.PLATFORMS is None - assert SandboxProvider.PLATFORMS is None # a plugin's provider that declares nothing isn't checked def _concrete_classes() -> set[str]: diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 1f8a3211..bf4d73cf 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -216,26 +216,27 @@ async def test_a_vm_on_this_machine_pulls_from_its_registry_and_one_elsewhere_lo assert "docker load" in elsewhere.scripts[0] and "docker pull" not in elsewhere.scripts[0] -class _AmdVm(_RecordingVmSandbox): - """A VM sandbox that declares it runs linux/amd64, as the remote providers' do.""" +class _AnyPlatformVm(_RecordingVmSandbox): + """A VM sandbox that runs any platform, as the local provider's do.""" - PLATFORMS = LINUX_AMD64 + PLATFORMS = None @pytest.mark.asyncio -async def test_a_vm_declaring_its_platforms_refuses_a_tar_gz_for_another_before_anything_is_loaded(signing_store): +async def test_a_vm_refuses_a_tar_gz_for_a_platform_it_doesnt_run_before_anything_is_loaded(signing_store): + """A VM runs linux/amd64 unless it declares otherwise.""" arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", tar_gz_object_url="s3://bucket/img.tar.gz") - amd = _AmdVm() + amd = _RecordingVmSandbox() with pytest.raises(RuntimeError, match="Can't load images: 'img' v1 is a linux/arm64 image, and the sandbox runs " "linux/amd64"): await amd.load_docker_images([arm64]) - assert amd.scripts == [] + assert amd.PLATFORMS == LINUX_AMD64 and amd.scripts == [] - undeclared = _RecordingVmSandbox(images_stdout="img\n") - await undeclared.load_docker_images([arm64]) - assert "docker load" in undeclared.scripts[0] + anything = _AnyPlatformVm(images_stdout="img\n") + await anything.load_docker_images([arm64]) + assert "docker load" in anything.scripts[0] @pytest.mark.asyncio @@ -720,9 +721,13 @@ async def test_two_builds_of_one_context_on_one_host_each_have_a_work_folder_of_ assert first != second +class _AmdAndArmVm(_RecordingVmSandbox): + PLATFORMS = frozenset({"linux/amd64", "linux/arm64"}) + + @pytest.mark.asyncio async def test_a_build_takes_the_recorded_dockerfile_and_platform_else_linux_amd64(signing_store): - sandbox = _RecordingVmSandbox() + sandbox = _AmdAndArmVm() await sandbox.load_docker_images([_context_image("a", "sha256:" + "1" * 64, platform=None, dockerfile="docker/app.Dockerfile"), From 5c1b6fbe8e621d942316b102d0c3e53545db625b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 03:58:51 -0700 Subject: [PATCH 06/13] fix(sandbox): a chain skips a provider that can't run the image, and refuses only when none can An agent or container-sandbox deploy on a chain now drops each provider that refuses its image, with a warning, and deploys on the rest; it's refused, naming each provider's refusal, only when every provider refuses. Env deploys already fell through provider by provider. The dry run matches: a deploy is refused only when every provider in its chain refuses it, so a gateway's infra and its sandboxes are checked per provider too, and a chain that falls back to the local provider isn't refused for infra elsewhere. The platform refusal says to rebuild the image for the sandbox's platform, and that a Linux host of another platform can only do that with QEMU emulation set up. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/agent_env/a2a_agent/a2a_agent.py | 15 +- src/agent_env/bundle/preflight.py | 148 +++++++++--------- .../chained_sandbox_provider.py | 16 ++ .../task_step/task_steps/deploy_sandbox.py | 10 +- .../a2a_agent/context_only_deploy_test.py | 27 +++- tst/unit/bundle/preflight_test.py | 60 +++++-- .../sandbox_provider_test.py | 28 +++- .../task_step/deploy_sandbox_image_test.py | 18 ++- 9 files changed, 213 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11c58d43..a37097b5 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. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too: linux/amd64 unless declared, and any, unchecked, for the local provider), 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`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too: linux/amd64 unless declared, and any, unchecked, for the local provider), 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 8d275353..339f8918 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -26,6 +26,7 @@ skill_add_call, ) from agent_env.a2a_agent.staging import transfer_store +from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, @@ -443,12 +444,14 @@ async def deploy( if sandbox.mode != SANDBOX_MODE_VM and (problem := image.by_name_problem()): raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " f"runs its own image, never the agent's: {problem}") - elif refused := [(link, problem) for link in provider.links - if (problem := self.accepts(link).problem(image, on=link))]: - link, problem = refused[0] - how = ("whose VMs can't run it" if self.accepts(link) is Accepts.LOADABLE - else "which runs an agent's image by name") - raise ValueError(f"Can't deploy agent {self.id!r} with {type(link).__name__}, {how}: {problem}") + else: + provider, refused = skip_refusing(provider, lambda link: self.accepts(link).problem(image, on=link)) + if provider is None: + refusals = [f"with {type(link).__name__}, " + + ("whose VMs can't run it" if self.accepts(link) is Accepts.LOADABLE + else "which runs an agent's image by name") + + f": {problem}" for link, problem in refused] + raise ValueError(f"Can't deploy agent {self.id!r} {'; nor '.join(refusals)}") try: if provider is not None: diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 09d0d3c8..a2612438 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -2,10 +2,11 @@ Each deploy, an env's, an agent's, a sandbox's or a rubrics judge's, runs on its effective sandbox provider: the run's ``--sandbox``, else the step's own field, else the config default, resolved as the step resolves it when it runs. A -comma-separated provider is a chain whose deploys fall back from one provider to the next, so every provider in it must -pass. A run is refused when a deploy would fail on its provider: an image the provider can't run, by the check the -deploy itself makes, such as one a sandbox elsewhere would pull from this machine's registry; a VM asked of a provider -that can't create one; or containers on the local provider without a Docker daemon. An image's tar.gz or build context +comma-separated provider is a chain whose deploys skip a provider that refuses them and fall back from one provider to +the next, so a deploy is refused only when every provider in it refuses it. A run is refused when a deploy would fail on +its provider: an image the provider can't run, by the check the deploy itself makes, such as one a sandbox elsewhere +would pull from this machine's registry; a VM asked of a provider that can't create one; or containers on the local +provider without a Docker daemon. An image's tar.gz or build context that only this machine's object store holds isn't refused: agent-env pushes it to the sandbox. A deploy_agent step that names no agent, and a judge that names none, deploy the configured default agent, which must be in the store. An env the bundle writes is checked from its planned env.toml. @@ -23,6 +24,7 @@ from __future__ import annotations import functools +import itertools from collections.abc import Callable, Iterable from dataclasses import dataclass @@ -119,7 +121,6 @@ def __init__(self, plan: Plan, sandbox: str | None): self.problems: dict[str, dict[str, None]] = {} # each deploy's problem, and the deploys it's found at self.run_problems: list[str] = [] # the run's as a whole self.infra: set[str] = set() # the infra kinds a deploy on the local provider needs - self.remote_infra: list[tuple[str, SandboxProvider, set[str]]] = [] # (where, provider, kinds) of a deploy elsewhere self.docker_users: list[str] = [] self.default_agent_users: list[tuple[str, str]] = [] # (where, what names no agent) self.written = {write.id for write in plan.writes} @@ -152,8 +153,6 @@ def finish(self) -> Preflight: infra = infra_to_build(self.infra) except InfraError as e: self.run_problems.extend(e.problems) - for where, provider, kinds in self.remote_infra: - self._remote_infra(where, provider, kinds) contexts = self._contexts() self._default_agent() if self.docker_users and (reason := docker_unreachable()): @@ -188,22 +187,20 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: else: # a plugin's provider, or one deploy_env's own preflight refuses for this env return env_id, images, env_provider = step.env_id, deployment.images, deployment.provider - self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds) + elsewhere = functools.partial(self._gateway_elsewhere, env_id, env_provider, kinds) if GATEWAY in kinds else None + self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere) if _local_link(provider): self.infra |= kinds self.docker_users.append(where) - if (remote := _remote_links(provider)) and GATEWAY in kinds: - for link in remote: - if problem := env_provider.sandbox_problem(link, websites=WEBSITE_BROWSER in kinds): - self._problem(where, f"deploys env {env_id!r} on the {_shown(link)} sandbox provider, which " - f"{problem}; run it on a VM provider, such as --sandbox local") - break - containers = [link for link in remote if not env_provider.in_vm(link)] - vms = [link for link in remote if link not in containers] - if containers: - self.remote_infra.append((where, containers[0], kinds - {WEBSITE_BROWSER})) - if vms: - self.remote_infra.append((where, vms[0], kinds)) + + def _gateway_elsewhere(self, env_id: str, env_provider: type, kinds: set[str], link: SandboxProvider) -> list[str]: + """Why ``link``, a provider not on this machine, can't run env ``env_id``'s gateway deploy: its sandboxes, or + the infra envs of ``kinds`` it runs, which agent-env builds only for the local provider.""" + found = [] + if problem := env_provider.sandbox_problem(link, websites=WEBSITE_BROWSER in kinds): + found.append(f"deploys env {env_id!r} on the {_shown(link)} sandbox provider, which {problem}; run it on a VM " + "provider, such as --sandbox local") + return found + self._remote_infra(link, kinds if env_provider.in_vm(link) else kinds - {WEBSITE_BROWSER}) def _stored(self, step: DeployEnvTaskStep) -> _Deployment | None: """How a store env deploys, or None when the walk leaves it to deploy_env's own preflight.""" @@ -312,9 +309,9 @@ def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: return if _local_link(provider): self.docker_users.append(where) - if step.image and (remote := _remote_links(provider)): + if step.image and not _local_link(provider): # one on this machine pulls from its registry, and a deploy skips the rest if problem := pull_problem(step.image, on_this_machine=False): - self._problem(where, f"deploys the image {step.image} on the {_shown(remote[0])} sandbox provider: " + self._problem(where, f"deploys the image {step.image} on the {_shown(provider)} sandbox provider: " f"{problem}; run it with --sandbox local") # What a deploy runs @@ -337,73 +334,74 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: return None def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], - images: list[_Image], *, vms_load: bool) -> None: - """Check ``images`` on each provider a deploy may run on, as ``accepts`` says the deploy runs them there, and - that one not on this machine reaches each and one declaring its platforms runs each; ``vms_load`` when the - deploy loads images on a VM provider, as an agent's and a gateway's do. An image the bundle builds is noted with - them, for ``_contexts`` to choose its form.""" + images: list[_Image], *, vms_load: bool, + elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> None: + """Refuse the deploy ``where`` when no provider it may run on takes it, naming why each refuses: one refuses an + image it can't run as ``accepts`` says the deploy runs images there (``_refusal``), and one not on this machine + whatever ``elsewhere`` says of it. A deploy skips a provider that refuses it, so one that takes it is enough. + ``vms_load`` when the deploy loads images on a VM provider, as an agent's and a gateway's do. An image the + bundle builds is noted with them, for ``_contexts`` to choose its form.""" links = list(provider.links) - checked: set[tuple[Accepts, bool, frozenset[str] | None]] = set() - for link in links: # the first link of each form, where it runs and what it runs - if (key := (accepts(link), link.ON_THIS_MACHINE, link.PLATFORMS)) not in checked: - checked.add(key) - self._accepted(where, link, key[0], images, vms_load=vms_load) + refusals = [[refusal for image in images if (refusal := self._refusal(link, accepts(link), image, vms_load=vms_load))] + + (elsewhere(link) if elsewhere and not link.ON_THIS_MACHINE else []) for link in links] + if all(refusals): + for problem in itertools.chain.from_iterable(refusals): + self._problem(where, problem) for image in images: if image.built is not None: self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) - def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: list[_Image], *, - vms_load: bool, put: str | None = None) -> None: - """Refuse each of ``images`` a deploy can't run on ``link``, which takes images in ``form``, with the check the - deploy makes (``Accepts.problem``): a VM loads an image's tar.gz, or pulls an image with none when its name - names a registry it reaches, and a provider that runs an image by name can't run one that is only a build + def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool, + put: str | None = None) -> str | None: + """Why a deploy can't run ``image`` on ``link``, which takes images in ``form``, by the check the deploy makes + (``Accepts.problem``), or None when it can: a VM loads an image's tar.gz, or pulls an image with none when its + name names a registry it reaches, and a provider that runs an image by name can't run one that is only a build context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's registry; and a provider declaring its platforms runs only an image of one of them. ``put`` is the command that puts the image again, when there is one.""" - for image in images: - if image.artifact is None: - continue - if (refusal := form.problem(image.artifact, on=link)) is None: - continue - if refusal == image.artifact.platform_problem(link.PLATFORMS): - platform = sorted(link.PLATFORMS)[0] - again = f"`{put} --platform {platform}`" if put else f"--platform {platform} on its put command" - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; put it " - f"again for {' or '.join(sorted(link.PLATFORMS))} ({again}), or run it with " - "--sandbox local") - continue - if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine - loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" - if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE - and image.artifact.platform_problem(LINUX_AMD64) is None else "") - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it " - f"with --sandbox local{loads}") - continue - if form is Accepts.LOADABLE: - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which can't load " - f"it: {refusal}") - continue - builds_itself = link.CONTAINER_ACCEPTS is Accepts.NAME_OR_CONTEXT - builds = ("whose VMs build it, such as --sandbox modal_vm" if builds_itself - else "that builds it, such as --sandbox modal") - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which runs it by name: " - f"{refusal}; run it on a provider {builds}") - - def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) -> None: + if image.artifact is None or (refusal := form.problem(image.artifact, on=link)) is None: + return None + if refusal == image.artifact.platform_problem(link.PLATFORMS): + platform = sorted(link.PLATFORMS)[0] + again = (f"`{put} --platform {platform}`" if put else + f"--platform {platform} on the put command that builds it, or `docker build --platform {platform}` " + "for one put by name") + return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; rebuild it for " + f"{' or '.join(sorted(link.PLATFORMS))} ({again}; a Linux host of another platform builds it only " + "with QEMU emulation set up), or run it with --sandbox local") + if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine + loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" + if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE + and image.artifact.platform_problem(LINUX_AMD64) is None else "") + return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with " + f"--sandbox local{loads}") + if form is Accepts.LOADABLE: + return f"deploys {image.what} on the {_shown(link)} sandbox provider, which can't load it: {refusal}" + builds_itself = link.CONTAINER_ACCEPTS is Accepts.NAME_OR_CONTEXT + builds = ("whose VMs build it, such as --sandbox modal_vm" if builds_itself + else "that builds it, such as --sandbox modal") + return (f"deploys {image.what} on the {_shown(link)} sandbox provider, which runs it by name: {refusal}; run it " + f"on a provider {builds}") + + def _remote_infra(self, link: SandboxProvider, kinds: set[str]) -> list[str]: + """Why ``link``, a provider not on this machine, can't run the infra envs of ``kinds``.""" + found = [] for kind in sorted(kinds): env_id = default_env_id(kind) try: env = Env.get(env_id) except NotFoundError: - self._problem(where, f"deploys on the {_shown(provider)} sandbox provider, which needs the {kind} env " - f"{env_id!r}, and the store doesn't hold it; agent-env builds it only for the local " - f"sandbox provider, so put it in a store that provider can reach (`{put_command(kind)}`)") + found.append(f"deploys on the {_shown(link)} sandbox provider, which needs the {kind} env {env_id!r}, and " + "the store doesn't hold it; agent-env builds it only for the local sandbox provider, so put " + f"it in a store that provider can reach (`{put_command(kind)}`)") continue artifacts = ([env.db_docker_image_artifact, env.db_web_docker_image_artifact, env.db_mcp_docker_image_artifact] if isinstance(env, ServiceDBEnv) else [env.docker_image_artifact]) - images = [_Image(f"the {kind} env {env_id!r}'s image {image.id!r}", image) for image in artifacts] - accepts = EnvironmentGatewayProvider.accepts(provider, prepared=kind == GATEWAY) - self._accepted(where, provider, accepts, images, vms_load=True, put=put_command(kind)) + accepts = EnvironmentGatewayProvider.accepts(link, prepared=kind == GATEWAY) + found += [refusal for image in artifacts + if (refusal := self._refusal(link, accepts, _Image(f"the {kind} env {env_id!r}'s image {image.id!r}", + image), vms_load=True, put=put_command(kind)))] + return found def _contexts(self) -> frozenset[str]: """The images the bundle builds that it writes as build contexts: those every deploy runs on providers that @@ -485,10 +483,6 @@ def _local_link(provider: SandboxProvider) -> bool: return any(link.ON_THIS_MACHINE for link in provider.links) -def _remote_links(provider: SandboxProvider) -> list[SandboxProvider]: - return [link for link in provider.links if not link.ON_THIS_MACHINE] - - def _shown(provider: SandboxProvider) -> str: return _named(list(provider.links)) 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 631214a8..fd8e2d28 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -2,6 +2,7 @@ import asyncio import logging +from collections.abc import Callable from typing import TYPE_CHECKING from agent_env.attribution import Attribution @@ -113,3 +114,18 @@ async def close(self) -> None: await p.close() except Exception as e: logger.warning(f"close() failed on {type(p).__name__}: {e}") + + +def skip_refusing(provider: SandboxProvider, problem: Callable[[SandboxProvider], str | None] + ) -> tuple[SandboxProvider | None, list[tuple[SandboxProvider, str]]]: + """``provider`` less each of its links ``problem`` refuses, each skipped with a warning, and the refusals: a deploy + runs on any link that takes it. None in place of the provider when ``problem`` refuses every link.""" + refused = [(link, reason) for link in provider.links if (reason := problem(link))] + if not refused: + return provider, [] + running = [link for link in provider.links if all(link is not refusing for refusing, _ in refused)] + if not running: + return None, refused + for link, reason in refused: + logger.warning(f"sandbox_provider_skipped provider={type(link).__name__}: {reason}") + return ChainedSandboxProvider(running), refused diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index abc5bb94..c7e45e49 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -6,6 +6,7 @@ from typing import ClassVar, Optional from agent_env.artifact.artifacts.docker_image import pull_problem +from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import DeployedSandbox, TaskStepContext from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -141,10 +142,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: else: if self.image is None or self.port is None: raise ValueError("container-mode sandbox requires `image` and `port`") - if problems := [(link, problem) for link in provider.links - if (problem := pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE))]: - link, problem = problems[0] - raise ValueError(f"Can't deploy sandbox '{self.sandbox_name}' with {type(link).__name__}: {problem}") + provider, refused = skip_refusing( + provider, lambda link: pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE)) + if provider is None: + refusals = "; nor ".join(f"with {type(link).__name__}: {problem}" for link, problem in refused) + raise ValueError(f"Can't deploy sandbox '{self.sandbox_name}' {refusals}") sandbox = await provider.create_sandbox( image_name=self.image, port=self.port, diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 401ba60d..b30283de 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -61,16 +61,31 @@ def litellm(monkeypatch): monkeypatch.setenv("LITELLM_BASE_URL", "http://litellm.example/v1") +BY_NAME = ("with _LocalProvider, which runs an agent's image by name: 'solver-image' v2 is only a build context, which has " + "to be built, not pulled") + + @pytest.mark.asyncio -@pytest.mark.parametrize("provider", [_LocalProvider(), ChainedSandboxProvider([_VmProvider(), _LocalProvider()])], - ids=["local", "chain-with-local"]) -async def test_a_provider_that_runs_the_agent_by_name_refuses_it(monkeypatch, local_stores, litellm, provider): +@pytest.mark.parametrize("provider, refusal", [ + (_LocalProvider(), BY_NAME), + (ChainedSandboxProvider([_LocalProvider(), _LocalProvider()]), f"{BY_NAME}; nor {BY_NAME}"), +], ids=["local", "chain-of-locals"]) +async def test_a_provider_that_runs_the_agent_by_name_refuses_it(monkeypatch, local_stores, litellm, provider, refusal): monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) - with pytest.raises(ValueError, match="Can't deploy agent 'solver' with _LocalProvider, which runs an agent's image by " - "name: 'solver-image' v2 is only a build context, which has to be built, not " - "pulled"): + with pytest.raises(ValueError, match=f"Can't deploy agent 'solver' {refusal}"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + + +@pytest.mark.asyncio +async def test_a_chain_skips_a_provider_that_runs_the_agent_by_name_for_one_that_builds_it(monkeypatch, local_stores, + litellm, caplog): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([_LocalProvider(), _VmProvider()])) + + with pytest.raises(RuntimeError, match=r"All 1 providers failed: _VmProvider: _Created\(\)"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert "sandbox_provider_skipped provider=_LocalProvider: 'solver-image' v2 is only a build context" in caplog.text @pytest.mark.asyncio diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index e445fde3..8ec04121 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -107,19 +107,25 @@ def _problems(fn): # Images only this machine has -@pytest.mark.parametrize("sandbox", ["modal", "local,modal"]) def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere_pulls_it_before_anything_is_written( - bundle_dir, sandbox): + bundle_dir): _agent("solver") _task(bundle_dir, [AGENT]) - assert _problems(lambda: run_bundle(bundle_dir, sandbox=sandbox)) == [ + assert _problems(lambda: run_bundle(bundle_dir, sandbox="modal")) == [ f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider: {HERE}; run it " "with --sandbox local, or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm", ] assert not local_store().path.exists() +def test_a_chain_runs_an_image_in_this_machines_registry_on_its_local_provider_skipping_one_elsewhere(bundle_dir): + _agent("solver") + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal,local").runs] == ["t"] + + @pytest.mark.parametrize("where", [LOCAL, (REMOTE[0], LOCAL[1])]) def test_a_vm_elsewhere_loads_a_tar_gz_only_this_machine_holds_which_agent_env_pushes_it(bundle_dir, where): """It never pulls the image, so a registry on this machine doesn't matter; the tar.gz goes over the VM's exec.""" @@ -325,17 +331,16 @@ def _context_image(id): @pytest.mark.parametrize("sandbox, refused", [("modal_vm", False), ("modal", False), ("modal_vm,modal", False), - ("local", True), ("modal_vm,local", True)]) + ("local", True), ("modal_vm,local", False)]) def test_an_agent_image_that_is_only_a_build_context_runs_where_a_vm_or_modal_builds_it(bundle_dir, sandbox, refused): + """A chain skips the local provider, which runs an image by name, so one that builds it is enough.""" with namespace_routing(): A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) _task(bundle_dir, [AGENT]) if refused: - link = "local" if "local" in sandbox else sandbox assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ - "tasks/t.json: step 'agent': deploys agent 'solver''s image " - + BY_NAME.format(sandbox=sandbox if link == sandbox else link, id="solver-image")] + "tasks/t.json: step 'agent': deploys agent 'solver''s image " + BY_NAME.format(sandbox=sandbox, id="solver-image")] else: assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] @@ -384,6 +389,9 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser # Images built for a platform a provider's sandboxes don't run NOT_AMD64 = "v1 is a linux/arm64 image, and the sandbox runs linux/amd64" +EMULATION = "a Linux host of another platform builds it only with QEMU emulation set up" +REBUILD = (f"rebuild it for linux/amd64 (--platform linux/amd64 on the put command that builds it, or `docker build " + f"--platform linux/amd64` for one put by name; {EMULATION}), or run it with --sandbox local") class _Vms(SandboxProvider): @@ -402,25 +410,38 @@ class _ArmVms(_Vms): PLATFORMS = frozenset({"linux/arm64"}) -@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("vms", True), ("local", False), ("arm-vms", False)]) -def test_an_image_for_a_platform_a_provider_doesnt_run_is_refused_there(bundle_dir, monkeypatch, sandbox, refused): - """A provider runs linux/amd64 unless it declares otherwise; the local provider runs any, since its Docker emulates - other platforms.""" +@pytest.fixture +def plugin_vms(monkeypatch): registry = Config.sandbox_registry monkeypatch.setattr(Config, "sandbox_registry", lambda self: {**registry(self), "vms": {"impl": _Vms}, "arm-vms": {"impl": _ArmVms}}) + + +@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("vms", True), ("local", False), ("arm-vms", False), + ("modal_vm,local", False), ("modal_vm,arm-vms", False)]) +def test_an_image_for_a_platform_a_provider_doesnt_run_is_refused_there(bundle_dir, plugin_vms, sandbox, refused): + """A provider runs linux/amd64 unless it declares otherwise; the local provider runs any, since its Docker emulates + other platforms. A chain skips a provider that doesn't run it.""" _agent("solver", ARM64_HERE) _task(bundle_dir, [AGENT]) if refused: assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {sandbox!r} sandbox provider: " - f"'solver-image' {NOT_AMD64}; put it again for linux/amd64 (--platform linux/amd64 on its put command), or " - "run it with --sandbox local"] + f"'solver-image' {NOT_AMD64}; {REBUILD}"] else: assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] +def test_a_chain_none_of_whose_providers_runs_an_image_is_refused_naming_each(bundle_dir, plugin_vms): + _agent("solver", ARM64_HERE) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm,vms")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {link!r} sandbox provider: 'solver-image' " + f"{NOT_AMD64}; {REBUILD}" for link in ("modal_vm", "vms")] + + def test_infra_built_here_for_another_platform_is_refused_on_a_vm_provider_naming_the_command_to_put_it_again(bundle_dir): """The infra bootstrap builds for this machine, linux/arm64 on Apple silicon, and the VM providers run linux/amd64.""" _env("crm", REMOTE) @@ -430,11 +451,20 @@ def test_infra_built_here_for_another_platform_is_refused_on_a_vm_provider_namin problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " - f"'modal_vm' sandbox provider: 'gateway-default' {NOT_AMD64}; put it again for linux/amd64 " - "(`agent-env env gateway put --id default --platform linux/amd64`), or run it with --sandbox local") + f"'modal_vm' sandbox provider: 'gateway-default' {NOT_AMD64}; rebuild it for linux/amd64 " + f"(`agent-env env gateway put --id default --platform linux/amd64`; {EMULATION}), or run it with " + "--sandbox local") assert len(problems) == 4 # the gateway's image, and the service-db's three +def test_infra_built_here_for_another_platform_runs_on_a_chain_that_falls_back_to_the_local_provider(bundle_dir): + _env("crm", REMOTE) + _infra(ARM64_HERE) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm,local").runs] == ["t"] + + def test_a_vm_provider_isnt_offered_for_an_image_its_vms_wouldnt_run(bundle_dir): """Modal can't pull from this machine's registry; a VM would load the tar.gz, but it's for linux/arm64.""" _agent("solver", ARM64_HERE) diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index f7f73923..f28469d9 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import pytest @@ -11,7 +12,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.config import get_config from agent_env.config.loader import load_impl -from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider, skip_refusing from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider @@ -283,6 +284,31 @@ async def test_chain_create_sandbox_all_fail_raises_with_all_names(): assert "second boom" in msg +def test_skip_refusing_leaves_a_provider_none_of_whose_links_it_refuses(): + chain = ChainedSandboxProvider([_StubProvider(), _StubProvider()]) + + assert skip_refusing(chain, lambda link: None) == (chain, []) + + +def test_skip_refusing_chains_the_links_it_doesnt_refuse_warning_of_each_it_skips(caplog): + first, second, third = _StubProvider(), _StubProvider(), _StubProvider() + + with caplog.at_level(logging.WARNING, logger=chained_module.__name__): + running, refused = skip_refusing(ChainedSandboxProvider([first, second, third]), + lambda link: "too far" if link is second else None) + + assert running.links == (first, third) and refused == [(second, "too far")] + assert "sandbox_provider_skipped provider=_StubProvider: too far" in caplog.text + + +@pytest.mark.parametrize("provider", [_StubProvider(), ChainedSandboxProvider([_StubProvider(), _StubProvider()])], + ids=["one", "chain"]) +def test_skip_refusing_gives_no_provider_when_it_refuses_every_link(provider): + running, refused = skip_refusing(provider, lambda link: "too far") + + assert running is None and refused == [(link, "too far") for link in provider.links] + + _CONTEXT_ONLY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="local/solver-0123456789ab:v2", build_context_object_url="s3://bucket/ctx.tar.gz") diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py index 0c889387..4c14cf75 100644 --- a/tst/unit/task_step/deploy_sandbox_image_test.py +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -1,10 +1,11 @@ """A container-mode deploy_sandbox runs its image by name, so a provider whose sandboxes are elsewhere can't pull one from -this machine's registry; one on this machine can.""" +this machine's registry; one on this machine can, and a chain skips the rest for it.""" from unittest.mock import AsyncMock, patch import pytest +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep @@ -36,3 +37,18 @@ async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_on "registry on this machine, which a sandbox elsewhere can't pull from"): await step.execute(TaskStepContext()) provider.create_sandbox.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_chain_pulls_an_image_from_this_machines_registry_on_its_provider_here_skipping_one_elsewhere(): + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="localhost:5000/box:v1", port=80) + elsewhere, here = _provider(on_this_machine=False), _provider(on_this_machine=True) + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", + return_value=ChainedSandboxProvider([elsewhere, here])): + await step.execute(TaskStepContext()) + + elsewhere.create_sandbox.assert_not_awaited() + here.create_sandbox.assert_awaited_once() + From 5e1926e0cf11c4c1b024f881e2b56bdce2c8a1ba Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 04:00:54 -0700 Subject: [PATCH 07/13] fix(images): a platform compares equal however it's spelled normalize_platform names a platform as docker build --platform does: lower case, the architecture by Docker's name (x86_64 is amd64, aarch64 arm64), and no variant but 32-bit arm's. A put records the platform that way, and the platform check compares both the image's and the sandbox's that way, so a linux/amd64/v2 image runs on a linux/amd64 sandbox and a plugin declaring linux/x86_64 runs linux/amd64 images. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 19 ++++++++++--------- src/agent_env/utils/docker_build.py | 16 ++++++++++++++++ .../test_docker_image_build_context.py | 10 ++++++++++ .../provider_capabilities_test.py | 9 +++++++++ tst/unit/utils/docker_build_test.py | 14 ++++++++++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 4646b281..64878f4a 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -27,7 +27,7 @@ from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, names_registry, registry_host_from_ref from agent_env.store.image_store.registry_api import pin_digest from agent_env.utils.build_context import BuildContext -from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, normalize_platform logger = logging.getLogger(__name__) @@ -126,7 +126,7 @@ def put( image_ref = image_store.image_ref(repository, f"v{version}") image_store.ensure_repository(repository) _push_local_image(image_name, image_ref, image_store) - platform = platform or _image_platform(image_ref) + platform = normalize_platform(platform) if platform else _image_platform(image_ref) with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: tmp_path = Path(tmp.name) @@ -199,7 +199,7 @@ def put_tar( tar_gz_object_url=tar_gz_object_url, build_context_object_url=build_context_object_url, dockerfile_path=dockerfile_path, - platform=platform, + platform=normalize_platform(platform) if platform else None, source_digest=source_digest, ) return store.put_document(instance) @@ -239,6 +239,7 @@ def put_context(cls, id: str, *, description: str, context_path: str, dockerfile from agent_env.artifact.store import get_artifact_store from agent_env.config import get_config + platform = normalize_platform(platform) root = Path(context_path) dockerfile = _resolve_dockerfile(root, dockerfile_path) if not dockerfile.is_file(): @@ -297,9 +298,11 @@ def by_name_problem(self, *, on_this_machine: bool = True, platforms: Collection def platform_problem(self, platforms: Collection[str] | None) -> str | None: """Why a sandbox that runs only images of ``platforms`` can't run this one, or None when it can: an image runs - on the platform it records, the one it's built for. One that records none, or a sandbox that declares none, - isn't checked.""" - if platforms is None or self.platform is None or self.platform in platforms: + on the platform it records, the one it's built for, however either is spelled (``normalize_platform``). One + that records none, or a sandbox that declares none, isn't checked.""" + if platforms is None or self.platform is None: + return None + if normalize_platform(self.platform) in {normalize_platform(platform) for platform in platforms}: return None runs = ", ".join(sorted(platforms)) return f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {runs}" @@ -560,9 +563,7 @@ def _image_platform(image_ref: str) -> str | None: capture_output=True, text=True) if result.returncode != 0 or result.stdout.count("/") != 2: return None - os_name, architecture, variant = result.stdout.strip().split("/") - # arm64 has one variant, so it goes unnamed as in `--platform linux/arm64`; 32-bit arm's v6 and v7 differ. - return f"{os_name}/{architecture}/{variant}" if architecture == "arm" and variant else f"{os_name}/{architecture}" + return normalize_platform(result.stdout) def _resolve_dockerfile(context_dir: Path, dockerfile_path: str | None) -> Path | None: diff --git a/src/agent_env/utils/docker_build.py b/src/agent_env/utils/docker_build.py index 870fbd36..aedeae8a 100644 --- a/src/agent_env/utils/docker_build.py +++ b/src/agent_env/utils/docker_build.py @@ -11,6 +11,22 @@ DOCKER_INFO_TIMEOUT_SECONDS = 10 +# Docker's names for architectures that hosts and tools also call otherwise. +_ARCHITECTURES = {"x86_64": "amd64", "x86-64": "amd64", "aarch64": "arm64", "i386": "386"} + + +def normalize_platform(platform: str) -> str: + """``platform`` as ``docker build --platform`` names it, so the same platform compares equal however it's spelled: + lower case, the architecture by Docker's name for it, and no variant but 32-bit arm's, whose v6 and v7 differ, so an + amd64 or arm64 image is known by its architecture alone.""" + os_name, _, rest = platform.strip().lower().partition("/") + architecture, _, variant = rest.partition("/") + architecture = _ARCHITECTURES.get(architecture, architecture) + if architecture == "arm" and variant: + return f"{os_name}/{architecture}/{variant}" + return f"{os_name}/{architecture}" if architecture else os_name + + class DockerBuildError(RuntimeError): """A local ``docker build`` that failed or couldn't start; the message carries docker's output.""" diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index a39ae469..8505cf66 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -165,3 +165,13 @@ def test_put_records_the_platform_it_built_for_when_none_is_given(local_stores, assert (native.platform, explicit.platform) == ("linux/arm64", "linux/amd64") assert native.source_digest != explicit.source_digest + + + +def test_a_put_records_the_platform_as_docker_build_names_it(local_stores, context): + image = DockerImageArtifact.put_context("a", description="d", context_path=str(context), dockerfile_path="Dockerfile", + platform="linux/arm64/v8") + named = DockerImageArtifact.put_context("b", description="d", context_path=str(context), dockerfile_path="Dockerfile", + platform="linux/arm64") + + assert image.platform == "linux/arm64" and image.source_digest == named.source_digest diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index b5ec8353..3b83265c 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -157,6 +157,15 @@ def test_a_sandbox_declaring_its_platforms_runs_only_images_of_one_of_them(image assert accepts.problem(image, on=_on(False)) is None # one that declares none isn't checked +@pytest.mark.parametrize("recorded, declared", [ + ("linux/amd64/v2", "linux/amd64"), ("linux/arm64/v8", "linux/arm64"), ("linux/amd64", "linux/x86_64"), +]) +def test_a_platform_is_the_same_however_the_image_or_the_sandbox_spells_it(recorded, declared): + image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform=recorded, + tar_gz_object_url="s3://bucket/img.tar.gz") + assert LOADABLE.problem(image, on=_on(False, frozenset({declared}))) is None + + def test_an_image_that_records_no_platform_isnt_checked(): image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", tar_gz_object_url="s3://bucket/img.tar.gz") diff --git a/tst/unit/utils/docker_build_test.py b/tst/unit/utils/docker_build_test.py index 68a10dd5..9ea3e8e2 100644 --- a/tst/unit/utils/docker_build_test.py +++ b/tst/unit/utils/docker_build_test.py @@ -1,5 +1,5 @@ """``build_image``: the argv it runs, what a failed or impossible build raises, and that importing it pulls in -neither the CLI nor the providers, so library code can build too.""" +neither the CLI nor the providers, so library code can build too; and ``normalize_platform``.""" import os import subprocess @@ -10,7 +10,7 @@ import pytest from agent_env.utils import docker_build -from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, DockerBuildError, build_image +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, DockerBuildError, build_image, normalize_platform @pytest.fixture @@ -104,3 +104,13 @@ def run(argv, **kwargs): monkeypatch.setattr(docker_build.subprocess, "run", run) assert docker_build.docker_unreachable() == reason + + +@pytest.mark.parametrize("platform, normalized", [ + ("linux/amd64", "linux/amd64"), (" Linux/AMD64\n", "linux/amd64"), ("linux/x86_64", "linux/amd64"), + ("linux/amd64/v2", "linux/amd64"), ("linux/aarch64", "linux/arm64"), ("linux/arm64/v8", "linux/arm64"), + ("linux/arm/v7", "linux/arm/v7"), ("linux/arm/v6", "linux/arm/v6"), ("linux/i386", "linux/386"), +]) +def test_a_platform_is_named_by_os_and_architecture_keeping_only_32_bit_arms_variant(platform, normalized): + assert normalize_platform(platform) == normalized + From 8184f003ee56886cfb698cfcf28d9ed84b38a09f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 04:17:46 -0700 Subject: [PATCH 08/13] fix(preflight): a chain that skips the local provider needs no Docker or local infra A deploy whose chain skips the local provider, because it can't run the deploy's image, never runs there, so preflight no longer counts it among the deploys that need this machine's Docker, nor builds infra for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/preflight.py | 16 ++++++++-------- tst/unit/bundle/preflight_test.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index a2612438..d34ff3d2 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -188,8 +188,7 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: return env_id, images, env_provider = step.env_id, deployment.images, deployment.provider elsewhere = functools.partial(self._gateway_elsewhere, env_id, env_provider, kinds) if GATEWAY in kinds else None - self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere) - if _local_link(provider): + if self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere): self.infra |= kinds self.docker_users.append(where) @@ -292,13 +291,12 @@ def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | None, version: int | None, unnamed: str, accepts: Callable[[SandboxProvider], Accepts] = A2AAgent.accepts) -> None: - if _local_link(provider): - self.docker_users.append(where) if agent_id is None: self.default_agent_users.append((where, unnamed)) agent_id = get_config().get_default_a2a_agent_id() - if (image := self._agent_image(agent_id, version)) is not None: - self._check(where, provider, accepts, [image], vms_load=True) + image = self._agent_image(agent_id, version) + if self._check(where, provider, accepts, [image] if image else [], vms_load=True): + self.docker_users.append(where) def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: provider = _provider(self.sandbox or step.sandbox_type, get_sandbox_provider) @@ -335,12 +333,13 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], images: list[_Image], *, vms_load: bool, - elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> None: + elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> bool: """Refuse the deploy ``where`` when no provider it may run on takes it, naming why each refuses: one refuses an image it can't run as ``accepts`` says the deploy runs images there (``_refusal``), and one not on this machine whatever ``elsewhere`` says of it. A deploy skips a provider that refuses it, so one that takes it is enough. ``vms_load`` when the deploy loads images on a VM provider, as an agent's and a gateway's do. An image the - bundle builds is noted with them, for ``_contexts`` to choose its form.""" + bundle builds is noted with them, for ``_contexts`` to choose its form. Returns whether a provider on this + machine takes the deploy, which then may run on this machine's Docker; one it skips needs none.""" links = list(provider.links) refusals = [[refusal for image in images if (refusal := self._refusal(link, accepts(link), image, vms_load=vms_load))] + (elsewhere(link) if elsewhere and not link.ON_THIS_MACHINE else []) for link in links] @@ -350,6 +349,7 @@ def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[Sandb for image in images: if image.built is not None: self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) + return any(link.ON_THIS_MACHINE and not refused for link, refused in zip(links, refusals)) def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool, put: str | None = None) -> str | None: diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 8ec04121..d62dafb4 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -576,6 +576,18 @@ def test_containers_on_the_local_provider_need_docker_and_a_vm_sandbox_doesnt(bu ] +def test_a_chain_that_skips_the_local_provider_doesnt_need_docker(bundle_dir, monkeypatch): + """The local provider runs an image by name, so a deploy of one that is only a build context skips it for a VM.""" + probes = [] + monkeypatch.setattr(preflight_module, "docker_unreachable", lambda: probes.append(1) or "docker isn't on PATH") + with namespace_routing(): + A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm,local").runs] == ["t"] + assert probes == [] + + class _DeploysItself(Env): """An env type with a deploy() of its own, as a plugin's env can have.""" From f7819b2a90d45bc4bdaa3ba67735e72e39b806b7 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 08:41:14 -0700 Subject: [PATCH 09/13] refactor(sandbox): a sandbox carries its platform; a provider's sandbox modes are derived Providers no longer declare PLATFORMS. Each sandbox carries `platform`, the platform of the images it runs, as it carries `mode`: linux/amd64 unless its provider sets another when it creates it, and None, any platform unchecked, for a local sandbox. A VM refuses an image built for another platform as it loads it, before starting anything. Nothing checks a platform before a sandbox exists, so the dry run doesn't, and a chain doesn't route by platform. SUPPORTED_SANDBOX_MODES replaces CREATES_VMS, derived for each provider class: a VM when it implements create_vm, and a container always; a provider whose create_sandbox can't make a container declares its own. The gateway's choice of a VM and the dry run's sandbox-mode check read it. CREATES_VMS stays, derived from it, for plugins that still read it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- .../artifact/artifacts/docker_image.py | 32 +++--- src/agent_env/bundle/preflight.py | 32 +++--- .../env_providers/env_gateway_provider.py | 9 +- .../sandbox_providers/e2b/provider.py | 1 - .../sandbox_providers/local_sandbox.py | 7 +- .../sandbox_providers/modal_vm_sandbox.py | 1 - .../sandbox_providers/sail_vm/provider.py | 1 - .../providers/sandbox_providers/sandbox.py | 14 +-- .../sandbox_providers/sandbox_provider.py | 29 +++--- .../a2a_agent/context_only_deploy_test.py | 1 - tst/unit/bundle/preflight_test.py | 80 ++------------- .../env_gateway_provider_test.py | 42 +++----- .../provider_capabilities_test.py | 98 ++++++++++++------- .../sandbox_providers/vm_sandbox_test.py | 21 ++-- tst/util/sandbox_providers.py | 7 +- 16 files changed, 152 insertions(+), 225 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a37097b5..64f2eb0c 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`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `PLATFORMS`, which its sandboxes declare too: linux/amd64 unless declared, and any, unchecked, for the local provider), 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`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_vm`, of which `CREATES_VMS` is the deprecated spelling), 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/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 64878f4a..b46a3a9a 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -15,7 +15,6 @@ from dataclasses import dataclass from importlib.metadata import version as pkg_version from pathlib import Path -from collections.abc import Collection from typing import Any, Callable, ClassVar, Literal from urllib.parse import urlparse @@ -276,36 +275,33 @@ def context_only(self) -> bool: tag the image's build gives it.""" return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name) - def load_problem(self, *, on_this_machine: bool = True, platforms: Collection[str] | None = None) -> str | None: + def load_problem(self, *, on_this_machine: bool = True, platform: str | None = None) -> str | None: """Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build - context when it has only that; and a VM declaring ``platforms`` runs only an image of one of them.""" + context when it has only that; and a VM of ``platform`` runs only an image built for it.""" if self.tar_gz_object_url or self.context_only: - return self.platform_problem(platforms) + return self.platform_problem(platform) if names_registry(self.image_name): - return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platforms) + return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platform) return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a " "registry to pull it from") - def by_name_problem(self, *, on_this_machine: bool = True, platforms: Collection[str] | None = None) -> str | None: + def by_name_problem(self, *, on_this_machine: bool = True) -> str | None: """Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on - Docker Hub; a sandbox not ``on_this_machine`` can't pull from a registry on this machine; and one declaring - ``platforms`` runs only an image of one of them.""" + Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" - return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platforms) + return pull_problem(self.image_name, on_this_machine=on_this_machine) - def platform_problem(self, platforms: Collection[str] | None) -> str | None: - """Why a sandbox that runs only images of ``platforms`` can't run this one, or None when it can: an image runs - on the platform it records, the one it's built for, however either is spelled (``normalize_platform``). One - that records none, or a sandbox that declares none, isn't checked.""" - if platforms is None or self.platform is None: + def platform_problem(self, platform: str | None) -> str | None: + """Why a sandbox that runs images of ``platform`` can't run this one, or None when it can: an image runs on the + platform it records, the one it's built for, however either is spelled (``normalize_platform``). One that + records none, or a sandbox that runs any (None), isn't checked.""" + if platform is None or self.platform is None or normalize_platform(self.platform) == normalize_platform(platform): return None - if normalize_platform(self.platform) in {normalize_platform(platform) for platform in platforms}: - return None - runs = ", ".join(sorted(platforms)) - return f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {runs}" + return (f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {platform}; rebuild it for " + f"{platform}, which a Linux host of another platform does only with QEMU emulation set up") @classmethod async def put_from_github( diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index d34ff3d2..c7cd2b0b 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -54,8 +54,8 @@ from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider from agent_env.providers.env_state.env_state_provider import LOCAL_POSTGRES_STATE_TYPE from agent_env.providers.env_state.store import get_env_state_instance_store -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64 from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, Accepts, SandboxProvider, build_sandbox_provider, @@ -68,7 +68,7 @@ from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep -from agent_env.utils.docker_build import docker_unreachable +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, docker_unreachable from ._fs import relative from .parse import BundleError, BundleKind @@ -300,10 +300,12 @@ def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | N def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: provider = _provider(self.sandbox or step.sandbox_type, get_sandbox_provider) - if step.sandbox_mode == "vm": - if not provider.CREATES_VMS: - self._problem(where, f"deploys a VM sandbox, and the {_shown(provider)} sandbox provider can't create a " - "VM; run it on one that can, such as --sandbox local") + if step.sandbox_mode not in provider.SUPPORTED_SANDBOX_MODES: + kind = "VM" if step.sandbox_mode == SANDBOX_MODE_VM else step.sandbox_mode + self._problem(where, f"deploys a {kind} sandbox, and the {_shown(provider)} sandbox provider can't create a " + f"{kind}; run it on one that can, such as --sandbox local") + return + if step.sandbox_mode == SANDBOX_MODE_VM: return if _local_link(provider): self.docker_users.append(where) @@ -351,28 +353,18 @@ def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[Sandb self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) return any(link.ON_THIS_MACHINE and not refused for link, refused in zip(links, refusals)) - def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool, - put: str | None = None) -> str | None: + def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool) -> str | None: """Why a deploy can't run ``image`` on ``link``, which takes images in ``form``, by the check the deploy makes (``Accepts.problem``), or None when it can: a VM loads an image's tar.gz, or pulls an image with none when its name names a registry it reaches, and a provider that runs an image by name can't run one that is only a build context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's - registry; and a provider declaring its platforms runs only an image of one of them. ``put`` is the command that - puts the image again, when there is one.""" + registry. A VM it would create checks the image's platform once it exists.""" if image.artifact is None or (refusal := form.problem(image.artifact, on=link)) is None: return None - if refusal == image.artifact.platform_problem(link.PLATFORMS): - platform = sorted(link.PLATFORMS)[0] - again = (f"`{put} --platform {platform}`" if put else - f"--platform {platform} on the put command that builds it, or `docker build --platform {platform}` " - "for one put by name") - return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; rebuild it for " - f"{' or '.join(sorted(link.PLATFORMS))} ({again}; a Linux host of another platform builds it only " - "with QEMU emulation set up), or run it with --sandbox local") if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE - and image.artifact.platform_problem(LINUX_AMD64) is None else "") + and image.artifact.platform_problem(DEFAULT_BUILD_PLATFORM) is None else "") return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with " f"--sandbox local{loads}") if form is Accepts.LOADABLE: @@ -400,7 +392,7 @@ def _remote_infra(self, link: SandboxProvider, kinds: set[str]) -> list[str]: accepts = EnvironmentGatewayProvider.accepts(link, prepared=kind == GATEWAY) found += [refusal for image in artifacts if (refusal := self._refusal(link, accepts, _Image(f"the {kind} env {env_id!r}'s image {image.id!r}", - image), vms_load=True, put=put_command(kind)))] + image), vms_load=True))] return found def _contexts(self) -> frozenset[str]: diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 016b4b68..4d3f126a 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -35,7 +35,12 @@ ) from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider, _size_kwargs, _tool_names from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox, port_bindings -from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_CONTAINER, + SANDBOX_MODE_VM, + Accepts, + SandboxProvider, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -129,7 +134,7 @@ class EnvironmentGatewayProvider(_SandboxEnvironmentProvider): def in_vm(sandbox_provider: SandboxProvider) -> bool: """Whether a deploy on ``sandbox_provider`` runs in a VM, as one on a provider that creates VMs does; on one that doesn't, the gateway and each server run in a container of their own, on the provider's private network.""" - return sandbox_provider.CREATES_VMS + return SANDBOX_MODE_VM in sandbox_provider.SUPPORTED_SANDBOX_MODES @classmethod def accepts(cls, sandbox_provider: SandboxProvider, *, prepared: bool = True) -> Accepts: diff --git a/src/agent_env/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index b0a293a7..d2f25479 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -38,7 +38,6 @@ class E2BSandboxProvider(SandboxProvider): interpolation in ``[sandbox.providers.e2b.config]``. """ - CREATES_VMS = True 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``. diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index ec9ae9fb..aa285861 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -135,7 +135,7 @@ class LocalSandbox(VmSandbox): """ ON_THIS_MACHINE = True - PLATFORMS = None # what this machine's Docker runs, emulating other platforms where it's set up + platform = None # this machine's Docker runs its own platform, and emulates others where it's set up def url_from_sandbox(self, url: str) -> str: return LocalSandboxProvider.get_external_url(url) @@ -498,11 +498,6 @@ class LocalSandboxProvider(SandboxProvider): EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS) ON_THIS_MACHINE = True - # A sandbox or a container is a container from the image's name, which this machine's Docker pulls or already - # holds; a VM it creates (create_vm) loads images of its own. - CREATES_VMS = True - # Any platform, unchecked: this machine's Docker runs its own and emulates others where it's set up. - PLATFORMS = None def url_from_sandbox(self, url: str) -> str: return self.get_external_url(url) 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 ca340771..d0762293 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -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") - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name @classmethod 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 ba208f07..25d2e562 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -134,7 +134,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",) - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name def __init__( diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 67c4cbce..5a34903a 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -51,8 +51,6 @@ # Docker label on what a step starts on a sandbox's Docker host (containers, images, networks), valued with the # sandbox id, so a sandbox that shares its host (the local one) can remove its own when it terminates. SANDBOX_LABEL = "agentenv.sandbox" -# The platforms of a sandbox on an x86-64 Linux host, as an image records the one it's built for. -LINUX_AMD64 = frozenset({"linux/amd64"}) # At most this many image signs in flight per event loop: each holds a worker thread, for up to a # remote signer's whole retry window, and a connection from its session's pool. @@ -154,8 +152,10 @@ class Sandbox(ABC): # Whether it runs on this machine, as a local sandbox does. ON_THIS_MACHINE: ClassVar[bool] = False - # The platforms it runs images of, as its provider declares them: linux/amd64 unless declared; None runs any. - PLATFORMS: ClassVar[frozenset[str] | None] = LINUX_AMD64 + # The platform of the images it runs, as an image records the one it's built for: linux/amd64, what agent-env + # builds for, unless its provider sets another when it creates it. None runs any, unchecked, as a local sandbox + # does, whose Docker runs this machine's platform and emulates others where it's set up. + platform: str | None = DEFAULT_BUILD_PLATFORM # The host other containers on its provider's private network reach it at, ready for a URL; None when it's on none. private_host: str | None = None @@ -295,13 +295,13 @@ async def wait_for_vm(self) -> None: async def load_docker_images(self, artifacts: list) -> None: """Put each DockerImageArtifact's image on the VM: those with a tar.gz are loaded from it, in parallel, those with only a build context are built from it, and the rest are pulled by image name. An image this VM can't get, - such as one to pull from a registry on this machine when the VM isn't on it, or can't run, being for a platform - it doesn't declare, is refused before anything is loaded.""" + such as one to pull from a registry on this machine when the VM isn't on it, or can't run, being built for + another platform than its own, is refused before anything is loaded.""" if not artifacts: return if problems := [problem for artifact in artifacts if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE, - platforms=self.PLATFORMS))]: + platform=self.platform))]: raise RuntimeError(f"Can't load images: {'; '.join(problems)}") if tarballs := [artifact for artifact in artifacts if artifact.tar_gz_object_url]: await self._load_tarballs(tarballs) diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index cce5f623..19b19833 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -16,7 +16,6 @@ from agent_env.config import ENV_REF_PREFIX from agent_env.config.errors import ConfigError from agent_env.providers.sandbox_providers.sandbox import ( - LINUX_AMD64, NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox, @@ -47,15 +46,14 @@ class Accepts(Enum): def problem(self, image: DockerImageArtifact, *, on: SandboxProvider | Sandbox | None = None) -> str | None: """Why a provider that accepts these can't run ``image`` on ``on``, the provider or one of its sandboxes, or - None when it can: one not on this machine can't pull from a registry on it, and one that declares its - platforms runs only images of one of them. With no ``on``, neither is checked.""" + None when it can: one not on this machine can't pull from a registry on it. With no ``on``, that isn't + checked.""" on_this_machine = on is None or on.ON_THIS_MACHINE - platforms = None if on is None else on.PLATFORMS if self is Accepts.LOADABLE: - return image.load_problem(on_this_machine=on_this_machine, platforms=platforms) + return image.load_problem(on_this_machine=on_this_machine) if self is Accepts.NAME_OR_CONTEXT and image.context_only: - return None # the provider builds it, for a platform of its own - return image.by_name_problem(on_this_machine=on_this_machine, platforms=platforms) + return None # the provider builds it + return image.by_name_problem(on_this_machine=on_this_machine) class SandboxProvider(ABC): @@ -177,17 +175,24 @@ def from_config(cls, **config: Any) -> Self: # 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. ON_THIS_MACHINE: ClassVar[bool] = False - # Whether create_vm gives a VM sandbox, as a provider that implements it does. + # The sandbox modes it creates, derived for each provider class (``__init_subclass__``): SANDBOX_MODE_VM when it + # implements create_vm, and SANDBOX_MODE_CONTAINER, since every provider implements create_sandbox. One whose + # create_sandbox can't make a container declares its own. + SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset({SANDBOX_MODE_CONTAINER}) + # Deprecated: SANDBOX_MODE_VM in SUPPORTED_SANDBOX_MODES, derived from it, for plugins that still read it. CREATES_VMS: ClassVar[bool] = False # The images create_sandbox and create_container run. SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False - # The platforms its sandboxes run images of, which its sandbox classes declare too: linux/amd64 unless declared. - # None runs any, unchecked, as the local provider does, whose Docker runs this machine's platform and emulates - # others where it's set up. - PLATFORMS: ClassVar[frozenset[str] | None] = LINUX_AMD64 + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "SUPPORTED_SANDBOX_MODES" not in cls.__dict__: + vm = cls.create_vm is not SandboxProvider.create_vm + cls.SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_CONTAINER, *([SANDBOX_MODE_VM] if vm else [])}) + cls.CREATES_VMS = SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES @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 b30283de..d23642c4 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -24,7 +24,6 @@ async def create_sandbox(self, **_): class _VmProvider(SandboxProvider): - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE async def create_sandbox(self, **_): diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index d62dafb4..cae607f2 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -29,7 +29,7 @@ from agent_env.providers.env_state.env_state_provider import EnvStateInstance from agent_env.providers.env_state.store import register_env_state_instance from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider -from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.store.routing import namespace_routing from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep @@ -386,83 +386,15 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser "built, not pulled; run it on a provider whose VMs build it, such as --sandbox modal_vm"] -# Images built for a platform a provider's sandboxes don't run +# Images built for another platform than the sandboxes run, which a VM refuses as it loads them -NOT_AMD64 = "v1 is a linux/arm64 image, and the sandbox runs linux/amd64" -EMULATION = "a Linux host of another platform builds it only with QEMU emulation set up" -REBUILD = (f"rebuild it for linux/amd64 (--platform linux/amd64 on the put command that builds it, or `docker build " - f"--platform linux/amd64` for one put by name; {EMULATION}), or run it with --sandbox local") - -class _Vms(SandboxProvider): - """A plugin's VM provider that declares no platform, so its VMs run linux/amd64.""" - - CREATES_VMS = True - SANDBOX_ACCEPTS = Accepts.LOADABLE - - async def create_sandbox(self, **kwargs): - raise AssertionError("preflight creates nothing") - - -class _ArmVms(_Vms): - """A plugin's VM provider whose VMs run linux/arm64.""" - - PLATFORMS = frozenset({"linux/arm64"}) - - -@pytest.fixture -def plugin_vms(monkeypatch): - registry = Config.sandbox_registry - monkeypatch.setattr(Config, "sandbox_registry", - lambda self: {**registry(self), "vms": {"impl": _Vms}, "arm-vms": {"impl": _ArmVms}}) - - -@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("vms", True), ("local", False), ("arm-vms", False), - ("modal_vm,local", False), ("modal_vm,arm-vms", False)]) -def test_an_image_for_a_platform_a_provider_doesnt_run_is_refused_there(bundle_dir, plugin_vms, sandbox, refused): - """A provider runs linux/amd64 unless it declares otherwise; the local provider runs any, since its Docker emulates - other platforms. A chain skips a provider that doesn't run it.""" +def test_the_dry_run_leaves_an_images_platform_to_the_vm_that_loads_it(bundle_dir): + """No provider says beforehand what its sandboxes run; a sandbox carries its platform once it exists.""" _agent("solver", ARM64_HERE) _task(bundle_dir, [AGENT]) - if refused: - assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ - f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {sandbox!r} sandbox provider: " - f"'solver-image' {NOT_AMD64}; {REBUILD}"] - else: - assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] - - -def test_a_chain_none_of_whose_providers_runs_an_image_is_refused_naming_each(bundle_dir, plugin_vms): - _agent("solver", ARM64_HERE) - _task(bundle_dir, [AGENT]) - - assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm,vms")) == [ - f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {link!r} sandbox provider: 'solver-image' " - f"{NOT_AMD64}; {REBUILD}" for link in ("modal_vm", "vms")] - - -def test_infra_built_here_for_another_platform_is_refused_on_a_vm_provider_naming_the_command_to_put_it_again(bundle_dir): - """The infra bootstrap builds for this machine, linux/arm64 on Apple silicon, and the VM providers run linux/amd64.""" - _env("crm", REMOTE) - _infra(ARM64_HERE) - _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) - - problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) - - assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " - f"'modal_vm' sandbox provider: 'gateway-default' {NOT_AMD64}; rebuild it for linux/amd64 " - f"(`agent-env env gateway put --id default --platform linux/amd64`; {EMULATION}), or run it with " - "--sandbox local") - assert len(problems) == 4 # the gateway's image, and the service-db's three - - -def test_infra_built_here_for_another_platform_runs_on_a_chain_that_falls_back_to_the_local_provider(bundle_dir): - _env("crm", REMOTE) - _infra(ARM64_HERE) - _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) - - assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm,local").runs] == ["t"] + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] def test_a_vm_provider_isnt_offered_for_an_image_its_vms_wouldnt_run(bundle_dir): @@ -519,7 +451,7 @@ def test_an_agent_is_refused_a_sandbox_deploy_agent_cant_place_it_on_in_the_step @pytest.mark.parametrize("where, sandbox", [(PULLED_HERE, "modal_vm"), (PULLED_HERE, "modal"), (LOCAL, "modal"), - (NO_REGISTRY, "modal_vm"), (ARM64_HERE, "modal_vm")]) + (NO_REGISTRY, "modal_vm")]) def test_preflight_refuses_an_agent_for_the_reason_its_deploy_would(bundle_dir, monkeypatch, where, sandbox): """Both make the one check (``Accepts.problem``), so a deploy preflight passes isn't refused for its image.""" agent = _agent("solver", where) diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 8cf7bdb8..79e483bb 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -38,7 +38,7 @@ ) from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, Sandbox +from agent_env.providers.sandbox_providers.sandbox import Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import Accepts from agent_env.providers.env_state import LocalPostgresStateProvider from agent_env.config import get_config, set_document_store @@ -48,6 +48,7 @@ EXTERNAL_STATE_TYPE, registered_external_provider, ) +from tst.util.sandbox_providers import CONTAINERS, VMS_AND_CONTAINERS def _image(image_name: str) -> DockerImageArtifact: @@ -108,9 +109,9 @@ async def write_file_from_text(self, content: str, destination_path: str) -> Non @pytest.mark.parametrize("facts, in_vm, accepts, by_name_otherwise", [ - ({"CREATES_VMS": True, "CONTAINER_ACCEPTS": Accepts.NAME}, True, Accepts.LOADABLE, Accepts.LOADABLE), - ({"CREATES_VMS": False, "CONTAINER_ACCEPTS": Accepts.NAME_OR_CONTEXT}, False, Accepts.NAME_OR_CONTEXT, Accepts.NAME), - ({"CREATES_VMS": False, "CONTAINER_ACCEPTS": Accepts.NAME}, False, Accepts.NAME, Accepts.NAME), + ({"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME}, True, Accepts.LOADABLE, Accepts.LOADABLE), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME_OR_CONTEXT}, False, Accepts.NAME_OR_CONTEXT, Accepts.NAME), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME}, False, Accepts.NAME, Accepts.NAME), ], ids=["vm", "containers-that-build", "containers-by-name"]) def test_a_gateway_runs_in_a_vm_where_its_provider_creates_them_and_else_in_containers(facts, in_vm, accepts, by_name_otherwise): @@ -124,10 +125,10 @@ def test_a_gateway_runs_in_a_vm_where_its_provider_creates_them_and_else_in_cont @pytest.mark.parametrize("facts, websites, problem", [ - ({"CREATES_VMS": True, "PRIVATE_NETWORK": False}, True, None), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": True}, False, None), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": True}, True, "they can't serve websites"), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": False}, False, "can't put the gateway's containers on a private network"), + ({"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "PRIVATE_NETWORK": False}, True, None), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": True}, False, None), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": True}, True, "they can't serve websites"), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": False}, False, "can't put the gateway's containers on a private network"), ], ids=["vm-with-websites", "containers", "containers-with-websites", "containers-without-a-private-network"]) def test_a_gateway_in_containers_needs_their_private_network_and_serves_no_websites(facts, websites, problem): found = EnvironmentGatewayProvider.sandbox_problem(MagicMock(**facts), websites=websites) @@ -136,7 +137,7 @@ def test_a_gateway_in_containers_needs_their_private_network_and_serves_no_websi @pytest.mark.asyncio async def test_a_gateway_deploy_on_a_provider_with_no_vm_and_no_private_network_creates_nothing(): - provider = MagicMock(CREATES_VMS=False, PRIVATE_NETWORK=False, create_container=AsyncMock()) + provider = MagicMock(SUPPORTED_SANDBOX_MODES=CONTAINERS, PRIVATE_NETWORK=False, create_container=AsyncMock()) with pytest.raises(ValueError, match="private network"): await EnvironmentGatewayProvider()._deploy_gateway(provider, state_instance=None, mcp_servers=[], @@ -160,7 +161,7 @@ async def test_container_mode_rejects_websites(): def _vm_provider(**attrs) -> MagicMock: """A sandbox provider that creates VMs, where a gateway runs, as every VM provider does.""" - provider = MagicMock(ON_THIS_MACHINE=False, CREATES_VMS=True, SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) + provider = MagicMock(ON_THIS_MACHINE=False, SUPPORTED_SANDBOX_MODES=VMS_AND_CONTAINERS, SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) provider.links = (provider,) return provider @@ -266,27 +267,6 @@ async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the provider.create_vm.assert_not_awaited() -@pytest.mark.asyncio -async def test_a_vm_gateway_refuses_an_image_for_a_platform_its_provider_doesnt_declare_before_creating_the_vm(): - from agent_env.env.gateway import GatewayMode - - arm64 = DockerImageArtifact(id="arm", description="d", image_name="arm:v1", platform="linux/arm64", - tar_gz_object_url="s3://bucket/arm.tar.gz") - gp = EnvironmentGatewayProvider() - gp._state_provider = MagicMock() - provider = _vm_provider(PLATFORMS=LINUX_AMD64, create_vm=AsyncMock(side_effect=AssertionError("created a VM"))) - - with patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=_loadable("gateway"))): - with pytest.raises(ValueError, match="whose VM can't run its images: 'arm' v0 is a linux/arm64 image, and the " - "sandbox runs linux/amd64"): - await gp._deploy_via_vm( - sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="arm:v1", environment_name="slack")], - mcp_server_images=[arm64], gateway_port=18765, website_configs=None, website_images=None, - gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, - ) - provider.create_vm.assert_not_awaited() - - @pytest.mark.asyncio async def test_container_mode_constructs_internal_mcp_servers_url_format(): """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 3b83265c..22b2d647 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -1,6 +1,7 @@ """What each sandbox provider declares it runs, and that nothing outside the providers asks which provider it has: every -choice that depends on a provider reads what it declares (``ON_THIS_MACHINE``, ``CREATES_VMS``, ``SANDBOX_ACCEPTS``, -``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``links`` and ``url_from_sandbox``) and its sandboxes' ``private_host``.""" +choice that depends on a provider reads what it declares (``ON_THIS_MACHINE``, ``SUPPORTED_SANDBOX_MODES``, +``SANDBOX_ACCEPTS``, ``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``links`` and ``url_from_sandbox``) and its sandboxes' +``private_host`` and ``platform``.""" import ast from pathlib import Path @@ -19,8 +20,13 @@ from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.sandbox import SailVmSandbox -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, Sandbox -from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox import Sandbox +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_CONTAINER, + SANDBOX_MODE_VM, + Accepts, + SandboxProvider, +) SRC = Path(__file__).resolve().parents[4] / "src" / "agent_env" PROVIDERS = SRC / "providers" / "sandbox_providers" @@ -39,13 +45,16 @@ ], ids=["local", "modal", "modal_vm", "e2b", "sail_vm"]) def test_each_provider_declares_where_it_runs_and_the_images_it_runs(cls, on_this_machine, creates_vms, sandbox, container, private_network): - assert (cls.ON_THIS_MACHINE, cls.CREATES_VMS, cls.PRIVATE_NETWORK) == (on_this_machine, creates_vms, private_network) + assert ((cls.ON_THIS_MACHINE, SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES, cls.PRIVATE_NETWORK) + == (on_this_machine, creates_vms, private_network)) assert (cls.SANDBOX_ACCEPTS, cls.CONTAINER_ACCEPTS) == (sandbox, container) @pytest.mark.parametrize("cls", PROVIDER_CLASSES, ids=lambda cls: cls.__name__) -def test_a_provider_declares_vms_and_a_private_network_only_where_it_implements_them(cls): - assert cls.CREATES_VMS == (cls.create_vm is not SandboxProvider.create_vm) +def test_a_provider_supports_vms_where_it_implements_create_vm_and_declares_a_private_network_only_with_containers(cls): + assert cls.SUPPORTED_SANDBOX_MODES == ({SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} + if cls.create_vm is not SandboxProvider.create_vm else {SANDBOX_MODE_CONTAINER}) + assert cls.CREATES_VMS == (SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES) # the deprecated spelling, derived assert not cls.PRIVATE_NETWORK or cls.create_container is not SandboxProvider.create_container @@ -57,13 +66,29 @@ async def create_vm(self, **_): raise NotImplementedError -def test_a_provider_that_declares_nothing_runs_images_by_name_and_creates_no_vm(): - """Implementing create_vm declares nothing: a provider says it creates VMs, as it says everything else.""" - assert (_Vms.ON_THIS_MACHINE, _Vms.CREATES_VMS, _Vms.PRIVATE_NETWORK) == (False, False, False) +def test_a_provider_that_declares_nothing_runs_images_by_name_and_supports_vms_by_implementing_create_vm(): + assert (_Vms.ON_THIS_MACHINE, _Vms.PRIVATE_NETWORK) == (False, False) + assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} and _Vms.CREATES_VMS assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (NAME, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" +class _VmsOnly(_Vms): + """One whose create_sandbox can't make a container, so it declares its modes.""" + + SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_VM}) + + +class _ContainersAgain(_VmsOnly): + async def create_sandbox(self, **_): + raise NotImplementedError + + +def test_a_provider_that_declares_its_modes_keeps_them_and_a_subclass_that_doesnt_has_them_derived(): + assert _VmsOnly.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} and _VmsOnly.CREATES_VMS + assert _ContainersAgain.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} + + def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): first, second = _Vms(), ModalSandboxProvider() @@ -108,16 +133,16 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" -def _on(on_this_machine: bool = False, platforms: frozenset[str] | None = None) -> SimpleNamespace: - """A sandbox or provider, as far as what it declares about where it runs and what.""" - return SimpleNamespace(ON_THIS_MACHINE=on_this_machine, PLATFORMS=platforms) +def _on(on_this_machine: bool = False) -> SimpleNamespace: + """A sandbox or provider, as far as what it declares about where it runs.""" + return SimpleNamespace(ON_THIS_MACHINE=on_this_machine) @pytest.mark.parametrize("image, accepts, problem", [ (CONTEXT_ONLY, LOADABLE, None), (CONTEXT_ONLY, NAME_OR_CONTEXT, None), (CONTEXT_ONLY, NAME, "only a build context"), (NO_REGISTRY, LOADABLE, "doesn't name a registry"), (NO_REGISTRY, NAME, None), (NO_REGISTRY, NAME_OR_CONTEXT, None), ]) -@pytest.mark.parametrize("on", [None, _on(True), _on(False), _on(False, LINUX_AMD64)]) +@pytest.mark.parametrize("on", [None, _on(True), _on(False)]) def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on): found = accepts.problem(image, on=on) assert (found is None) if problem is None else (problem in found) @@ -142,47 +167,44 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it image_name="123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v2") ARM64_CONTEXT = DockerImageArtifact(id="img", version=2, description="d", image_name="local/img-0123456789ab:v2", build_context_object_url="s3://bucket/ctx.tar.gz", platform="linux/arm64") -ARM64 = "'img' v2 is a linux/arm64 image, and the sandbox runs linux/amd64" +ARM64 = "'img' v2 is a linux/arm64 image, and the sandbox runs linux/amd64; rebuild it for linux/amd64" -@pytest.mark.parametrize("image, accepts, problem", [ - (ARM64_TAR_GZ, LOADABLE, ARM64), (ARM64_PULLED, LOADABLE, ARM64), (ARM64_PULLED, NAME, ARM64), - (ARM64_PULLED, NAME_OR_CONTEXT, ARM64), - (ARM64_CONTEXT, LOADABLE, ARM64), # a VM builds it for the platform it records - (ARM64_CONTEXT, NAME_OR_CONTEXT, None), # a provider that builds it does so for a platform of its own -]) -def test_a_sandbox_declaring_its_platforms_runs_only_images_of_one_of_them(image, accepts, problem): - assert accepts.problem(image, on=_on(False, LINUX_AMD64)) == problem - assert accepts.problem(image, on=_on(False, frozenset({"linux/amd64", "linux/arm64"}))) is None - assert accepts.problem(image, on=_on(False)) is None # one that declares none isn't checked +@pytest.mark.parametrize("image", [ARM64_TAR_GZ, ARM64_PULLED, ARM64_CONTEXT], ids=["tar.gz", "pulled", "context"]) +def test_a_vm_runs_only_images_built_for_its_platform(image): + """A VM builds a context for the platform it records, so a context is checked too.""" + assert image.load_problem(platform="linux/amd64").startswith(ARM64) + assert image.load_problem(platform="linux/arm64") is None + assert image.load_problem(platform=None) is None # a sandbox that runs any, as a local one does -@pytest.mark.parametrize("recorded, declared", [ +@pytest.mark.parametrize("accepts", [LOADABLE, NAME, NAME_OR_CONTEXT]) +def test_a_provider_checks_no_platform_before_its_sandbox_exists(accepts): + assert accepts.problem(ARM64_PULLED, on=_on(False)) is None + + +@pytest.mark.parametrize("recorded, runs", [ ("linux/amd64/v2", "linux/amd64"), ("linux/arm64/v8", "linux/arm64"), ("linux/amd64", "linux/x86_64"), ]) -def test_a_platform_is_the_same_however_the_image_or_the_sandbox_spells_it(recorded, declared): +def test_a_platform_is_the_same_however_the_image_or_the_sandbox_spells_it(recorded, runs): image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform=recorded, tar_gz_object_url="s3://bucket/img.tar.gz") - assert LOADABLE.problem(image, on=_on(False, frozenset({declared}))) is None + assert image.load_problem(platform=runs) is None def test_an_image_that_records_no_platform_isnt_checked(): image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", tar_gz_object_url="s3://bucket/img.tar.gz") - assert image.platform is None and LOADABLE.problem(image, on=_on(False, frozenset({"linux/arm64"}))) is None + assert image.platform is None and image.load_problem(platform="linux/arm64") is None -@pytest.mark.parametrize("provider, sandbox", [ - (SandboxProvider, Sandbox), # a plugin's that declares nothing - (ModalSandboxProvider, ModalSandbox), (ModalVmSandboxProvider, ModalVmSandbox), (E2BSandboxProvider, E2BSandbox), - (SailVmSandboxProvider, SailVmSandbox), -]) -def test_a_provider_and_its_sandboxes_run_linux_amd64_unless_they_declare_otherwise(provider, sandbox): - assert provider.PLATFORMS == sandbox.PLATFORMS == LINUX_AMD64 +@pytest.mark.parametrize("sandbox", [Sandbox, ModalSandbox, ModalVmSandbox, E2BSandbox, SailVmSandbox]) +def test_a_sandbox_runs_linux_amd64_unless_its_provider_sets_another(sandbox): + assert sandbox.platform == "linux/amd64" -def test_the_local_provider_runs_any_platform_unchecked_since_its_docker_emulates_others(): - assert LocalSandboxProvider.PLATFORMS is None and LocalSandbox.PLATFORMS is None +def test_a_local_sandbox_runs_any_platform_unchecked_since_its_docker_emulates_others(): + assert LocalSandbox.platform is None def _concrete_classes() -> set[str]: diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index bf4d73cf..edc937be 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -10,7 +10,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.providers.sandbox_providers import sandbox as sandbox_module -from agent_env.providers.sandbox_providers.sandbox import LINUX_AMD64, VmSandbox +from agent_env.providers.sandbox_providers.sandbox import VmSandbox from agent_env.store import ImageStore, RegistryAuth, set_object_store from agent_env.config import get_config, reset_config from tst.unit.store.fakes import ConfiguredObjectStore @@ -219,20 +219,25 @@ async def test_a_vm_on_this_machine_pulls_from_its_registry_and_one_elsewhere_lo class _AnyPlatformVm(_RecordingVmSandbox): """A VM sandbox that runs any platform, as the local provider's do.""" - PLATFORMS = None + platform = None @pytest.mark.asyncio async def test_a_vm_refuses_a_tar_gz_for_a_platform_it_doesnt_run_before_anything_is_loaded(signing_store): - """A VM runs linux/amd64 unless it declares otherwise.""" + """A VM runs linux/amd64 unless its provider set another when it created it, as it sets its mode.""" arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", tar_gz_object_url="s3://bucket/img.tar.gz") amd = _RecordingVmSandbox() with pytest.raises(RuntimeError, match="Can't load images: 'img' v1 is a linux/arm64 image, and the sandbox runs " - "linux/amd64"): + "linux/amd64; rebuild it for linux/amd64"): await amd.load_docker_images([arm64]) - assert amd.PLATFORMS == LINUX_AMD64 and amd.scripts == [] + assert amd.platform == "linux/amd64" and amd.scripts == [] + + arm = _RecordingVmSandbox(images_stdout="img\n") + arm.platform = "linux/arm64" + await arm.load_docker_images([arm64]) + assert "docker load" in arm.scripts[0] anything = _AnyPlatformVm(images_stdout="img\n") await anything.load_docker_images([arm64]) @@ -721,13 +726,9 @@ async def test_two_builds_of_one_context_on_one_host_each_have_a_work_folder_of_ assert first != second -class _AmdAndArmVm(_RecordingVmSandbox): - PLATFORMS = frozenset({"linux/amd64", "linux/arm64"}) - - @pytest.mark.asyncio async def test_a_build_takes_the_recorded_dockerfile_and_platform_else_linux_amd64(signing_store): - sandbox = _AmdAndArmVm() + sandbox = _AnyPlatformVm() await sandbox.load_docker_images([_context_image("a", "sha256:" + "1" * 64, platform=None, dockerfile="docker/app.Dockerfile"), diff --git a/tst/util/sandbox_providers.py b/tst/util/sandbox_providers.py index 1870e9d3..ee2bc46a 100644 --- a/tst/util/sandbox_providers.py +++ b/tst/util/sandbox_providers.py @@ -3,12 +3,15 @@ from unittest.mock import MagicMock -from agent_env.providers.sandbox_providers.sandbox_provider import Accepts +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM, Accepts + +VMS_AND_CONTAINERS = frozenset({SANDBOX_MODE_VM, SANDBOX_MODE_CONTAINER}) +CONTAINERS = frozenset({SANDBOX_MODE_CONTAINER}) def mock_provider(*, on_this_machine: bool = False, **attrs) -> MagicMock: """A MagicMock provider that declares what a VM provider does, unless ``attrs`` declare otherwise.""" - facts = {"CREATES_VMS": True, "SANDBOX_ACCEPTS": Accepts.LOADABLE, "CONTAINER_ACCEPTS": Accepts.NAME, + facts = {"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "SANDBOX_ACCEPTS": Accepts.LOADABLE, "CONTAINER_ACCEPTS": Accepts.NAME, "PRIVATE_NETWORK": False} provider = MagicMock(ON_THIS_MACHINE=on_this_machine, **(facts | attrs)) provider.links = (provider,) From 8b8ef6b5ed30ae67228effd5360bf8c16a280f2a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 10:27:09 -0700 Subject: [PATCH 10/13] refactor(sandbox)!: modes are derived from what a provider implements, and CREATES_VMS is gone SUPPORTED_SANDBOX_MODES no longer assumes containers: a provider supports SANDBOX_MODE_CONTAINER when it implements create_sandbox and SANDBOX_MODE_VM when it implements create_vm. create_sandbox is no longer abstract; like create_vm it raises NotImplementedError unless implemented, and a provider plugin that implements neither is refused at registration. CREATES_VMS is removed; read SANDBOX_MODE_VM in SUPPORTED_SANDBOX_MODES instead. A chain that gets a VM sandbox of another platform than the image it deploys closes it and tries the next provider, so `--sandbox modal_vm,local` runs a linux/arm64 agent on the local provider, at the cost of the VM it closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- .../chained_sandbox_provider.py | 11 +++++- .../sandbox_providers/sandbox_provider.py | 33 ++++++++++------- .../provider_capabilities_test.py | 34 +++++++++++------ .../sandbox_provider_test.py | 37 +++++++++++++++++++ 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64f2eb0c..a377d8c8 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_vm`, of which `CREATES_VMS` is the deprecated spelling), 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`, `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). | | `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/providers/sandbox_providers/chained_sandbox_provider.py b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py index fd8e2d28..97b019c1 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -7,7 +7,7 @@ from agent_env.attribution import Attribution from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox -from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, SandboxProvider if TYPE_CHECKING: from agent_env.artifact.artifacts.docker_image import DockerImageArtifact @@ -82,6 +82,15 @@ async def create_sandbox( logger.warning(f"{name}.create_sandbox failed: {e}; trying next provider") errors.append((name, e)) continue + # A VM loads the image, so one of another platform can't run it, and the next provider's may. + if image is not None and sandbox.mode == SANDBOX_MODE_VM and (problem := image.platform_problem(sandbox.platform)): + logger.warning(f"{name}.create_sandbox gave a VM that can't run the image: {problem}; trying next provider") + errors.append((name, ValueError(problem))) + try: + await sandbox.terminate() + except Exception as e: + logger.warning(f"{name}: terminating sandbox {sandbox.sandbox_id} failed: {e}") + continue logger.info( f"chain_provider_attempt provider={name} status=success sandbox_type={sandbox.type} " f"network_policy={sandbox.network_policy.mode.value if sandbox.network_policy else 'unknown'}" diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 19b19833..a1da1f07 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -5,7 +5,7 @@ import logging import os import shlex -from abc import ABC, abstractmethod +from abc import ABC from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self from urllib.parse import urlparse @@ -59,7 +59,6 @@ def problem(self, image: DockerImageArtifact, *, on: SandboxProvider | Sandbox | class SandboxProvider(ABC): """Compute backend that provisions sandboxes. Selected via [sandbox] default / build_sandbox_provider(); swap via set_sandbox_provider().""" - @abstractmethod async def create_sandbox( self, *, @@ -72,7 +71,8 @@ async def create_sandbox( timeout: int = 3600 * 2, attribution: Optional[Attribution] = None, network_policy: Optional[NetworkPolicy] = None, - ) -> Sandbox: ... + ) -> Sandbox: + raise NotImplementedError(f"{type(self).__name__} does not support create_sandbox") async def create_vm( self, @@ -175,12 +175,10 @@ def from_config(cls, **config: Any) -> Self: # 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. ON_THIS_MACHINE: ClassVar[bool] = False - # The sandbox modes it creates, derived for each provider class (``__init_subclass__``): SANDBOX_MODE_VM when it - # implements create_vm, and SANDBOX_MODE_CONTAINER, since every provider implements create_sandbox. One whose - # create_sandbox can't make a container declares its own. - SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset({SANDBOX_MODE_CONTAINER}) - # Deprecated: SANDBOX_MODE_VM in SUPPORTED_SANDBOX_MODES, derived from it, for plugins that still read it. - CREATES_VMS: ClassVar[bool] = False + # 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. One that + # implements a method it can't serve declares its own. + SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset() # The images create_sandbox and create_container run. SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME @@ -190,9 +188,9 @@ def from_config(cls, **config: Any) -> Self: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if "SUPPORTED_SANDBOX_MODES" not in cls.__dict__: - vm = cls.create_vm is not SandboxProvider.create_vm - cls.SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_CONTAINER, *([SANDBOX_MODE_VM] if vm else [])}) - cls.CREATES_VMS = SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES + 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)) @property def links(self) -> tuple[SandboxProvider, ...]: @@ -315,11 +313,18 @@ def _build_registry(source: Config | None = None) -> dict[str, dict]: def _validate_plugin(name: str, loaded: Any) -> type[SandboxProvider]: cls = _registration.require_subclass(loaded, SandboxProvider) - if problem := _registration.unimplemented(cls, SandboxProvider): + if problem := _unimplemented(cls): raise TypeError(problem) return cls +def _unimplemented(cls: type[SandboxProvider]) -> str | None: + """What ``cls`` still has to implement to work as a sandbox provider, if anything: a sandbox mode at least, + create_sandbox or create_vm.""" + return _registration.unimplemented(cls, SandboxProvider) or ( + None if cls.SUPPORTED_SANDBOX_MODES else f"{cls.__qualname__} must implement create_sandbox or create_vm") + + def _get_sandbox_registry() -> dict[str, dict]: from agent_env.config import runtime @@ -371,7 +376,7 @@ def _merge_config_toml_sandbox_providers( if not impl: raise ConfigError(f"config.toml sandbox provider {name!r} is missing an 'impl' dotted path") cls = load_impl(impl, SandboxProvider) - if problem := _registration.unimplemented(cls, SandboxProvider): + if problem := _unimplemented(cls): raise ConfigError(f"{where} impl {impl!r}: {problem}") if from_plugins.refuse(name, where): continue diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 22b2d647..8672169a 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -51,10 +51,9 @@ def test_each_provider_declares_where_it_runs_and_the_images_it_runs(cls, on_thi @pytest.mark.parametrize("cls", PROVIDER_CLASSES, ids=lambda cls: cls.__name__) -def test_a_provider_supports_vms_where_it_implements_create_vm_and_declares_a_private_network_only_with_containers(cls): - assert cls.SUPPORTED_SANDBOX_MODES == ({SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} - if cls.create_vm is not SandboxProvider.create_vm else {SANDBOX_MODE_CONTAINER}) - assert cls.CREATES_VMS == (SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES) # the deprecated spelling, derived +def test_a_provider_supports_the_modes_it_implements_and_declares_a_private_network_only_with_containers(cls): + assert cls.SUPPORTED_SANDBOX_MODES == ( + {SANDBOX_MODE_CONTAINER} | ({SANDBOX_MODE_VM} if cls.create_vm is not SandboxProvider.create_vm else set())) assert not cls.PRIVATE_NETWORK or cls.create_container is not SandboxProvider.create_container @@ -68,25 +67,38 @@ async def create_vm(self, **_): def test_a_provider_that_declares_nothing_runs_images_by_name_and_supports_vms_by_implementing_create_vm(): assert (_Vms.ON_THIS_MACHINE, _Vms.PRIVATE_NETWORK) == (False, False) - assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} and _Vms.CREATES_VMS + assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (NAME, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" -class _VmsOnly(_Vms): - """One whose create_sandbox can't make a container, so it declares its modes.""" +class _VmsOnly(SandboxProvider): + async def create_vm(self, **_): + raise NotImplementedError + + +class _ContainersOnly(SandboxProvider): + async def create_sandbox(self, **_): + raise NotImplementedError + + +class _Declared(_Vms): + """One that implements create_sandbox but can't always serve it, so it declares its modes.""" SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_VM}) -class _ContainersAgain(_VmsOnly): +class _DerivedAgain(_Declared): async def create_sandbox(self, **_): raise NotImplementedError -def test_a_provider_that_declares_its_modes_keeps_them_and_a_subclass_that_doesnt_has_them_derived(): - assert _VmsOnly.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} and _VmsOnly.CREATES_VMS - assert _ContainersAgain.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} +def test_a_provider_supports_only_the_modes_it_implements_unless_it_declares_them(): + assert (_VmsOnly.SUPPORTED_SANDBOX_MODES, _ContainersOnly.SUPPORTED_SANDBOX_MODES) == ( + {SANDBOX_MODE_VM}, {SANDBOX_MODE_CONTAINER}) + assert SandboxProvider.SUPPORTED_SANDBOX_MODES == set() + assert _Declared.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} + assert _DerivedAgain.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} # a subclass that doesn't def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index f28469d9..6c053929 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -309,6 +309,43 @@ def test_skip_refusing_gives_no_provider_when_it_refuses_every_link(provider): assert running is None and refused == [(link, "too far") for link in provider.links] +class _Vm(_FakeSandbox): + """A VM sandbox of ``platform``, recording whether it was terminated.""" + + def __init__(self, sandbox_id: str, platform: str): + super().__init__(sandbox_id) + self.mode, self.platform, self.terminated = "vm", platform, False + + async def terminate(self) -> None: + self.terminated = True + + +@pytest.mark.asyncio +async def test_a_chain_closes_a_vm_that_cant_run_the_image_and_tries_the_next_provider(caplog): + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + amd, arm = _Vm("amd", "linux/amd64"), _Vm("arm", "linux/arm64") + chain = ChainedSandboxProvider([_StubProvider(sandbox=amd), _StubProvider(sandbox=arm)]) + await chain.prepare_image(arm64) + + assert await chain.create_sandbox(image_name="img:v1", port=8000, env={}) is arm + assert amd.terminated and not arm.terminated + assert "gave a VM that can't run the image: 'img' v1 is a linux/arm64 image" in caplog.text + + +@pytest.mark.asyncio +async def test_a_chain_none_of_whose_vms_runs_the_image_names_each_refusal(): + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + chain = ChainedSandboxProvider([_StubProvider(sandbox=_Vm("one", "linux/amd64")), + _StubProvider(sandbox=_Vm("two", "linux/amd64"))]) + await chain.prepare_image(arm64) + + with pytest.raises(RuntimeError, match="All 2 providers failed: _StubProvider: ValueError.*linux/arm64 image.*; " + "_StubProvider: ValueError"): + await chain.create_sandbox(image_name="img:v1", port=8000, env={}) + + _CONTEXT_ONLY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="local/solver-0123456789ab:v2", build_context_object_url="s3://bucket/ctx.tar.gz") From 960ce101472ab96f4b4699586a84607c7731b040 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 10:51:50 -0700 Subject: [PATCH 11/13] refactor(sandbox): one load check, one way to skip a provider, and plainer image checks - Accepts.problem takes a required on_this_machine instead of a provider or sandbox it read one flag from, so no caller skips the reachability check by leaving it out. - VmSandbox.load_problems is the check load_docker_images refuses on, and the chain asks it of a VM it created, instead of repeating the platform half of it. - skip_refusing raises the refusal itself, and it and the network-policy filter share one way to drop providers: the first, preferred, skipped with a warning, later ones noted. - SUPPORTED_SANDBOX_MODES is always derived from what a provider implements. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 17 ++--- src/agent_env/bundle/preflight.py | 4 +- .../env_providers/env_gateway_provider.py | 8 ++- .../env_providers/env_server_provider.py | 2 +- .../chained_sandbox_provider.py | 63 ++++++++++--------- .../providers/sandbox_providers/sandbox.py | 11 +++- .../sandbox_providers/sandbox_provider.py | 18 +++--- .../task_step/task_steps/deploy_sandbox.py | 17 +++-- .../a2a_agent/context_only_deploy_test.py | 3 +- .../provider_capabilities_test.py | 33 +++------- .../sandbox_provider_test.py | 38 ++++++----- .../task_step/deploy_sandbox_image_test.py | 5 +- 12 files changed, 115 insertions(+), 104 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index 339f8918..fd93ca72 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import functools import json import logging import posixpath @@ -445,13 +446,7 @@ async def deploy( raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " f"runs its own image, never the agent's: {problem}") else: - provider, refused = skip_refusing(provider, lambda link: self.accepts(link).problem(image, on=link)) - if provider is None: - refusals = [f"with {type(link).__name__}, " - + ("whose VMs can't run it" if self.accepts(link) is Accepts.LOADABLE - else "which runs an agent's image by name") - + f": {problem}" for link, problem in refused] - raise ValueError(f"Can't deploy agent {self.id!r} {'; nor '.join(refusals)}") + provider = skip_refusing(provider, functools.partial(self._refusal, image), what=f"agent {self.id!r}") try: if provider is not None: @@ -508,6 +503,14 @@ async def deploy( await self.close() raise + 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) + if problem := accepts.problem(image, on_this_machine=link.ON_THIS_MACHINE): + how = "whose VMs can't run it" if accepts is Accepts.LOADABLE else "which runs an agent's image by name" + return f"{how}: {problem}" + return None + def _build_merged_env(self, resolved_env: dict[str, str], a2a_port: int) -> dict[str, str]: merged_env = dict(self.default_env_vars) merged_env.update(resolved_env) diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index c7cd2b0b..335acc20 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -359,9 +359,9 @@ def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_l name names a registry it reaches, and a provider that runs an image by name can't run one that is only a build context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's registry. A VM it would create checks the image's platform once it exists.""" - if image.artifact is None or (refusal := form.problem(image.artifact, on=link)) is None: + if image.artifact is None or (refusal := form.problem(image.artifact, on_this_machine=link.ON_THIS_MACHINE)) is None: return None - if form.problem(image.artifact) is None: # refused only because the sandbox isn't on this machine + if form.problem(image.artifact, on_this_machine=True) is None: # refused only because the sandbox isn't here loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm" if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE and image.artifact.platform_problem(DEFAULT_BUILD_PLATFORM) is None else "") diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 4d3f126a..4adc83c3 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -717,8 +717,9 @@ async def _deploy_via_vm( if self._needs_local_postgres: all_images += self._state_provider.store_images_to_load() # Every image is checked before the VM is created, so one it can't get leaves nothing to close. - on = existing_sandbox or sandbox_provider - if problems := [problem for image in all_images if (problem := Accepts.LOADABLE.problem(image, on=on))]: + here = (existing_sandbox or sandbox_provider).ON_THIS_MACHINE + if problems := [problem for image in all_images + if (problem := Accepts.LOADABLE.problem(image, on_this_machine=here))]: raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, whose VM can't run its " f"images: {'; '.join(problems)}") @@ -897,7 +898,8 @@ async def _deploy_via_containers( # an image the provider can't run, or a failed build, leaves nothing. images = [gateway_env.docker_image_artifact, *mcp_server_images] accepts = self.accepts(sandbox_provider) - problems = [problem for image in images if (problem := accepts.problem(image, on=sandbox_provider))] + problems = [problem for image in images + if (problem := accepts.problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE))] if self._needs_local_postgres: # the servicedb and its sidecars, by the names the state provider picks problems += [problem for name in self._state_provider.container_images() if (problem := pull_problem(name, on_this_machine=sandbox_provider.ON_THIS_MACHINE))] diff --git a/src/agent_env/providers/env_providers/env_server_provider.py b/src/agent_env/providers/env_providers/env_server_provider.py index 85d95c91..61f31ec1 100644 --- a/src/agent_env/providers/env_providers/env_server_provider.py +++ b/src/agent_env/providers/env_providers/env_server_provider.py @@ -75,7 +75,7 @@ async def _deploy_server( name = env.environment_name image = env.docker_image_artifact - if problem := self.accepts(sandbox_provider).problem(image, on=sandbox_provider): + if problem := self.accepts(sandbox_provider).problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE): raise ValueError(f"Can't deploy {name!r} as a server on {type(sandbox_provider).__name__}, which runs its " f"image by name: {problem}") await sandbox_provider.prepare_image(image, attribution=attribution) 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 97b019c1..dce3fb38 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from agent_env.attribution import Attribution -from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox +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 if TYPE_CHECKING: @@ -40,19 +40,13 @@ def filter_sandbox_providers( """``providers`` that can enforce ``policy``, so an incapable one is never called.""" if policy is None or not policy.restricts_egress: return providers - eligible: list[SandboxProvider] = [] - for p in providers: - if p.supports_network_policy(policy): - eligible.append(p) - else: - log = logger.warning if p is providers[0] else logger.info - log(f"sandbox_provider_skipped provider={type(p).__name__} mode={policy.mode.value}") - if not eligible: - raise NetworkPolicyUnsupportedError( + return _without( + providers, + lambda p: None if p.supports_network_policy(policy) else f"can't enforce network policy mode {policy.mode.value!r}", + lambda refused: NetworkPolicyUnsupportedError( f"No sandbox backend can enforce network policy mode {policy.mode.value!r} " - f"(providers: {[type(p).__name__ for p in providers]})" - ) - return eligible + f"(providers: {[type(p).__name__ for p in providers]})"), + ) def supports_network_policy(self, policy: NetworkPolicy) -> bool: return any(p.supports_network_policy(policy) for p in self._providers) @@ -82,9 +76,11 @@ async def create_sandbox( logger.warning(f"{name}.create_sandbox failed: {e}; trying next provider") errors.append((name, e)) continue - # A VM loads the image, so one of another platform can't run it, and the next provider's may. - if image is not None and sandbox.mode == SANDBOX_MODE_VM and (problem := image.platform_problem(sandbox.platform)): - logger.warning(f"{name}.create_sandbox gave a VM that can't run the image: {problem}; trying next provider") + # 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") errors.append((name, ValueError(problem))) try: await sandbox.terminate() @@ -125,16 +121,25 @@ async def close(self) -> None: logger.warning(f"close() failed on {type(p).__name__}: {e}") -def skip_refusing(provider: SandboxProvider, problem: Callable[[SandboxProvider], str | None] - ) -> tuple[SandboxProvider | None, list[tuple[SandboxProvider, str]]]: - """``provider`` less each of its links ``problem`` refuses, each skipped with a warning, and the refusals: a deploy - runs on any link that takes it. None in place of the provider when ``problem`` refuses every link.""" - refused = [(link, reason) for link in provider.links if (reason := problem(link))] - if not refused: - return provider, [] - running = [link for link in provider.links if all(link is not refusing for refusing, _ in refused)] - if not running: - return None, refused - for link, reason in refused: - logger.warning(f"sandbox_provider_skipped provider={type(link).__name__}: {reason}") - return ChainedSandboxProvider(running), refused +def skip_refusing(provider: SandboxProvider, problem: Callable[[SandboxProvider], str | None], *, what: str + ) -> SandboxProvider: + """``provider``, less each of its links ``problem`` refuses, as a deploy of ``what`` runs on any link that takes it. + ``problem`` says why a link refuses as a clause on its name ("which ..."); a ValueError naming each is raised when + it refuses every link.""" + links = list(provider.links) + running = _without(links, problem, lambda refused: ValueError(f"Can't deploy {what} " + "; nor ".join( + f"with {type(link).__name__}, {reason}" for link, reason in refused))) + return provider if len(running) == len(links) else ChainedSandboxProvider(running) + + +def _without(providers: list[SandboxProvider], problem: Callable[[SandboxProvider], str | None], + refuse: Callable[[list[tuple[SandboxProvider, str]]], Exception]) -> list[SandboxProvider]: + """``providers`` less each one ``problem`` refuses, skipped with a warning when it's the first, the one preferred, + and noted otherwise; ``refuse(refusals)`` is raised when it refuses them all.""" + refused = [(p, reason) for p in providers if (reason := problem(p))] + if refused and len(refused) == len(providers): + raise refuse(refused) + for p, reason in refused: + log = logger.warning if p is providers[0] else logger.info + log(f"sandbox_provider_skipped provider={type(p).__name__}: {reason}") + return [p for p in providers if all(p is not refusing for refusing, _ in refused)] diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 5a34903a..2cb83a35 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -299,9 +299,7 @@ async def load_docker_images(self, artifacts: list) -> None: another platform than its own, is refused before anything is loaded.""" if not artifacts: return - if problems := [problem for artifact in artifacts - if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE, - platform=self.platform))]: + if problems := self.load_problems(artifacts): raise RuntimeError(f"Can't load images: {'; '.join(problems)}") if tarballs := [artifact for artifact in artifacts if artifact.tar_gz_object_url]: await self._load_tarballs(tarballs) @@ -311,6 +309,13 @@ async def load_docker_images(self, artifacts: list) -> None: if built := [artifact for artifact in artifacts if artifact.context_only]: await self.build_images(built) + def load_problems(self, artifacts: list) -> list[str]: + """Why this VM can't get or run each DockerImageArtifact it can't, as ``load_docker_images`` refuses them: one + to pull from a registry on this machine when the VM isn't on it, one with no tar.gz whose name names no + registry, or one built for another platform than its own.""" + return [problem for artifact in artifacts + if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE, platform=self.platform))] + async def build_images(self, artifacts: list) -> None: """Build each context-only image from its build context, one build at a time, so heavy builds don't contend for one VM; images with the same ``source_digest`` share a build. The build pulls its base images and packages diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index a1da1f07..5df448c7 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -44,11 +44,9 @@ class Accepts(Enum): NAME = "name" # one it starts by its name, which an image that is only a build context gets only from a build NAME_OR_CONTEXT = "name_or_context" # one it starts by its name, or a build context it builds first (prepare_image) - def problem(self, image: DockerImageArtifact, *, on: SandboxProvider | Sandbox | None = None) -> str | None: - """Why a provider that accepts these can't run ``image`` on ``on``, the provider or one of its sandboxes, or - None when it can: one not on this machine can't pull from a registry on it. With no ``on``, that isn't - checked.""" - on_this_machine = on is None or on.ON_THIS_MACHINE + def problem(self, image: DockerImageArtifact, *, on_this_machine: bool) -> str | None: + """Why a provider that accepts these, whose sandboxes run ``on_this_machine`` or elsewhere, can't run ``image``, + or None when it can: one elsewhere can't pull from a registry on this machine.""" if self is Accepts.LOADABLE: return image.load_problem(on_this_machine=on_this_machine) if self is Accepts.NAME_OR_CONTEXT and image.context_only: @@ -176,8 +174,7 @@ def from_config(cls, **config: Any) -> Self: # it) reaches them, its containers need this machine's Docker, and a gateway's infra envs are built here for it. ON_THIS_MACHINE: ClassVar[bool] = False # 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. One that - # implements a method it can't serve declares its own. + # 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 @@ -187,10 +184,9 @@ def from_config(cls, **config: Any) -> Self: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - if "SUPPORTED_SANDBOX_MODES" not in cls.__dict__: - 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)) + 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)) @property def links(self) -> tuple[SandboxProvider, ...]: diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index c7e45e49..f0d8cd37 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional from agent_env.artifact.artifacts.docker_image import pull_problem from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing @@ -12,6 +12,9 @@ from agent_env.task_step.task_step import TaskStep, TaskStepDependency from agent_env.attribution import deploy_attribution +if TYPE_CHECKING: + from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider + logger = logging.getLogger(__name__) _SUPPORTED_MODES = {"vm", "container"} @@ -98,6 +101,12 @@ def from_dict(cls, data: dict) -> DeploySandboxTaskStep: network_policy=data.get("network_policy"), ) + def _pull_refusal(self, link: SandboxProvider) -> str | None: + """Why ``link``'s sandboxes can't pull this step's image by name, as a clause on its name, or None when they can.""" + if problem := pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE): + return f"whose sandboxes pull it by name: {problem}" + return None + async def execute(self, context: TaskStepContext) -> TaskStepContext: from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_sandbox_provider @@ -142,11 +151,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: else: if self.image is None or self.port is None: raise ValueError("container-mode sandbox requires `image` and `port`") - provider, refused = skip_refusing( - provider, lambda link: pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE)) - if provider is None: - refusals = "; nor ".join(f"with {type(link).__name__}: {problem}" for link, problem in refused) - raise ValueError(f"Can't deploy sandbox '{self.sandbox_name}' {refusals}") + provider = skip_refusing(provider, self._pull_refusal, what=f"sandbox '{self.sandbox_name}'") sandbox = await provider.create_sandbox( image_name=self.image, port=self.port, diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index d23642c4..3819945c 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -84,7 +84,8 @@ async def test_a_chain_skips_a_provider_that_runs_the_agent_by_name_for_one_that with pytest.raises(RuntimeError, match=r"All 1 providers failed: _VmProvider: _Created\(\)"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") - assert "sandbox_provider_skipped provider=_LocalProvider: 'solver-image' v2 is only a build context" in caplog.text + assert ("sandbox_provider_skipped provider=_LocalProvider: which runs an agent's image by name: 'solver-image' v2 is " + "only a build context") in caplog.text @pytest.mark.asyncio diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 8672169a..4c9e3f18 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -5,7 +5,6 @@ import ast from pathlib import Path -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import modal @@ -82,23 +81,17 @@ async def create_sandbox(self, **_): raise NotImplementedError -class _Declared(_Vms): - """One that implements create_sandbox but can't always serve it, so it declares its modes.""" +class _Declared(_VmsOnly): + """One that declares its modes, which are derived regardless: what it implements says what it supports.""" - SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_VM}) + SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_CONTAINER}) -class _DerivedAgain(_Declared): - async def create_sandbox(self, **_): - raise NotImplementedError - - -def test_a_provider_supports_only_the_modes_it_implements_unless_it_declares_them(): +def test_a_provider_supports_only_the_modes_it_implements(): assert (_VmsOnly.SUPPORTED_SANDBOX_MODES, _ContainersOnly.SUPPORTED_SANDBOX_MODES) == ( {SANDBOX_MODE_VM}, {SANDBOX_MODE_CONTAINER}) assert SandboxProvider.SUPPORTED_SANDBOX_MODES == set() assert _Declared.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} - assert _DerivedAgain.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} # a subclass that doesn't def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): @@ -145,18 +138,13 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" -def _on(on_this_machine: bool = False) -> SimpleNamespace: - """A sandbox or provider, as far as what it declares about where it runs.""" - return SimpleNamespace(ON_THIS_MACHINE=on_this_machine) - - @pytest.mark.parametrize("image, accepts, problem", [ (CONTEXT_ONLY, LOADABLE, None), (CONTEXT_ONLY, NAME_OR_CONTEXT, None), (CONTEXT_ONLY, NAME, "only a build context"), (NO_REGISTRY, LOADABLE, "doesn't name a registry"), (NO_REGISTRY, NAME, None), (NO_REGISTRY, NAME_OR_CONTEXT, None), ]) -@pytest.mark.parametrize("on", [None, _on(True), _on(False)]) -def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on): - found = accepts.problem(image, on=on) +@pytest.mark.parametrize("on_this_machine", [True, False]) +def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on_this_machine): + found = accepts.problem(image, on_this_machine=on_this_machine) assert (found is None) if problem is None else (problem in found) @@ -167,10 +155,9 @@ def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, pr ]) def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it_can(image, accepts, problem): """A VM loads an image's tar.gz, which agent-env pushes to it, and pulls one only when it has none.""" - found = accepts.problem(image, on=_on(False)) + found = accepts.problem(image, on_this_machine=False) assert (found is None) if problem is None else (problem in found) - assert accepts.problem(image, on=_on(True)) is None - assert accepts.problem(image) is None # a caller that doesn't say gets the check as it was + assert accepts.problem(image, on_this_machine=True) is None ARM64_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform="linux/arm64", @@ -192,7 +179,7 @@ def test_a_vm_runs_only_images_built_for_its_platform(image): @pytest.mark.parametrize("accepts", [LOADABLE, NAME, NAME_OR_CONTEXT]) def test_a_provider_checks_no_platform_before_its_sandbox_exists(accepts): - assert accepts.problem(ARM64_PULLED, on=_on(False)) is None + assert accepts.problem(ARM64_PULLED, on_this_machine=False) is None @pytest.mark.parametrize("recorded, runs", [ diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index 6c053929..dd94579d 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -287,33 +287,39 @@ async def test_chain_create_sandbox_all_fail_raises_with_all_names(): def test_skip_refusing_leaves_a_provider_none_of_whose_links_it_refuses(): chain = ChainedSandboxProvider([_StubProvider(), _StubProvider()]) - assert skip_refusing(chain, lambda link: None) == (chain, []) + assert skip_refusing(chain, lambda link: None, what="it") is chain -def test_skip_refusing_chains_the_links_it_doesnt_refuse_warning_of_each_it_skips(caplog): +def test_skip_refusing_chains_the_links_it_doesnt_refuse_noting_each_it_skips(caplog): first, second, third = _StubProvider(), _StubProvider(), _StubProvider() - with caplog.at_level(logging.WARNING, logger=chained_module.__name__): - running, refused = skip_refusing(ChainedSandboxProvider([first, second, third]), - lambda link: "too far" if link is second else None) + with caplog.at_level(logging.INFO, logger=chained_module.__name__): + running = skip_refusing(ChainedSandboxProvider([first, second, third]), + lambda link: "which is too far" if link is not third else None, what="it") - assert running.links == (first, third) and refused == [(second, "too far")] - assert "sandbox_provider_skipped provider=_StubProvider: too far" in caplog.text + assert running.links == (third,) + warned, noted = [r for r in caplog.records if "sandbox_provider_skipped" in r.getMessage()] + assert (warned.levelno, noted.levelno) == (logging.WARNING, logging.INFO) # the first, preferred, is warned of + assert warned.getMessage() == "sandbox_provider_skipped provider=_StubProvider: which is too far" -@pytest.mark.parametrize("provider", [_StubProvider(), ChainedSandboxProvider([_StubProvider(), _StubProvider()])], - ids=["one", "chain"]) -def test_skip_refusing_gives_no_provider_when_it_refuses_every_link(provider): - running, refused = skip_refusing(provider, lambda link: "too far") +@pytest.mark.parametrize("provider, refusal", [ + (_StubProvider(), "with _StubProvider, which is too far"), + (ChainedSandboxProvider([_StubProvider(), _StubProvider()]), + "with _StubProvider, which is too far; nor with _StubProvider, which is too far"), +], ids=["one", "chain"]) +def test_skip_refusing_names_each_refusal_when_it_refuses_every_link(provider, refusal): + with pytest.raises(ValueError, match=f"^Can't deploy it {refusal}$"): + skip_refusing(provider, lambda link: "which is too far", what="it") - assert running is None and refused == [(link, "too far") for link in provider.links] - -class _Vm(_FakeSandbox): +class _Vm(VmSandbox): """A VM sandbox of ``platform``, recording whether it was terminated.""" + type = "fake" + def __init__(self, sandbox_id: str, platform: str): - super().__init__(sandbox_id) + self.sandbox_id, self.tunnel_urls, self.vnc_url = sandbox_id, {}, None self.mode, self.platform, self.terminated = "vm", platform, False async def terminate(self) -> None: @@ -330,7 +336,7 @@ async def test_a_chain_closes_a_vm_that_cant_run_the_image_and_tries_the_next_pr assert await chain.create_sandbox(image_name="img:v1", port=8000, env={}) is arm assert amd.terminated and not arm.terminated - assert "gave a VM that can't run the image: 'img' v1 is a linux/arm64 image" in caplog.text + assert "gave a VM that can't load the image: 'img' v1 is a linux/arm64 image" in caplog.text @pytest.mark.asyncio diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py index 4c14cf75..bbc038ea 100644 --- a/tst/unit/task_step/deploy_sandbox_image_test.py +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -33,8 +33,9 @@ async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_on await step.execute(TaskStepContext()) provider.create_sandbox.assert_awaited_once() else: - with pytest.raises(ValueError, match="Can't deploy sandbox 'box' with AsyncMock: localhost:5000/box:v1 is in a " - "registry on this machine, which a sandbox elsewhere can't pull from"): + with pytest.raises(ValueError, match="Can't deploy sandbox 'box' with AsyncMock, whose sandboxes pull it by " + "name: localhost:5000/box:v1 is in a registry on this machine, which a " + "sandbox elsewhere can't pull from"): await step.execute(TaskStepContext()) provider.create_sandbox.assert_not_awaited() From ab110a0baa972ecd42de149799e91e65b76379b2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 14:43:40 -0700 Subject: [PATCH 12/13] refactor(images): every image check says where its sandbox runs, and the dry run's check reads plainly - DockerImageArtifact.load_problem and by_name_problem take a required on_this_machine, as Accepts.problem does, so no caller gets the check without reachability by leaving it out. - The dry run's _check returns the providers that take a deploy, and each provider's refusals come from _link_refusals, instead of one dense comprehension and a bool. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 2 +- .../artifact/artifacts/docker_image.py | 4 +- src/agent_env/bundle/preflight.py | 42 ++++++++++++------- .../a2a_agent/context_only_deploy_test.py | 1 + .../test_docker_image_build_context.py | 4 +- tst/unit/artifact/test_docker_image_pulled.py | 6 +-- .../artifact/test_docker_image_put_ref.py | 2 +- .../provider_capabilities_test.py | 10 ++--- 8 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index fd93ca72..d98f735a 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -442,7 +442,7 @@ async def deploy( image = self.docker_image_artifact image_name = image.image_name if provider is None: - if sandbox.mode != SANDBOX_MODE_VM and (problem := image.by_name_problem()): + if sandbox.mode != SANDBOX_MODE_VM and (problem := image.by_name_problem(on_this_machine=sandbox.ON_THIS_MACHINE)): raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " f"runs its own image, never the agent's: {problem}") else: diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index b46a3a9a..14af42d7 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -275,7 +275,7 @@ def context_only(self) -> bool: tag the image's build gives it.""" return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name) - def load_problem(self, *, on_this_machine: bool = True, platform: str | None = None) -> str | None: + def load_problem(self, *, on_this_machine: bool, platform: str | None = None) -> str | None: """Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build context when it has only that; and a VM of ``platform`` runs only an image built for it.""" @@ -286,7 +286,7 @@ def load_problem(self, *, on_this_machine: bool = True, platform: str | None = N return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a " "registry to pull it from") - def by_name_problem(self, *, on_this_machine: bool = True) -> str | None: + def by_name_problem(self, *, on_this_machine: bool) -> str | None: """Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 335acc20..d99b8a6a 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -24,7 +24,6 @@ from __future__ import annotations import functools -import itertools from collections.abc import Callable, Iterable from dataclasses import dataclass @@ -188,7 +187,8 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: return env_id, images, env_provider = step.env_id, deployment.images, deployment.provider elsewhere = functools.partial(self._gateway_elsewhere, env_id, env_provider, kinds) if GATEWAY in kinds else None - if self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere): + taking = self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere) + if any(link.ON_THIS_MACHINE for link in taking): self.infra |= kinds self.docker_users.append(where) @@ -295,7 +295,8 @@ def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | N self.default_agent_users.append((where, unnamed)) agent_id = get_config().get_default_a2a_agent_id() image = self._agent_image(agent_id, version) - if self._check(where, provider, accepts, [image] if image else [], vms_load=True): + taking = self._check(where, provider, accepts, [image] if image else [], vms_load=True) + if any(link.ON_THIS_MACHINE for link in taking): self.docker_users.append(where) def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: @@ -335,23 +336,32 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], images: list[_Image], *, vms_load: bool, - elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> bool: - """Refuse the deploy ``where`` when no provider it may run on takes it, naming why each refuses: one refuses an - image it can't run as ``accepts`` says the deploy runs images there (``_refusal``), and one not on this machine - whatever ``elsewhere`` says of it. A deploy skips a provider that refuses it, so one that takes it is enough. - ``vms_load`` when the deploy loads images on a VM provider, as an agent's and a gateway's do. An image the - bundle builds is noted with them, for ``_contexts`` to choose its form. Returns whether a provider on this - machine takes the deploy, which then may run on this machine's Docker; one it skips needs none.""" + elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> list[SandboxProvider]: + """The providers the deploy ``where`` may run on that take it, refusing the deploy, naming why each refuses + (``_link_refusals``), when none does: a deploy skips a provider that refuses it, so one that takes it is enough. + One on this machine that takes it runs it on this machine's Docker; one it skips needs none. An image the + bundle builds is noted with them, for ``_contexts`` to choose its form.""" links = list(provider.links) - refusals = [[refusal for image in images if (refusal := self._refusal(link, accepts(link), image, vms_load=vms_load))] - + (elsewhere(link) if elsewhere and not link.ON_THIS_MACHINE else []) for link in links] - if all(refusals): - for problem in itertools.chain.from_iterable(refusals): - self._problem(where, problem) + refusals = [(link, self._link_refusals(link, accepts, images, vms_load=vms_load, elsewhere=elsewhere)) + for link in links] + if all(found for _, found in refusals): + for _, found in refusals: + for problem in found: + self._problem(where, problem) for image in images: if image.built is not None: self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) - return any(link.ON_THIS_MACHINE and not refused for link, refused in zip(links, refusals)) + return [link for link, found in refusals if not found] + + def _link_refusals(self, link: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], images: list[_Image], + *, vms_load: bool, elsewhere: Callable[[SandboxProvider], list[str]] | None) -> list[str]: + """Why ``link`` can't take a deploy of ``images``: each it can't run as ``accepts`` says the deploy runs images + there (``_refusal``), and, when it isn't on this machine, whatever ``elsewhere`` says of it. ``vms_load`` when + the deploy loads images on a VM provider, as an agent's and a gateway's do.""" + found = [refusal for image in images if (refusal := self._refusal(link, accepts(link), image, vms_load=vms_load))] + if elsewhere is not None and not link.ON_THIS_MACHINE: + found += elsewhere(link) + return found def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool) -> str | None: """Why a deploy can't run ``image`` on ``link``, which takes images in ``form``, by the check the deploy makes diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 3819945c..f3ef48ec 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -136,6 +136,7 @@ class _ContainerSandbox: sandbox_id = "sb-container" mode = "container" tunnel_urls = {} + ON_THIS_MACHINE = False def url_from_sandbox(self, url): return url diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index 8505cf66..5662cc9b 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -69,8 +69,8 @@ def test_equal_sources_give_equal_digests_and_bytes(local_stores, context): def test_a_context_only_image_is_built_and_refused_where_run_by_name(local_stores, context): image = DockerImageArtifact.put_context("a", description="d", context_path=str(context), dockerfile_path="Dockerfile") - assert image.context_only and image.load_problem() is None - assert image.by_name_problem() == "'a' v1 is only a build context, which has to be built, not pulled" + assert image.context_only and image.load_problem(on_this_machine=False) is None + assert image.by_name_problem(on_this_machine=False) == "'a' v1 is only a build context, which has to be built, not pulled" with pytest.raises(ValueError, match="has no tar.gz; its image is built from its build context"): image.load() diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index b9cf2620..ac3166e9 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -16,7 +16,7 @@ def test_a_document_with_no_tar_gz_is_read(document): image = DockerImageArtifact.model_validate(document) assert image.tar_gz_object_url is None - assert image.load_problem() is None + assert image.load_problem(on_this_machine=False) is None def test_one_is_written_with_both_spellings_of_its_tar_gz_empty_and_reads_back_the_same(): @@ -39,7 +39,7 @@ def test_one_is_written_with_both_spellings_of_its_tar_gz_empty_and_reads_back_t def test_an_image_loads_from_its_tar_gz_or_by_pulling_a_name_that_names_its_registry(image_name, tar_gz, problem): image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name, tar_gz_object_url=tar_gz) - assert image.load_problem() == problem + assert image.load_problem(on_this_machine=True) == problem @pytest.mark.parametrize("image_name", ["localhost:5000/img:v1", "127.0.0.1:5000/img:v1"]) @@ -47,7 +47,7 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name) here = f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from" - assert (image.load_problem(), image.by_name_problem()) == (None, None) + assert (image.load_problem(on_this_machine=True), image.by_name_problem(on_this_machine=True)) == (None, None) assert image.load_problem(on_this_machine=False) == image.by_name_problem(on_this_machine=False) == here diff --git a/tst/unit/artifact/test_docker_image_put_ref.py b/tst/unit/artifact/test_docker_image_put_ref.py index 3ec15635..480fb6f5 100644 --- a/tst/unit/artifact/test_docker_image_put_ref.py +++ b/tst/unit/artifact/test_docker_image_put_ref.py @@ -31,7 +31,7 @@ def test_a_ref_is_registered_with_its_tag_pinned_and_no_tarball(local_stores, re stored = DockerImageArtifact.get("tool-image", image.version) assert (stored.version, stored.image_name, stored.tar_gz_object_url) == (1, f"ghcr.io/org/tool:v1@{DIGEST}", None) - assert stored.load_problem() is None + assert stored.load_problem(on_this_machine=False) is None assert DockerImageArtifact.put_ref("tool-image", description="d", image_name="ghcr.io/org/tool:v1").version == 2 diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 4c9e3f18..35b3cb7b 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -172,9 +172,9 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it @pytest.mark.parametrize("image", [ARM64_TAR_GZ, ARM64_PULLED, ARM64_CONTEXT], ids=["tar.gz", "pulled", "context"]) def test_a_vm_runs_only_images_built_for_its_platform(image): """A VM builds a context for the platform it records, so a context is checked too.""" - assert image.load_problem(platform="linux/amd64").startswith(ARM64) - assert image.load_problem(platform="linux/arm64") is None - assert image.load_problem(platform=None) is None # a sandbox that runs any, as a local one does + assert image.load_problem(on_this_machine=False, platform="linux/amd64").startswith(ARM64) + assert image.load_problem(on_this_machine=False, platform="linux/arm64") is None + assert image.load_problem(on_this_machine=False, platform=None) is None # a sandbox that runs any, as a local one does @pytest.mark.parametrize("accepts", [LOADABLE, NAME, NAME_OR_CONTEXT]) @@ -188,13 +188,13 @@ def test_a_provider_checks_no_platform_before_its_sandbox_exists(accepts): def test_a_platform_is_the_same_however_the_image_or_the_sandbox_spells_it(recorded, runs): image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform=recorded, tar_gz_object_url="s3://bucket/img.tar.gz") - assert image.load_problem(platform=runs) is None + assert image.load_problem(on_this_machine=False, platform=runs) is None def test_an_image_that_records_no_platform_isnt_checked(): image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", tar_gz_object_url="s3://bucket/img.tar.gz") - assert image.platform is None and image.load_problem(platform="linux/arm64") is None + assert image.platform is None and image.load_problem(on_this_machine=False, platform="linux/arm64") is None @pytest.mark.parametrize("sandbox", [Sandbox, ModalSandbox, ModalVmSandbox, E2BSandbox, SailVmSandbox]) From 614e7e198e3c1d1f9bfba735581a22f2ba1fdf00 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sun, 11 Oct 2026 14:55:03 -0700 Subject: [PATCH 13/13] fix(a2a_agent): an existing container sandbox is refused only an image that has to be built A container sandbox runs its own image and pulls nothing, so where the agent's image is can't rule it out: the deploy goes on to read its card. Only an image that is a build context alone, whose name is a tag only a build gives it, can't be what it runs, as on main. Checking reachability there refused a remote container that could already serve the agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 6 ++++-- tst/unit/a2a_agent/context_only_deploy_test.py | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index d98f735a..71742f53 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -442,9 +442,11 @@ async def deploy( image = self.docker_image_artifact image_name = image.image_name if provider is None: - if sandbox.mode != SANDBOX_MODE_VM and (problem := image.by_name_problem(on_this_machine=sandbox.ON_THIS_MACHINE)): + # A container sandbox pulls nothing, so only an image that has to be built can't be what it runs. + if sandbox.mode != SANDBOX_MODE_VM and image.context_only: raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " - f"runs its own image, never the agent's: {problem}") + f"runs its own image, never the agent's: {image.id!r} v{image.version} is only a build " + "context, which has to be built, not pulled") else: provider = skip_refusing(provider, functools.partial(self._refusal, image), what=f"agent {self.id!r}") diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index f3ef48ec..63e58bae 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -136,7 +136,6 @@ class _ContainerSandbox: sandbox_id = "sb-container" mode = "container" tunnel_urls = {} - ON_THIS_MACHINE = False def url_from_sandbox(self, url): return url @@ -155,6 +154,14 @@ async def test_an_existing_container_sandbox_refuses_it_since_it_never_runs_the_ NO_REGISTRY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="solver:v2") +@pytest.mark.asyncio +async def test_an_existing_container_sandbox_elsewhere_takes_an_agent_whose_image_is_in_this_machines_registry( + local_stores, litellm): + """It pulls nothing, so where the agent's image is doesn't matter: the deploy goes on to the container's card.""" + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox=_ContainerSandbox()) + + class _LocalPulls(LocalSandboxProvider): async def create_sandbox(self, **_): raise _Created