From 22ea5a0a66d15ff1c9e68b255aa201ef573afd9c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 10:53:07 -0700 Subject: [PATCH] feat(cli): register a shipped agent's published image with a2a-agent put --published `agent-env a2a-agent put --id --published claude-code` registers the image the installed release published for a shipped agent, by its tag and digest, with the agent's folder as its build context, so a hosted control plane can serve the released image without building or pushing one. It refuses when no image is published for the installed sources. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/agents/AGENTS.md | 1 + src/agent_env/agents/README.md | 5 +- src/agent_env/cli/a2a_agent/put.py | 41 +++++++++-- tst/unit/cli/a2a_agent_put_published_test.py | 75 ++++++++++++++++++++ tst/unit/cli/image_ref_put_test.py | 4 +- 5 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 tst/unit/cli/a2a_agent_put_published_test.py diff --git a/src/agent_env/agents/AGENTS.md b/src/agent_env/agents/AGENTS.md index 98ba4701..b0e59e01 100644 --- a/src/agent_env/agents/AGENTS.md +++ b/src/agent_env/agents/AGENTS.md @@ -224,6 +224,7 @@ Use your own URI, not `urn:agentenv:*`. Reference: `packages/agentenv-protocol/e ```bash agent-env run ./my-bundle # build, register under @local, run its tasks agent-env a2a-agent put --id my-agent --dockerfile my-bundle/agents/my-agent/Dockerfile +agent-env a2a-agent put --id my-claude --published claude-code # a shipped agent's released image, nothing built agent-env a2a-agent validate --id my-agent # agent-env's built-in checks agent-env a2a-agent get --id my-agent # the validation results ``` diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 4c5a5c89..1fbe191e 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -16,7 +16,10 @@ endpoint. The image holds Claude Code, which is Anthropic's software under its at `ghcr.io/scaleapi/agentenv-claude-code:src-`, built from this folder with the protocol pinned as the wheel ships it. A run that deploys the agent registers that image when it's published for the sources installed: each deploy pulls it by its digest and builds it from this folder only when it can't, and a provider that runs images by name can -run it. The CLI's version is the Dockerfile's +run it. To register that published image under an id of your own, in a store a hosted control plane reads, +`agent-env a2a-agent put --id --published claude-code` takes its tag and digest from the release installed and this +folder as its build context, with nothing built or pushed; it refuses when no image is published for those sources, as +for a git checkout. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. The image gives the scripts the model runs `node`, without npm or npx, and the Python packages `model-requirements.txt` pins. diff --git a/src/agent_env/cli/a2a_agent/put.py b/src/agent_env/cli/a2a_agent/put.py index 407c411e..5bc8a5cb 100644 --- a/src/agent_env/cli/a2a_agent/put.py +++ b/src/agent_env/cli/a2a_agent/put.py @@ -6,6 +6,7 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact import DockerImageArtifact from agent_env.cli.utils import build_platform_option, detect_env_metadata, refuse_unwritable_ids +from agent_env.env.published_images import AGENT_IMAGES, PublishedImage, published_digest from agent_env.store.ids import derive_id, image_repository from agent_env.utils.docker.build import DEFAULT_BUILD_PLATFORM, build_image @@ -17,6 +18,10 @@ @click.option("--image-ref", "image_ref", default=None, help="An image already in a registry, e.g. ghcr.io/org/agent:v1: registered as it is, with its tag pinned " "to the digest the registry serves now, and pulled by sandboxes (instead of --dockerfile)") +@click.option("--published", "published", default=None, metavar="AGENT", + help="A shipped agent, e.g. claude-code: registers the image agent-env's release published for the agent " + "this install ships, by its tag and digest, with the agent's folder as the build context " + "(instead of --dockerfile)") @click.option("--env-var", "env_var_pairs", multiple=True, help="Default env var KEY=VALUE (repeatable)") @click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)") @click.option("--min-disk-size-gb", type=float, default=None, help="Minimum disk size in GB for agent deployment") @@ -24,11 +29,11 @@ @click.option("--skip-validation", is_flag=True, default=False, help="Skip A2A agent validation after registration") @click.option("--litellm-api-key", type=str, default=None, help="LiteLLM API key for validation prompts") @build_platform_option -def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_ref: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str): +def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_ref: str | None, published: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str): """Build a Docker image and register an A2A agent, or register one over an image already in a registry.""" - if bool(dockerfile) == bool(image_ref): - click.echo("Error: exactly one of --dockerfile and --image-ref is required", err=True) + if sum(map(bool, (dockerfile, image_ref, published))) != 1: + click.echo("Error: exactly one of --dockerfile, --image-ref and --published is required", err=True) sys.exit(1) if context_path and not dockerfile: click.echo("Error: --context needs --dockerfile", err=True) @@ -52,9 +57,25 @@ def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_r image_id = derive_id(agent_id, "agent_image") refuse_unwritable_ids(agent_id, image_id) - if image_ref: + if image_ref or published: if build_platform != DEFAULT_BUILD_PLATFORM: - click.echo(f"Warning: --platform {build_platform!r} is ignored for --image-ref, which builds nothing", err=True) + flag = "--image-ref" if image_ref else "--published" + click.echo(f"Warning: --platform {build_platform!r} is ignored for {flag}, which builds nothing", err=True) + if published: + image = _shipped_image(published) + digest = published_digest(image) + if digest is None: + click.echo(f"Error: no image is published for this install's {published} ({image.ref()}): a release's wheel " + "from PyPI holds the sources its release published", err=True) + sys.exit(1) + folder = image.dockerfile.parent + artifact = DockerImageArtifact.put_ref( + image_id, description=f"A2A agent image for {agent_id}", image_name=image.ref(), image_digest=digest, + context_path=str(folder), dockerfile_path=str(image.dockerfile)) + click.echo(f"Registered published image: id={artifact.id} version={artifact.version} " + f"image={artifact.image_name}@{digest}") + metadata = detect_env_metadata(image.dockerfile, folder) + elif image_ref: artifact = DockerImageArtifact.put_ref(image_id, description=f"A2A agent image for {agent_id}", image_name=image_ref) click.echo(f"Registered image: id={artifact.id} version={artifact.version} image={artifact.image_name}") metadata = {} @@ -96,3 +117,13 @@ def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_r from agent_env.cli.a2a_agent.validate import run_validation click.echo() run_validation(agent, litellm_api_key=litellm_api_key) + + +def _shipped_image(name: str) -> PublishedImage: + """The published image of the shipped agent in folder ``name``.""" + for image in AGENT_IMAGES: + if image.dockerfile.parent.name == name: + return image + names = ", ".join(image.dockerfile.parent.name for image in AGENT_IMAGES) + click.echo(f"Error: agent-env publishes no image for {name!r}; it publishes {names}", err=True) + sys.exit(1) diff --git a/tst/unit/cli/a2a_agent_put_published_test.py b/tst/unit/cli/a2a_agent_put_published_test.py new file mode 100644 index 00000000..13796e7a --- /dev/null +++ b/tst/unit/cli/a2a_agent_put_published_test.py @@ -0,0 +1,75 @@ +"""`agent-env a2a-agent put --published`: an agent registered over the image a release published for a shipped agent.""" + +import importlib + +import pytest +from click.testing import CliRunner + +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.store import get_artifact_store +from agent_env.cli import cli +from agent_env.env.published_images import PublishedImage +from agent_env.store.base import NotFoundError + +put_module = importlib.import_module("agent_env.cli.a2a_agent.put") +DIGEST = "sha256:" + "a" * 64 + + +@pytest.fixture +def shipped(tmp_path, monkeypatch, local_stores): + """A shipped claude-code agent folder, and a release that published its image under ``DIGEST``.""" + folder = tmp_path / "agents" / "claude-code" + folder.mkdir(parents=True) + (folder / "Dockerfile").write_text("FROM scratch\nCOPY a2a_server.py /\n") + (folder / "a2a_server.py").write_text("print('ok')\n") + image = PublishedImage("agentenv-claude-code", folder / "Dockerfile") + monkeypatch.setattr(put_module, "AGENT_IMAGES", (image,)) + monkeypatch.setattr(put_module, "published_digest", lambda published: DIGEST) + return image + + +def _put(*args): + return CliRunner().invoke(cli, ["a2a-agent", "put", "--id", "claude-code-cli", *args, "--skip-validation"]) + + +def test_published_registers_the_released_image_by_tag_and_digest_with_the_folder_as_its_context(shipped): + result = _put("--published", "claude-code") + + assert result.exit_code == 0, result.output + agent = A2AAgent.get("claude-code-cli") + image = get_artifact_store().get(agent.docker_image_artifact.id, agent.docker_image_artifact.version) + assert image.image_name == shipped.ref() and image.image_name.startswith("ghcr.io/scaleapi/agentenv-claude-code:src-") + assert image.image_digest == DIGEST + assert image.build_context_object_url + assert image.source_digest == "sha256:" + shipped.tag().removeprefix("src-") + assert not image.context_only + assert f"{shipped.ref()}@{DIGEST}" in result.output + + +def test_published_refuses_when_no_image_is_published_for_the_installed_sources(shipped, monkeypatch): + monkeypatch.setattr(put_module, "published_digest", lambda published: None) + + result = _put("--published", "claude-code") + + assert result.exit_code == 1 + assert "no image is published" in result.output and shipped.ref() in result.output + assert get_artifact_store().next_version("claude-code-cli__agent_image") == 1 + with pytest.raises(NotFoundError): + A2AAgent.get("claude-code-cli") + + +def test_published_names_the_agents_it_can_register(shipped): + result = _put("--published", "pi") + + assert result.exit_code == 1 + assert "publishes no image for 'pi'" in result.output and "claude-code" in result.output + + +@pytest.mark.parametrize("other", [["--image-ref", "ghcr.io/acme/agent:1"], ["--dockerfile", "{dockerfile}"]]) +def test_published_is_one_of_the_image_sources(shipped, other): + args = [arg.format(dockerfile=shipped.dockerfile) for arg in other] + + result = _put("--published", "claude-code", *args) + + assert result.exit_code == 1 + assert "exactly one of --dockerfile, --image-ref and --published" in result.output diff --git a/tst/unit/cli/image_ref_put_test.py b/tst/unit/cli/image_ref_put_test.py index 16fbaa36..2d7f2eef 100644 --- a/tst/unit/cli/image_ref_put_test.py +++ b/tst/unit/cli/image_ref_put_test.py @@ -66,8 +66,8 @@ def test_an_agent_is_registered_over_an_image_in_a_registry(local_stores, pinned @pytest.mark.parametrize("extra, message", [ - ([], "exactly one of --dockerfile and --image-ref is required"), - (["--image-ref", REF, "--dockerfile", "Dockerfile"], "exactly one of --dockerfile and --image-ref is required"), + ([], "exactly one of --dockerfile, --image-ref and --published is required"), + (["--image-ref", REF, "--dockerfile", "Dockerfile"], "exactly one of --dockerfile, --image-ref and --published is required"), (["--image-ref", REF, "--context", "."], "--context needs --dockerfile"), ]) def test_an_agent_takes_one_image_source(local_stores, pinned, tmp_path, extra, message):