From e1a81a483f548554d64fb8a7ed21788aafe44093 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 12:01:22 -0700 Subject: [PATCH 1/5] fix(images): a sandbox elsewhere can't pull a tar.gz image's bare tag, nor build a context that names no Dockerfile - by_name_problem: an image stored as a tar.gz under a name that names no registry carries the tag of the machine that built it, so a sandbox elsewhere (Modal, say) refuses it instead of looking it up on Docker Hub. A sandbox on this machine may hold the tag, so it's left as it was. - DockerImageArtifact.buildable: a stored context is built only when the image is only a context or the context records its Dockerfile, as every put since contexts became whole folders does. Older contexts and GitHub builds' record none. Accepts.NAME_OR_CONTEXT, Modal's prepare_image and a VM's rebuild for another platform use it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 15 ++++++++++-- .../sandbox_providers/modal_sandbox.py | 4 ++-- .../sandbox_providers/sandbox_provider.py | 2 +- .../test_docker_image_build_context.py | 24 ++++++++++++------- tst/unit/artifact/test_docker_image_pulled.py | 19 +++++++++++++++ .../env_gateway_provider_test.py | 3 ++- .../modal_context_build_test.py | 14 ++++++++++- .../provider_capabilities_test.py | 16 ++++++++++++- 8 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 04356b8f..9dafaf94 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -275,10 +275,17 @@ 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) + @property + def buildable(self) -> bool: + """Whether a sandbox can build the image from its stored build context: one that is only a context, or one whose + context records its Dockerfile's path, as every put since contexts became whole folders does. An older context, + or a GitHub build's, records none and is kept only beside its tar.gz.""" + return self.context_only or bool(self.build_context_object_url and self.dockerfile_path) + def rebuilt_for(self, platform: str | None) -> bool: """Whether a VM sandbox of ``platform`` builds this image from its build context rather than load its tar.gz, which is built for another platform: the image runs, though not byte for byte the one stored.""" - return bool(self.tar_gz_object_url and self.build_context_object_url) and self.platform_problem(platform) is not None + return bool(self.tar_gz_object_url) and self.buildable and self.platform_problem(platform) is not 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 @@ -295,9 +302,13 @@ def load_problem(self, *, on_this_machine: bool, platform: str | None = 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.""" + Docker Hub. A sandbox not ``on_this_machine`` can't pull from a registry on this machine, nor an image stored as + a tar.gz under a name that names no registry: that's the tag the machine that built it gave it.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" + if self.tar_gz_object_url and not names_registry(self.image_name) and not on_this_machine: + return (f"{self.id!r} v{self.version} is stored as a tar.gz under {self.image_name!r}, which names no " + "registry: it's the tag the machine that built it gave it, which a sandbox elsewhere can't pull") return pull_problem(self.image_name, on_this_machine=on_this_machine) def platform_problem(self, platform: str | None) -> str | None: diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 9c5bee77..28fbaf34 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -247,8 +247,8 @@ async def prepare_image(self, image: DockerImageArtifact, *, attribution: Option """Build ``image`` from its build context when Modal can't run it by name: it's only a build context, its name is in a registry Modal can't reach, or it's built for another platform than Modal's; unless this process has already built its sources. One it pulls drops an image prepared earlier under its name.""" - if image.build_context_object_url and (image.by_name_problem(on_this_machine=self.ON_THIS_MACHINE) - or image.platform_problem(self._sandbox_cls.platform)): + if image.buildable and (image.by_name_problem(on_this_machine=self.ON_THIS_MACHINE) + or image.platform_problem(self._sandbox_cls.platform)): self._context_images[image.image_name] = image await self._context_image(image) else: diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index bb6dc46f..ba73e563 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -49,7 +49,7 @@ def problem(self, image: DockerImageArtifact, *, on_this_machine: bool) -> str | 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.build_context_object_url: + if self is Accepts.NAME_OR_CONTEXT and image.buildable: return None # the provider builds what it can't pull return image.by_name_problem(on_this_machine=on_this_machine) diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index cdf93344..0c0f8867 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -177,17 +177,23 @@ def test_a_put_records_the_platform_as_docker_build_names_it(local_stores, conte assert image.platform == "linux/arm64" and image.source_digest == named.source_digest -@pytest.mark.parametrize("context_url, platform, rebuilt, problem", [ - ("s3://bucket/ctx.tar.gz", "linux/amd64", True, None), - ("s3://bucket/ctx.tar.gz", "linux/arm64", False, None), - ("s3://bucket/ctx.tar.gz", None, False, None), - (None, "linux/amd64", False, "'img' v1 is a linux/arm64 image, and the sandbox runs linux/amd64"), -], ids=["context-another-platform", "context-same-platform", "vm-runs-any", "no-context"]) -def test_a_vm_of_another_platform_builds_an_image_from_the_context_beside_its_tar_gz(context_url, platform, rebuilt, - problem): +OTHER_PLATFORM = "'img' v1 is a linux/arm64 image, and the sandbox runs linux/amd64" + + +@pytest.mark.parametrize("context_url, dockerfile, platform, rebuilt, problem", [ + ("s3://bucket/ctx.tar.gz", "Dockerfile", "linux/amd64", True, None), + ("s3://bucket/ctx.tar.gz", "Dockerfile", "linux/arm64", False, None), + ("s3://bucket/ctx.tar.gz", "Dockerfile", None, False, None), + (None, None, "linux/amd64", False, OTHER_PLATFORM), + ("s3://bucket/ctx.tar.gz", None, "linux/amd64", False, OTHER_PLATFORM), +], ids=["context-another-platform", "context-same-platform", "vm-runs-any", "no-context", + "context-naming-no-dockerfile"]) +def test_a_vm_of_another_platform_builds_an_image_from_the_context_beside_its_tar_gz(context_url, dockerfile, platform, + rebuilt, problem): + """Only from a context that records its Dockerfile, as every put since contexts became whole folders does.""" image = DockerImageArtifact(id="img", version=1, description="d", image_name="localhost:5000/img:v1", tar_gz_object_url="file:///state/img.tar.gz", build_context_object_url=context_url, - platform="linux/arm64") + dockerfile_path=dockerfile, platform="linux/arm64") assert image.rebuilt_for(platform) is rebuilt assert (image.load_problem(on_this_machine=False, platform=platform) or "").startswith(problem or "") diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index ac3166e9..169e591f 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -51,6 +51,25 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): assert image.load_problem(on_this_machine=False) == image.by_name_problem(on_this_machine=False) == here +@pytest.mark.parametrize("image_name, tar_gz, elsewhere", [ + ("mcp-server-email", "s3://bucket/img.tar.gz", + "'img' v1 is stored as a tar.gz under 'mcp-server-email', which names no registry: it's the tag the machine that " + "built it gave it, which a sandbox elsewhere can't pull"), + ("team/img:v1", "s3://bucket/img.tar.gz", "names no registry"), + (REF, "s3://bucket/img.tar.gz", None), + ("python:3.12", None, None), +], ids=["bare-tag", "bare-path", "registry", "docker-hub-name"]) +def test_a_sandbox_elsewhere_cant_pull_the_tag_an_image_stored_as_a_tar_gz_was_built_under(image_name, tar_gz, + elsewhere): + """The tag exists only where the image was built, so a sandbox on this machine may still have it; a name without + a tar.gz is an image to pull from Docker Hub.""" + image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name, tar_gz_object_url=tar_gz) + + found = image.by_name_problem(on_this_machine=False) + assert (found is None) if elsewhere is None else (elsewhere in found) + assert image.by_name_problem(on_this_machine=True) is None + + 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") 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 bcb5dc50..17f50dcd 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -243,7 +243,8 @@ async def test_container_mode_refuses_a_servicedb_image_in_this_machines_registr _SERVICEDB_BUILT_HERE = DockerImageArtifact(id="default-db__db_image", version=1, description="d", image_name="localhost:5000/local/servicedb:v1", tar_gz_object_url="file:///state/servicedb.tar.gz", - build_context_object_url="file:///state/ctx.tar.gz", platform="linux/arm64") + build_context_object_url="file:///state/ctx.tar.gz", + dockerfile_path="Dockerfile", platform="linux/arm64") @pytest.mark.asyncio diff --git a/tst/unit/providers/sandbox_providers/modal_context_build_test.py b/tst/unit/providers/sandbox_providers/modal_context_build_test.py index af052a45..b4b6a6eb 100644 --- a/tst/unit/providers/sandbox_providers/modal_context_build_test.py +++ b/tst/unit/providers/sandbox_providers/modal_context_build_test.py @@ -178,7 +178,8 @@ async def test_an_image_with_no_build_context_is_not_built(local_stores, builds) ("localhost:5000/local/gateway:v1", "linux/amd64", True), ("registry.example/gateway:v1", "linux/arm64", True), ("registry.example/gateway:v1", "linux/amd64", False), -], ids=["in-this-machines-registry", "for-another-platform", "pulled"]) + ("mcp-server-gateway", "linux/amd64", True), +], ids=["in-this-machines-registry", "for-another-platform", "pulled", "under-a-tag-of-the-machine-that-built-it"]) async def test_an_image_with_a_build_context_beside_its_tar_gz_is_built_when_modal_cant_run_it_by_name( local_stores, context, builds, image_name, platform, built): """As the infra bootstrap writes its images: a tar.gz, which Modal can't load, and the context it was built from.""" @@ -190,6 +191,17 @@ async def test_an_image_with_a_build_context_beside_its_tar_gz_is_built_when_mod assert len(builds.calls) == int(built) +@pytest.mark.asyncio +async def test_an_image_whose_context_records_no_dockerfile_is_not_built(local_stores, context, builds): + """A context written before contexts became whole folders, or a GitHub build's, names no Dockerfile to build.""" + image = _context_image("server-image", context).model_copy(update={ + "image_name": "mcp-server-email", "tar_gz_object_url": "file:///state/server.tar.gz", "dockerfile_path": None}) + + await _provider().prepare_image(image) + + assert builds.calls == [] + + @pytest.mark.asyncio async def test_an_image_modal_pulls_drops_one_prepared_earlier_under_its_name(local_stores, context, builds): provider = _provider() diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 73326044..2d5c3ab1 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -164,8 +164,18 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p 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") -WITH_TAR_GZ_AND_CONTEXT = WITH_TAR_GZ.model_copy(update={"build_context_object_url": "file:///state/ctx.tar.gz"}) +WITH_TAR_GZ_AND_CONTEXT = WITH_TAR_GZ.model_copy(update={"build_context_object_url": "file:///state/ctx.tar.gz", + "dockerfile_path": "Dockerfile"}) +# A context written before contexts became whole folders records no Dockerfile, so it can't be built. +WITH_TAR_GZ_AND_AN_OLD_CONTEXT = WITH_TAR_GZ.model_copy(update={"build_context_object_url": "file:///state/ctx.tar.gz"}) HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" +BARE_TAG_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="mcp-server-email", + tar_gz_object_url="s3://bucket/img.tar.gz") +BARE_TAG_TAR_GZ_AND_CONTEXT = BARE_TAG_TAR_GZ.model_copy(update={"build_context_object_url": "s3://bucket/ctx.tar.gz", + "dockerfile_path": "Dockerfile"}) +BARE_TAG_TAR_GZ_AND_AN_OLD_CONTEXT = BARE_TAG_TAR_GZ.model_copy( + update={"build_context_object_url": "s3://bucket/ctx.tar.gz"}) +BUILT_ELSEWHERE = "it's the tag the machine that built it gave it" @pytest.mark.parametrize("image, accepts, problem", [ @@ -184,6 +194,10 @@ def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, pr (WITH_TAR_GZ, LOADABLE, None), (WITH_TAR_GZ, NAME, HERE), (WITH_TAR_GZ, NAME_OR_CONTEXT, HERE), (WITH_TAR_GZ_AND_CONTEXT, LOADABLE, None), (WITH_TAR_GZ_AND_CONTEXT, NAME, HERE), (WITH_TAR_GZ_AND_CONTEXT, NAME_OR_CONTEXT, None), + (BARE_TAG_TAR_GZ, LOADABLE, None), (BARE_TAG_TAR_GZ, NAME, BUILT_ELSEWHERE), + (BARE_TAG_TAR_GZ, NAME_OR_CONTEXT, BUILT_ELSEWHERE), (BARE_TAG_TAR_GZ_AND_CONTEXT, NAME_OR_CONTEXT, None), + (WITH_TAR_GZ_AND_AN_OLD_CONTEXT, NAME_OR_CONTEXT, HERE), + (BARE_TAG_TAR_GZ_AND_AN_OLD_CONTEXT, NAME_OR_CONTEXT, BUILT_ELSEWHERE), ]) 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; a provider that From 727c209bb26417282fe34d7fe7c8643b54c6ff51 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 21:22:50 -0700 Subject: [PATCH 2/5] fix(images): a refused build-tag image says how to run it, wherever the refusal surfaces A sandbox elsewhere refuses an image stored as a tar.gz under its build tag; outside bundle preflight, in a hub or worker deploy, the refusal named no remedy. It now ends with one: run it on a provider whose VMs load its tar.gz, or put the image again so its name records its registry. Preflight gives the same remedy once, with the flags that apply it (--sandbox local, --sandbox modal_vm when the platforms match). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 19 ++++++++++++++++--- src/agent_env/bundle/preflight.py | 5 ++++- tst/unit/artifact/test_docker_image_pulled.py | 3 ++- tst/unit/bundle/preflight_test.py | 17 +++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 9dafaf94..f17e47f4 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -32,6 +32,9 @@ ProgressCallback = Callable[[str, str, int], None] +# How an image stored under its build tag gets a name a sandbox elsewhere pulls: put() pushes it to the image store. +PUT_AGAIN = "put the image again so its name records its registry" + _SAFE_GIT_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -282,6 +285,17 @@ def buildable(self) -> bool: or a GitHub build's, records none and is kept only beside its tar.gz.""" return self.context_only or bool(self.build_context_object_url and self.dockerfile_path) + @property + def stored_under_build_tag(self) -> bool: + """Whether the image is stored as a tar.gz under a name that names no registry, the tag the machine that built + it gave it: only that machine, or a VM that loads the tar.gz, has an image of that name.""" + return bool(self.tar_gz_object_url) and not names_registry(self.image_name) + + def build_tag_reason(self) -> str: + """Why a sandbox elsewhere can't pull an image ``stored_under_build_tag``.""" + return (f"{self.id!r} v{self.version} is stored as a tar.gz under {self.image_name!r}, which names no registry: " + "it's the tag the machine that built it gave it, which a sandbox elsewhere can't pull") + def rebuilt_for(self, platform: str | None) -> bool: """Whether a VM sandbox of ``platform`` builds this image from its build context rather than load its tar.gz, which is built for another platform: the image runs, though not byte for byte the one stored.""" @@ -306,9 +320,8 @@ def by_name_problem(self, *, on_this_machine: bool) -> str | None: a tar.gz under a name that names no registry: that's the tag the machine that built it gave it.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" - if self.tar_gz_object_url and not names_registry(self.image_name) and not on_this_machine: - return (f"{self.id!r} v{self.version} is stored as a tar.gz under {self.image_name!r}, which names no " - "registry: it's the tag the machine that built it gave it, which a sandbox elsewhere can't pull") + if self.stored_under_build_tag and not on_this_machine: + return f"{self.build_tag_reason()}; run it on a provider whose VMs load its tar.gz, or {PUT_AGAIN}" return pull_problem(self.image_name, on_this_machine=on_this_machine) def platform_problem(self, platform: str | None) -> str | None: diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index dc282c0c..e0a71949 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from agent_env.a2a_agent import A2AAgent -from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, pull_problem +from agent_env.artifact.artifacts.docker_image import PUT_AGAIN, 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 ( @@ -383,6 +383,9 @@ def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_l 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 "") + if image.artifact.stored_under_build_tag: # the refusal's own remedy, with the flags that apply it + return (f"deploys {image.what} on the {_shown(link)} sandbox provider: " + f"{image.artifact.build_tag_reason()}; run it with --sandbox local{loads}, or {PUT_AGAIN}") return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with " f"--sandbox local{loads}") if form is Accepts.LOADABLE: diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index 169e591f..e91770f4 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -54,7 +54,8 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): @pytest.mark.parametrize("image_name, tar_gz, elsewhere", [ ("mcp-server-email", "s3://bucket/img.tar.gz", "'img' v1 is stored as a tar.gz under 'mcp-server-email', which names no registry: it's the tag the machine that " - "built it gave it, which a sandbox elsewhere can't pull"), + "built it gave it, which a sandbox elsewhere can't pull; run it on a provider whose VMs load its tar.gz, or put " + "the image again so its name records its registry"), ("team/img:v1", "s3://bucket/img.tar.gz", "names no registry"), (REF, "s3://bucket/img.tar.gz", None), ("python:3.12", None, None), diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 8076c19f..46c9670e 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -119,6 +119,23 @@ def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere assert not local_store().path.exists() +def test_an_image_stored_under_its_build_tag_is_refused_where_a_sandbox_elsewhere_pulls_it_naming_the_flags(bundle_dir): + """The tag exists only on the machine that built the image, so a sandbox elsewhere would look it up on Docker Hub; + the refusal gives its remedy once, with the flags that apply it.""" + _agent("solver", ("mcp-server-email:v1", "s3://bucket/img-v1.tar.gz")) + _task(bundle_dir, [AGENT]) + + [problem] = _problems(lambda: run_bundle(bundle_dir, sandbox="modal")) + + assert problem.startswith("tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox " + "provider: ") + assert "is stored as a tar.gz under 'mcp-server-email:v1', which names no registry" in problem + assert problem.endswith("; run it with --sandbox local, or on a provider whose VMs load its tar.gz, such as " + "--sandbox modal_vm, or put the image again so its name records its registry") + assert problem.count("whose VMs load its tar.gz") == 1 + 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]) From 55076cb674e8e3a76bb268e8d7224e35457e360a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 21:51:50 -0700 Subject: [PATCH 3/5] fix(images): the build-tag remedy names a registry the sandbox reaches Putting the image again into this machine's registry leaves it as unreachable from a sandbox elsewhere, so the remedy says to put it in an image store whose registry the sandbox can reach. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/artifact/artifacts/docker_image.py | 5 +++-- tst/unit/artifact/test_docker_image_pulled.py | 2 +- tst/unit/bundle/preflight_test.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index f17e47f4..29f1ed45 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -32,8 +32,9 @@ ProgressCallback = Callable[[str, str, int], None] -# How an image stored under its build tag gets a name a sandbox elsewhere pulls: put() pushes it to the image store. -PUT_AGAIN = "put the image again so its name records its registry" +# How an image stored under its build tag gets a name a sandbox elsewhere pulls: put() pushes it to the image store, +# which has to be one whose registry that sandbox reaches; this machine's own registry isn't. +PUT_AGAIN = "put the image again in an image store whose registry the sandbox can reach" _SAFE_GIT_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index e91770f4..cd9b19a5 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -55,7 +55,7 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): ("mcp-server-email", "s3://bucket/img.tar.gz", "'img' v1 is stored as a tar.gz under 'mcp-server-email', which names no registry: it's the tag the machine that " "built it gave it, which a sandbox elsewhere can't pull; run it on a provider whose VMs load its tar.gz, or put " - "the image again so its name records its registry"), + "the image again in an image store whose registry the sandbox can reach"), ("team/img:v1", "s3://bucket/img.tar.gz", "names no registry"), (REF, "s3://bucket/img.tar.gz", None), ("python:3.12", None, None), diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 46c9670e..0cc3c453 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -131,7 +131,7 @@ def test_an_image_stored_under_its_build_tag_is_refused_where_a_sandbox_elsewher "provider: ") assert "is stored as a tar.gz under 'mcp-server-email:v1', which names no registry" in problem assert problem.endswith("; run it with --sandbox local, or on a provider whose VMs load its tar.gz, such as " - "--sandbox modal_vm, or put the image again so its name records its registry") + "--sandbox modal_vm, or put the image again in an image store whose registry the sandbox can reach") assert problem.count("whose VMs load its tar.gz") == 1 assert not local_store().path.exists() From 6dd7eba7217aa0737b46b983916d237c6b68c545 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 22:46:49 -0700 Subject: [PATCH 4/5] refactor(images): a context is buildable when it records its Dockerfile, the one rule every builder reads `buildable` also held for any context-only image, and the VM loader and both builders fell back to a Dockerfile at the context's root when none was recorded: the assumption this change retires. put_context always records the Dockerfile's path, so that clause held only for documents nothing writes. Now: - `buildable` is a context that records its Dockerfile's path; - a VM refuses a context-only image that records none, before anything is loaded, instead of building it from a guessed path; - the VM loader's and Modal's builds read the recorded path, with no fallback. The context-only fixtures record their Dockerfile, as put_context writes them. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/artifact/artifacts/docker_image.py | 10 ++++++---- .../providers/sandbox_providers/modal_image_build.py | 2 +- src/agent_env/providers/sandbox_providers/sandbox.py | 2 +- tst/unit/a2a_agent/context_only_deploy_test.py | 2 +- .../env_providers/env_gateway_provider_test.py | 2 +- .../env_providers/env_server_provider_test.py | 2 +- .../sandbox_providers/provider_capabilities_test.py | 10 ++++++++-- .../sandbox_providers/sandbox_provider_test.py | 2 +- 8 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 29f1ed45..6260e236 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -281,10 +281,10 @@ def context_only(self) -> bool: @property def buildable(self) -> bool: - """Whether a sandbox can build the image from its stored build context: one that is only a context, or one whose - context records its Dockerfile's path, as every put since contexts became whole folders does. An older context, - or a GitHub build's, records none and is kept only beside its tar.gz.""" - return self.context_only or bool(self.build_context_object_url and self.dockerfile_path) + """Whether a sandbox can build the image from its stored build context: one that records its Dockerfile's path, + as every put since contexts became whole folders does. An older context, or a GitHub build's, records none and + is kept only beside its tar.gz.""" + return bool(self.build_context_object_url and self.dockerfile_path) @property def stored_under_build_tag(self) -> bool: @@ -307,6 +307,8 @@ def load_problem(self, *, on_this_machine: bool, platform: str | None = None) -> 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, or one it rebuilds from the build context beside a tar.gz of another platform.""" + if self.context_only and not self.buildable: + return f"{self.id!r} v{self.version} is only a build context, which records no Dockerfile to build it with" if self.tar_gz_object_url or self.context_only: return None if self.rebuilt_for(platform) else self.platform_problem(platform) if names_registry(self.image_name): diff --git a/src/agent_env/providers/sandbox_providers/modal_image_build.py b/src/agent_env/providers/sandbox_providers/modal_image_build.py index ec601aa9..c7eca715 100644 --- a/src/agent_env/providers/sandbox_providers/modal_image_build.py +++ b/src/agent_env/providers/sandbox_providers/modal_image_build.py @@ -53,7 +53,7 @@ async def build(image: DockerImageArtifact, app: modal.App) -> str: try: await asyncio.to_thread(_fetch_context, image.build_context_object_url, Path(work) / "context.tar.gz", context) - dockerfile = context / (image.dockerfile_path or "Dockerfile") + dockerfile = context / image.dockerfile_path ignores = ignore_file(context, dockerfile) # Beside the context, not in it, so a COPY of the context still copies the Dockerfile as written. for_modal = Path(work) / "Dockerfile" diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 3016b919..d9806af1 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -345,7 +345,7 @@ async def _build_image(self, images: list) -> None: await self.load_object_file(first.build_context_object_url, f"{work}/context.tar.gz") await self.exec_script( f"tar -xzf {work}/context.tar.gz -C {work}/context && cd {work}/context && " - f"docker build --platform {shlex.quote(platform)} -f {shlex.quote(first.dockerfile_path or 'Dockerfile')} " + f"docker build --platform {shlex.quote(platform)} -f {shlex.quote(first.dockerfile_path)} " f"{tags} ." ) except RuntimeError as e: diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 97e32f04..34fca12b 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -51,7 +51,7 @@ async def create_container(self, **kwargs): IMAGE = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="local/solver-0123456789ab:v2", - build_context_object_url="s3://bucket/ctx.tar.gz") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") @pytest.fixture 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 efaeea38..8497800b 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -166,7 +166,7 @@ def _vm_provider(**attrs) -> MagicMock: _CONTEXT_ONLY = DockerImageArtifact(id="slack-image", version=2, description="d", image_name="local/slack-0123456789ab:v2", - build_context_object_url="s3://bucket/ctx.tar.gz") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") @pytest.mark.asyncio 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 ed501a79..d798d0e5 100644 --- a/tst/unit/providers/env_providers/env_server_provider_test.py +++ b/tst/unit/providers/env_providers/env_server_provider_test.py @@ -63,7 +63,7 @@ async def test_a_deploy_starts_only_the_server_on_sqlite_with_its_sizing(): _CONTEXT_ONLY = DockerImageArtifact(id="slack-image", version=3, description="d", image_name="local/slack-0123456789ab:v3", - build_context_object_url="s3://bucket/ctx.tar.gz") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") @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 2d5c3ab1..f18a5a44 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -156,8 +156,10 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p CONTEXT_ONLY = DockerImageArtifact(id="img", version=2, description="d", image_name="local/img-0123456789ab:v2", - build_context_object_url="s3://bucket/ctx.tar.gz") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") NO_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2") +# Only a context, which put_context always writes with its Dockerfile's path: one without is built by no sandbox. +CONTEXT_ONLY_NO_DOCKERFILE = CONTEXT_ONLY.model_copy(update={"dockerfile_path": None}) IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", @@ -181,6 +183,9 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p @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), + (CONTEXT_ONLY_NO_DOCKERFILE, LOADABLE, "records no Dockerfile to build it with"), + (CONTEXT_ONLY_NO_DOCKERFILE, NAME_OR_CONTEXT, "only a build context"), + (CONTEXT_ONLY_NO_DOCKERFILE, NAME, "only a build context"), ]) @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): @@ -212,7 +217,8 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it 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") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile", + platform="linux/arm64") ARM64 = "'img' v2 is a linux/arm64 image, and the sandbox runs linux/amd64; rebuild it for linux/amd64" diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index dd94579d..edcc03df 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -353,7 +353,7 @@ async def test_a_chain_none_of_whose_vms_runs_the_image_names_each_refusal(): _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") + build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") class _PreparingProvider(_StubProvider): From 5d473b04bc4c5ccfb2f47da06978c30d8e0840da Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 23:14:28 -0700 Subject: [PATCH 5/5] docs(images): shorter comments and docstrings for the build-tag and buildable rules Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/artifact/artifacts/docker_image.py | 15 ++++----------- src/agent_env/bundle/preflight.py | 2 +- .../artifact/test_docker_image_build_context.py | 2 +- tst/unit/artifact/test_docker_image_pulled.py | 3 +-- tst/unit/bundle/preflight_test.py | 3 +-- .../sandbox_providers/modal_context_build_test.py | 2 +- .../provider_capabilities_test.py | 4 ++-- 7 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 6260e236..6917a61c 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -32,8 +32,6 @@ ProgressCallback = Callable[[str, str, int], None] -# How an image stored under its build tag gets a name a sandbox elsewhere pulls: put() pushes it to the image store, -# which has to be one whose registry that sandbox reaches; this machine's own registry isn't. PUT_AGAIN = "put the image again in an image store whose registry the sandbox can reach" _SAFE_GIT_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -281,15 +279,12 @@ def context_only(self) -> bool: @property def buildable(self) -> bool: - """Whether a sandbox can build the image from its stored build context: one that records its Dockerfile's path, - as every put since contexts became whole folders does. An older context, or a GitHub build's, records none and - is kept only beside its tar.gz.""" + """Whether the context records its Dockerfile, so a sandbox can build it; old and GitHub contexts don't.""" return bool(self.build_context_object_url and self.dockerfile_path) @property def stored_under_build_tag(self) -> bool: - """Whether the image is stored as a tar.gz under a name that names no registry, the tag the machine that built - it gave it: only that machine, or a VM that loads the tar.gz, has an image of that name.""" + """Whether the image is a tar.gz under a bare tag, which only the machine that built it has.""" return bool(self.tar_gz_object_url) and not names_registry(self.image_name) def build_tag_reason(self) -> str: @@ -317,10 +312,8 @@ def load_problem(self, *, on_this_machine: bool, platform: str | None = None) -> "registry to pull it from") 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. A sandbox not ``on_this_machine`` can't pull from a registry on this machine, nor an image stored as - a tar.gz under a name that names no registry: that's the tag the machine that built it gave it.""" + """Why a sandbox that pulls images by name can't run this one, or None: a context-only image must be built, + and a sandbox elsewhere can't pull from this machine's registry or an image ``stored_under_build_tag``.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" if self.stored_under_build_tag and not on_this_machine: diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index e0a71949..6711f0f9 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -383,7 +383,7 @@ def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_l 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 "") - if image.artifact.stored_under_build_tag: # the refusal's own remedy, with the flags that apply it + if image.artifact.stored_under_build_tag: # its remedy, with the CLI's flags return (f"deploys {image.what} on the {_shown(link)} sandbox provider: " f"{image.artifact.build_tag_reason()}; run it with --sandbox local{loads}, or {PUT_AGAIN}") return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with " diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index 0c0f8867..d6b8dc4d 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -190,7 +190,7 @@ def test_a_put_records_the_platform_as_docker_build_names_it(local_stores, conte "context-naming-no-dockerfile"]) def test_a_vm_of_another_platform_builds_an_image_from_the_context_beside_its_tar_gz(context_url, dockerfile, platform, rebuilt, problem): - """Only from a context that records its Dockerfile, as every put since contexts became whole folders does.""" + """Only from a context that records its Dockerfile.""" image = DockerImageArtifact(id="img", version=1, description="d", image_name="localhost:5000/img:v1", tar_gz_object_url="file:///state/img.tar.gz", build_context_object_url=context_url, dockerfile_path=dockerfile, platform="linux/arm64") diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index cd9b19a5..1a2c7a12 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -62,8 +62,7 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): ], ids=["bare-tag", "bare-path", "registry", "docker-hub-name"]) def test_a_sandbox_elsewhere_cant_pull_the_tag_an_image_stored_as_a_tar_gz_was_built_under(image_name, tar_gz, elsewhere): - """The tag exists only where the image was built, so a sandbox on this machine may still have it; a name without - a tar.gz is an image to pull from Docker Hub.""" + """The tag exists only where the image was built; a bare name with no tar.gz is a Docker Hub image.""" image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name, tar_gz_object_url=tar_gz) found = image.by_name_problem(on_this_machine=False) diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 0cc3c453..79947d31 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -120,8 +120,7 @@ def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere def test_an_image_stored_under_its_build_tag_is_refused_where_a_sandbox_elsewhere_pulls_it_naming_the_flags(bundle_dir): - """The tag exists only on the machine that built the image, so a sandbox elsewhere would look it up on Docker Hub; - the refusal gives its remedy once, with the flags that apply it.""" + """The remedy is given once, with the CLI's flags.""" _agent("solver", ("mcp-server-email:v1", "s3://bucket/img-v1.tar.gz")) _task(bundle_dir, [AGENT]) diff --git a/tst/unit/providers/sandbox_providers/modal_context_build_test.py b/tst/unit/providers/sandbox_providers/modal_context_build_test.py index 279a2a4b..f1e3ac15 100644 --- a/tst/unit/providers/sandbox_providers/modal_context_build_test.py +++ b/tst/unit/providers/sandbox_providers/modal_context_build_test.py @@ -204,7 +204,7 @@ async def test_an_image_with_a_build_context_beside_its_tar_gz_is_built_when_mod @pytest.mark.asyncio async def test_an_image_whose_context_records_no_dockerfile_is_not_built(local_stores, context, builds): - """A context written before contexts became whole folders, or a GitHub build's, names no Dockerfile to build.""" + """Old and GitHub contexts record no Dockerfile.""" image = _context_image("server-image", context).model_copy(update={ "image_name": "mcp-server-email", "tar_gz_object_url": "file:///state/server.tar.gz", "dockerfile_path": None}) diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index f18a5a44..31d5142e 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -158,7 +158,7 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p CONTEXT_ONLY = DockerImageArtifact(id="img", version=2, description="d", image_name="local/img-0123456789ab:v2", build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile") NO_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2") -# Only a context, which put_context always writes with its Dockerfile's path: one without is built by no sandbox. +# put_context always records the Dockerfile; without it, nothing builds the context. CONTEXT_ONLY_NO_DOCKERFILE = CONTEXT_ONLY.model_copy(update={"dockerfile_path": None}) @@ -168,7 +168,7 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p tar_gz_object_url="file:///state/img.tar.gz") WITH_TAR_GZ_AND_CONTEXT = WITH_TAR_GZ.model_copy(update={"build_context_object_url": "file:///state/ctx.tar.gz", "dockerfile_path": "Dockerfile"}) -# A context written before contexts became whole folders records no Dockerfile, so it can't be built. +# An old context records no Dockerfile, so nothing builds it. WITH_TAR_GZ_AND_AN_OLD_CONTEXT = WITH_TAR_GZ.model_copy(update={"build_context_object_url": "file:///state/ctx.tar.gz"}) HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" BARE_TAG_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="mcp-server-email",