From 9f65ab016deb09adeefca6d8cee73a2d9d518883 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 27 Jul 2026 17:02:54 +0000 Subject: [PATCH] feat(release): pin first-party agent images --- .gitea/workflows/package-release.yml | 12 ++++++++++++ bot_bottle/backend/docker/launch.py | 9 +++++++++ bot_bottle/backend/macos_container/launch.py | 10 +++++++++- bot_bottle/backend/preparation.py | 8 +++++++- bot_bottle/release_bundle.py | 4 ++++ bot_bottle/release_manifest.py | 9 +++++++-- docs/prds/0083-packaged-infra-artifacts.md | 13 ++++++++++--- scripts/generate_release_manifest.py | 6 ++++++ tests/unit/test_generate_release_manifest.py | 3 +++ tests/unit/test_release_manifest.py | 15 +++++++++++++++ 10 files changed, 82 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/package-release.yml b/.gitea/workflows/package-release.yml index 7d621847..29aade80 100644 --- a/.gitea/workflows/package-release.yml +++ b/.gitea/workflows/package-release.yml @@ -13,6 +13,15 @@ on: gateway_image: description: Digest-pinned gateway OCI reference required: true + agent_claude_image: + description: Digest-pinned Claude agent OCI reference + required: true + agent_codex_image: + description: Digest-pinned Codex agent OCI reference + required: true + agent_pi_image: + description: Digest-pinned Pi agent OCI reference + required: true firecracker_orchestrator_version: description: Published orchestrator rootfs package version required: true @@ -42,6 +51,9 @@ jobs: --source-commit "$GITHUB_SHA" \ --orchestrator-image '${{ inputs.orchestrator_image }}' \ --gateway-image '${{ inputs.gateway_image }}' \ + --agent-claude-image '${{ inputs.agent_claude_image }}' \ + --agent-codex-image '${{ inputs.agent_codex_image }}' \ + --agent-pi-image '${{ inputs.agent_pi_image }}' \ --firecracker-orchestrator-version '${{ inputs.firecracker_orchestrator_version }}' \ --firecracker-orchestrator-sha256 '${{ inputs.firecracker_orchestrator_sha256 }}' \ --firecracker-gateway-version '${{ inputs.firecracker_gateway_version }}' \ diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 1b96f01d..3eacd678 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -84,6 +84,15 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages: ) info(f"using cached agent image {plan.image!r}") return BottleImages(agent=plan.image) + if "@sha256:" in plan.image: + pulled = docker_mod.run_docker(["docker", "pull", plan.image]) + if pulled.returncode != 0: + die(f"pulling packaged agent image {plan.image!r} failed: " + f"{pulled.stderr.strip()}") + docker_mod.verify_agent_image( + plan.image, runtime_for(plan.agent_provider_template).smoke_test, + ) + return BottleImages(agent=plan.image) docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path) docker_mod.verify_agent_image( plan.image, runtime_for(plan.agent_provider_template).smoke_test, diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 702dcff3..657bebc2 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -38,6 +38,7 @@ import subprocess from contextlib import ExitStack, contextmanager from typing import Callable, Generator +from ...agent_provider import runtime_for from ...bottle_state import ( egress_state_dir, git_gate_state_dir, @@ -93,7 +94,14 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str: ) info(f"using cached agent image {plan.image!r}") return plan.image - container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path) + if "@sha256:" in plan.image: + container_mod.pull_image(plan.image) + container_mod.verify_agent_image( + plan.image, runtime_for(plan.agent_provider_template).smoke_test, + ) + return plan.image + container_mod.build_image( + plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path) return plan.image diff --git a/bot_bottle/backend/preparation.py b/bot_bottle/backend/preparation.py index 756011c4..2e9e7837 100644 --- a/bot_bottle/backend/preparation.py +++ b/bot_bottle/backend/preparation.py @@ -9,7 +9,7 @@ backend-specific final resolution. from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Protocol from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider @@ -17,6 +17,7 @@ from ..egress import EgressPlan from ..env import ResolvedEnv, resolve_env from ..git_gate import GitGate, GitGatePlan from ..manifest import Manifest +from ..release_manifest import oci_image from ..supervisor.plan import SupervisePlan from ..workspace import workspace_plan from .resolve_common import ( @@ -112,6 +113,11 @@ class BottlePreparationPlanner: color=spec.color, provider_settings=provider_config.settings, ) + if not provider_config.dockerfile: + image, local = oci_image( + f"agent_{provider_config.template}", provision.image) + if not local: + provision = replace(provision, image=image) provision = merge_provision_env_vars(provision) return PreparedBottle( manifest=manifest, diff --git a/bot_bottle/release_bundle.py b/bot_bottle/release_bundle.py index 4ee56709..60062918 100644 --- a/bot_bottle/release_bundle.py +++ b/bot_bottle/release_bundle.py @@ -51,6 +51,10 @@ def build_bundle_index( "oci": { "orchestrator": manifest.orchestrator_image, "gateway": manifest.gateway_image, + **{ + f"agent_{role}": reference + for role, reference in manifest.agent_images.items() + }, }, "firecracker": { "orchestrator": { diff --git a/bot_bottle/release_manifest.py b/bot_bottle/release_manifest.py index 65a7be23..79a0ac70 100644 --- a/bot_bottle/release_manifest.py +++ b/bot_bottle/release_manifest.py @@ -32,6 +32,7 @@ class ReleaseManifest: source_commit: str orchestrator_image: str gateway_image: str + agent_images: dict[str, str] firecracker_orchestrator: FirecrackerArtifact firecracker_gateway: FirecrackerArtifact @@ -66,7 +67,7 @@ def parse_manifest(data: Any) -> ReleaseManifest: if not isinstance(oci, dict): raise ReleaseManifestError("release manifest oci must be an object") images: dict[str, str] = {} - for role in ("orchestrator", "gateway"): + for role in ("orchestrator", "gateway", "agent_claude", "agent_codex", "agent_pi"): ref = oci.get(role) if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None: raise ReleaseManifestError( @@ -79,6 +80,9 @@ def parse_manifest(data: Any) -> ReleaseManifest: source_commit=commit, orchestrator_image=images["orchestrator"], gateway_image=images["gateway"], + agent_images={ + role: images[f"agent_{role}"] for role in ("claude", "codex", "pi") + }, firecracker_orchestrator=_artifact( firecracker.get("orchestrator"), "orchestrator"), firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"), @@ -132,7 +136,8 @@ def oci_image(role: str, local_default: str) -> tuple[str, bool]: assert manifest is not None return ( manifest.orchestrator_image if role == "orchestrator" - else manifest.gateway_image, + else manifest.gateway_image if role == "gateway" + else manifest.agent_images[role.removeprefix("agent_")], False, ) diff --git a/docs/prds/0083-packaged-infra-artifacts.md b/docs/prds/0083-packaged-infra-artifacts.md index 7541a124..df8d113d 100644 --- a/docs/prds/0083-packaged-infra-artifacts.md +++ b/docs/prds/0083-packaged-infra-artifacts.md @@ -49,6 +49,8 @@ supply-chain boundary between release production and runtime. immutable orchestrator and gateway identities. - Docker and Apple Container acquire and run the manifest's digest-pinned OCI images without invoking `docker build` or `container build`. +- The shipped Claude, Codex, and Pi agent images are built, smoke-tested, + published, and selected by digest as part of the same commit bundle. - Firecracker acquires the manifest's versioned, checksum-verified orchestrator and gateway rootfs artifacts rather than deriving versions at launch. @@ -75,7 +77,7 @@ supply-chain boundary between release production and runtime. ## Non-goals -- Changing agent/provider image builds. User Dockerfiles continue to build +- Prebuilding user-supplied Dockerfiles. Custom agent images continue to build through the backend-specific agent-image paths. - Installing Docker, Apple Container, Firecracker, or other host prerequisites. - Replacing Gitea's OCI or generic-package registries. @@ -101,7 +103,10 @@ shipped as package data: "source_commit": "<40 lowercase hex>", "oci": { "orchestrator": "gitea.dideric.is/didericis/bot-bottle-orchestrator@sha256:<64 hex>", - "gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>" + "gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>", + "agent_claude": "gitea.dideric.is/didericis/bot-bottle-claude@sha256:<64 hex>", + "agent_codex": "gitea.dideric.is/didericis/bot-bottle-codex@sha256:<64 hex>", + "agent_pi": "gitea.dideric.is/didericis/bot-bottle-pi@sha256:<64 hex>" }, "firecracker": { "orchestrator": { @@ -131,7 +136,8 @@ it refuses mutable or incomplete release inputs. The release job operates on one tested commit: -1. Build the multi-architecture orchestrator and gateway OCI images. +1. Build and smoke-test the multi-architecture orchestrator, gateway, Claude, + Codex, and Pi OCI images. 2. Push them and resolve their registry manifest digests. 3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same source and pinned OCI bases. @@ -158,6 +164,7 @@ immutable external bundle index under that SHA. The index contains: - schema version and source SHA; - wheel URL and SHA-256; - orchestrator and gateway OCI digest references; +- Claude, Codex, and Pi agent OCI digest references; - Firecracker package versions and compressed-file SHA-256 values; - producing workflow/run identity and publication timestamp; and - qualification records, if any, naming the test workflow and release tag. diff --git a/scripts/generate_release_manifest.py b/scripts/generate_release_manifest.py index 2b1f2244..2b0a6f1d 100644 --- a/scripts/generate_release_manifest.py +++ b/scripts/generate_release_manifest.py @@ -14,6 +14,8 @@ def parser() -> argparse.ArgumentParser: result.add_argument("--source-commit", required=True) result.add_argument("--orchestrator-image", required=True) result.add_argument("--gateway-image", required=True) + for provider in ("claude", "codex", "pi"): + result.add_argument(f"--agent-{provider}-image", required=True) for role in ("orchestrator", "gateway"): result.add_argument(f"--firecracker-{role}-version", required=True) result.add_argument(f"--firecracker-{role}-sha256", required=True) @@ -29,6 +31,10 @@ def main(argv: list[str] | None = None) -> int: "oci": { "orchestrator": args.orchestrator_image, "gateway": args.gateway_image, + **{ + f"agent_{provider}": getattr(args, f"agent_{provider}_image") + for provider in ("claude", "codex", "pi") + }, }, "firecracker": { role: { diff --git a/tests/unit/test_generate_release_manifest.py b/tests/unit/test_generate_release_manifest.py index 79b2ae1b..7824fe83 100644 --- a/tests/unit/test_generate_release_manifest.py +++ b/tests/unit/test_generate_release_manifest.py @@ -17,6 +17,9 @@ class TestGenerateReleaseManifest(unittest.TestCase): "--orchestrator-image", "registry/orchestrator@sha256:" + "a" * 64, "--gateway-image", "registry/gateway@sha256:" + "b" * 64, + "--agent-claude-image", "registry/claude@sha256:" + "e" * 64, + "--agent-codex-image", "registry/codex@sha256:" + "f" * 64, + "--agent-pi-image", "registry/pi@sha256:" + "0" * 64, "--firecracker-orchestrator-version", "orch-v1", "--firecracker-orchestrator-sha256", "c" * 64, "--firecracker-gateway-version", "gateway-v1", diff --git a/tests/unit/test_release_manifest.py b/tests/unit/test_release_manifest.py index 7ce3eb1a..0d6b663a 100644 --- a/tests/unit/test_release_manifest.py +++ b/tests/unit/test_release_manifest.py @@ -18,6 +18,9 @@ def manifest() -> dict[str, object]: "oci": { "orchestrator": "registry/orchestrator@sha256:" + "a" * 64, "gateway": "registry/gateway@sha256:" + "b" * 64, + "agent_claude": "registry/agent-claude@sha256:" + "e" * 64, + "agent_codex": "registry/agent-codex@sha256:" + "f" * 64, + "agent_pi": "registry/agent-pi@sha256:" + "0" * 64, }, "firecracker": { "orchestrator": {"version": "orch-v1", "sha256": "c" * 64}, @@ -31,6 +34,7 @@ class TestParseManifest(unittest.TestCase): parsed = parse_manifest(manifest()) self.assertTrue(parsed.orchestrator_image.endswith("a" * 64)) self.assertEqual("orch-v1", parsed.firecracker_orchestrator.version) + self.assertTrue(parsed.agent_images["codex"].endswith("f" * 64)) def test_rejects_mutable_oci_reference(self) -> None: data = manifest() @@ -100,6 +104,17 @@ class TestImageSelection(unittest.TestCase): self.assertEqual(parsed.orchestrator_image, ref) self.assertFalse(local) + def test_packaged_install_selects_agent_reference(self) -> None: + parsed = parse_manifest(manifest()) + with patch( + "bot_bottle.release_manifest.packaged_manifest", return_value=parsed, + ), patch( + "bot_bottle.release_manifest.local_build_requested", return_value=False, + ), patch.dict("os.environ", {}, clear=True): + ref, local = oci_image("agent_codex", "local:latest") + self.assertEqual(parsed.agent_images["codex"], ref) + self.assertFalse(local) + def test_mutable_override_fails_outside_local_mode(self) -> None: parsed = parse_manifest(manifest()) with patch(