From 1830b54b2db05dfb9c2662eebdab65bbcbafcbdd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:07:19 -0700 Subject: [PATCH 01/22] feat(artifact): give every artifact metadata, changed by compare-and-swap What is learned about an artifact version after it's written, such as the images a computer artifact inspects at put and, later, what booting them found, has nowhere to go. Artifacts gain a metadata dict outside their versioned fields, which merge_metadata changes by compare-and-swap on metadata.updated_at, as envs and agents do. The golden wire files gain the empty metadata each artifact now writes. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifact.py | 21 +++++++++ src/agent_env/artifact/store.py | 25 +++++++++- tst/unit/artifact/test_artifact_metadata.py | 47 +++++++++++++++++++ .../serialization/golden/artifact/cli.json | 1 + .../golden/artifact/docker_image.json | 1 + .../golden/artifact/environment.json | 1 + .../serialization/golden/artifact/file.json | 1 + .../artifact/file_artifact_universe.json | 1 + .../serialization/golden/artifact/skill.json | 1 + 9 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tst/unit/artifact/test_artifact_metadata.py diff --git a/src/agent_env/artifact/artifact.py b/src/agent_env/artifact/artifact.py index c7cb3a9ea..dfc7310ce 100644 --- a/src/agent_env/artifact/artifact.py +++ b/src/agent_env/artifact/artifact.py @@ -36,6 +36,9 @@ class Artifact(BaseModel): id: str = Field(description="Unique artifact identifier") version: int = Field(default=0, description="Artifact version: 0 takes the next free one on put; another must be the next") type: str = Field(description="The type of Artifact") + metadata: dict[str, Any] = Field(default_factory=dict, description=( + "What is learned about this version after it's written, such as a computer's inspected images; outside the " + "versioned fields, and changed only by compare-and-swap (merge_metadata)")) toml_refs: ClassVar[tuple[EntityRef, ...]] = () toml_keys: ClassVar[dict[str, type]] = {} # what an artifact.toml may set, for a type without an accept_toml @@ -76,6 +79,24 @@ def put(cls, **kwargs: Any) -> Self: instance = cls(**kwargs) return get_artifact_store().put_document(instance) + def merge_metadata(self, updates: dict[str, Any], retries: int = 3) -> None: + """Merge ``updates`` into this version's stored metadata, re-reading and trying again when another writer + changed it meanwhile.""" + from agent_env.artifact.store import get_artifact_store + from agent_env.store.base import ConcurrentModificationError + + if self.version < 1: + raise ValueError(f"artifact {self.id!r} isn't stored yet, so it has no metadata to update") + store, metadata = get_artifact_store(), self.metadata + for attempt in range(retries + 1): + try: + self.metadata = store.update_metadata(self.id, self.version, metadata, {**metadata, **updates}) + return + except ConcurrentModificationError: + if attempt == retries: + raise + metadata = store.get(self.id, self.version).metadata + @classmethod def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self: """Write the artifact authored as ``data`` (its toml, with the keys ``toml_refs`` declares diff --git a/src/agent_env/artifact/store.py b/src/agent_env/artifact/store.py index 716fd01aa..042efdaa0 100644 --- a/src/agent_env/artifact/store.py +++ b/src/agent_env/artifact/store.py @@ -6,18 +6,20 @@ import threading import uuid from datetime import datetime, timezone -from typing import TYPE_CHECKING, Optional, Self, TypeVar +from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar from agent_env.config import get_config from agent_env.plugins import _registration logger = logging.getLogger(__name__) from agent_env.artifact.registry import canonical_type, equivalent_types, get_artifact_registry -from agent_env.store.base import NotFoundError +from agent_env.store.base import ConcurrentModificationError, NotFoundError from agent_env.store.document_store import ( + AbsentOrNull, DuplicateKeyError, Filter, In, + LteOrAbsent, UnknownDocumentTypeError, UpdateSpec, VersionedEntityStore, @@ -254,6 +256,25 @@ def set_field(self, artifact_type: str, id: str, version: int, field: str, value UpdateSpec(set={field: value}), ) + def update_metadata(self, id: str, version: int, old_metadata: dict[str, Any], + new_metadata: dict[str, Any]) -> dict[str, Any]: + """Replace an artifact version's metadata, by compare-and-swap on ``metadata.updated_at``: it goes through when + the stored metadata is no newer than ``old_metadata``. Raises NotFoundError for no such version and + ConcurrentModificationError when another writer got there first.""" + new_metadata = {**new_metadata, "updated_at": datetime.now(timezone.utc).isoformat()} + old_ts = old_metadata.get("updated_at") + matched = self._doc_store.update( + ARTIFACTS_COLLECTION, + Filter.of(id=id, version=version).where( + "metadata.updated_at", LteOrAbsent(old_ts) if old_ts is not None else AbsentOrNull()), + UpdateSpec(set={"metadata": new_metadata}), + ) + if matched == 0: + if self._doc_store.find_one(ARTIFACTS_COLLECTION, Filter.of(id=id, version=version)) is None: + raise NotFoundError(f"Artifact {id} version={version} not found") + raise ConcurrentModificationError(f"Artifact {id} version={version} metadata was modified concurrently") + return new_metadata + def _deserialize(self, doc: dict) -> "Artifact": # Remove MongoDB-specific fields not in Pydantic model doc.pop("_id", None) diff --git a/tst/unit/artifact/test_artifact_metadata.py b/tst/unit/artifact/test_artifact_metadata.py new file mode 100644 index 000000000..e6edb69f5 --- /dev/null +++ b/tst/unit/artifact/test_artifact_metadata.py @@ -0,0 +1,47 @@ +"""An artifact version's metadata: written with it, then changed by compare-and-swap without touching its versioned +fields, merging with a concurrent writer's keys rather than overwriting them.""" + +import pytest + +from agent_env.artifact import Artifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.store import ConcurrentModificationError, NotFoundError + + +def _image(**metadata) -> DockerImageArtifact: + return get_artifact_store().put_document(DockerImageArtifact( + id="desktop-image", description="a desktop", image_name="docker.io/library/ubuntu:24.04", metadata=metadata)) + + +def test_metadata_is_stored_with_the_artifact_and_merged_without_a_new_version(local_stores): + image = _image(source="put") + image.merge_metadata({"validated_images": {"container": {"amd64": {"status": "passed"}}}}) + stored = Artifact.get("desktop-image", image.version) + assert stored.version == image.version == 1 and stored.image_name == image.image_name + assert stored.metadata["source"] == "put" and stored.metadata["validated_images"]["container"]["amd64"] == { + "status": "passed"} + assert stored.metadata == image.metadata and "updated_at" in stored.metadata + assert get_artifact_store().next_version("desktop-image") == 2 + + +def test_a_merge_after_another_writer_keeps_both_keys(local_stores): + mine = _image() + theirs = Artifact.get("desktop-image", mine.version) + theirs.merge_metadata({"validated_boot": {"container": "passed"}}) + mine.merge_metadata({"validated_images": {"container": "passed"}}) + stored = Artifact.get("desktop-image", mine.version).metadata + assert stored["validated_boot"] == {"container": "passed"} and stored["validated_images"] == {"container": "passed"} + + +def test_a_stale_update_and_a_missing_version_are_refused(local_stores): + image = _image() + store = get_artifact_store() + fresh = store.update_metadata(image.id, image.version, image.metadata, {"a": 1}) + store.update_metadata(image.id, image.version, fresh, {"a": 2}) + with pytest.raises(ConcurrentModificationError): + store.update_metadata(image.id, image.version, fresh, {"a": 3}) + with pytest.raises(NotFoundError): + store.update_metadata(image.id, 9, {}, {"a": 1}) + with pytest.raises(ValueError, match="isn't stored yet"): + DockerImageArtifact(id="unsaved", description="", image_name="docker.io/library/x:1").merge_metadata({"a": 1}) diff --git a/tst/unit/serialization/golden/artifact/cli.json b/tst/unit/serialization/golden/artifact/cli.json index 0efa25b6c..cc01ceba3 100644 --- a/tst/unit/serialization/golden/artifact/cli.json +++ b/tst/unit/serialization/golden/artifact/cli.json @@ -7,6 +7,7 @@ "env_id": "slack-env", "env_version": 2, "id": "slack-cli", + "metadata": {}, "type": "cli", "version": 3 } diff --git a/tst/unit/serialization/golden/artifact/docker_image.json b/tst/unit/serialization/golden/artifact/docker_image.json index d9e0060cd..76d2d92ad 100644 --- a/tst/unit/serialization/golden/artifact/docker_image.json +++ b/tst/unit/serialization/golden/artifact/docker_image.json @@ -6,6 +6,7 @@ "id": "slack-mcp-image", "image_digest": null, "image_name": "slack-mcp:v5", + "metadata": {}, "platform": null, "source_digest": null, "tar_gz_object_url": "s3://artifact-bucket/artifacts/docker_image/slack-mcp-image/5/image.tar.gz", diff --git a/tst/unit/serialization/golden/artifact/environment.json b/tst/unit/serialization/golden/artifact/environment.json index 3a6e522ad..ddf01072c 100644 --- a/tst/unit/serialization/golden/artifact/environment.json +++ b/tst/unit/serialization/golden/artifact/environment.json @@ -5,6 +5,7 @@ "version": 2 }, "id": "slack-service", + "metadata": {}, "service_name": "slack", "type": "environment", "version": 7 diff --git a/tst/unit/serialization/golden/artifact/file.json b/tst/unit/serialization/golden/artifact/file.json index 5ac03b358..a42d98435 100644 --- a/tst/unit/serialization/golden/artifact/file.json +++ b/tst/unit/serialization/golden/artifact/file.json @@ -3,6 +3,7 @@ "description": "Slack universe seed data", "filename": "slack.json", "id": "slack-universe-data", + "metadata": {}, "object_url": "s3://artifact-bucket/artifacts/file/slack-universe-data/2/slack.json", "s3_url": "s3://artifact-bucket/artifacts/file/slack-universe-data/2/slack.json", "type": "file", diff --git a/tst/unit/serialization/golden/artifact/file_artifact_universe.json b/tst/unit/serialization/golden/artifact/file_artifact_universe.json index 325add644..e72762a1d 100644 --- a/tst/unit/serialization/golden/artifact/file_artifact_universe.json +++ b/tst/unit/serialization/golden/artifact/file_artifact_universe.json @@ -11,6 +11,7 @@ } }, "id": "tutorial-files", + "metadata": {}, "type": "file_artifact_universe", "version": 4 } diff --git a/tst/unit/serialization/golden/artifact/skill.json b/tst/unit/serialization/golden/artifact/skill.json index 2cb49bfb0..b5c17fc45 100644 --- a/tst/unit/serialization/golden/artifact/skill.json +++ b/tst/unit/serialization/golden/artifact/skill.json @@ -5,6 +5,7 @@ "description": "Fill PDF forms", "id": "pdf-filler", "license": "MIT", + "metadata": {}, "skill_files_id": "pdf-filler-files", "skill_metadata": { "category": "documents" From fa41567d37fa7fba6e337c4d0d3e506ddcf45ef6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:20:00 -0700 Subject: [PATCH 02/22] feat(computer): add the computer artifact type A computer artifact is the base a computer env composes a driver and apps onto: its architectures, its OS (linux), its session user, its settings, and its container form, whose image is a docker_image artifact pinned by id and version. Other OS types and forms are refused as not yet supported. put reads each form's image from its registry before writing: the index has a linux manifest for every architecture, and the os-release of the lowest layer holding it names the declared distribution and version. A mismatch refuses the put, naming its form and architecture; an image no registry serves yet is recorded uninspected. What was read goes in metadata.validated_images. In a bundle, forms.container.image takes what an image key takes, a Dockerfile in the folder, a registry ref or an image artifact, so the resolver now writes images for image keys inside a table. The registry API gains manifest and blob reads, with the token handshake pin_digest uses. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/__init__.py | 2 + src/agent_env/artifact/artifacts/computer.py | 286 ++++++++++++++++++ src/agent_env/artifact/registry.py | 2 + src/agent_env/bundle/resolve.py | 26 +- .../store/image_store/registry_api.py | 102 ++++++- tst/unit/artifact/test_computer_artifact.py | 191 ++++++++++++ tst/unit/bundle/computer_folder_test.py | 133 ++++++++ .../golden/artifact/computer.json | 49 +++ .../serialization/test_golden_wire_format.py | 13 + tst/unit/store/registry_api_test.py | 67 +++- 10 files changed, 858 insertions(+), 13 deletions(-) create mode 100644 src/agent_env/artifact/artifacts/computer.py create mode 100644 tst/unit/artifact/test_computer_artifact.py create mode 100644 tst/unit/bundle/computer_folder_test.py create mode 100644 tst/unit/serialization/golden/artifact/computer.json diff --git a/src/agent_env/artifact/__init__.py b/src/agent_env/artifact/__init__.py index aa8248e72..4ba467702 100644 --- a/src/agent_env/artifact/__init__.py +++ b/src/agent_env/artifact/__init__.py @@ -3,6 +3,7 @@ from agent_env.artifact.artifact import Artifact from agent_env.artifact.ref import ArtifactRef from agent_env.artifact.artifacts.cli import CliArtifact +from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse @@ -23,6 +24,7 @@ "Artifact", "ArtifactRef", "CliArtifact", + "ComputerArtifact", "DockerImageArtifact", "FileArtifact", "FileArtifactUniverse", diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py new file mode 100644 index 000000000..e3e8bcf95 --- /dev/null +++ b/src/agent_env/artifact/artifacts/computer.py @@ -0,0 +1,286 @@ +"""The computer artifact: a base a computer env composes a driver and apps onto. It declares what the computer is, its +architectures, OS, session user and settings, and how each form starts it. A form's image is a docker_image artifact, +pinned when the computer is written and read back from its registry to check it holds what the computer says +(``metadata.validated_images``).""" + +from __future__ import annotations + +import json +import re +import tarfile +import tempfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +from agent_env.artifact.artifact import Artifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.ref import ArtifactRef +from agent_env.entity.refs import EntityRef +from agent_env.store.image_store.oci_registry_credentials import names_registry +from agent_env.store.image_store.registry_api import read_blob, read_manifest + +if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext + +OS_TYPES = ("linux",) +FORMS = ("container",) +_INDEX_TYPES = ("application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json") +# os-release(5): /etc/os-release first, else /usr/lib/os-release; on Ubuntu the first links to the second. +_OS_RELEASE = ("etc/os-release", "usr/lib/os-release") + + +class LinuxOS(BaseModel): + """A Linux base: its distribution and version (``rolling`` for one with no versions, such as Arch), and the display + server its desktop runs.""" + + model_config = ConfigDict(extra="forbid") + + type: Literal["linux"] = "linux" + distribution: str = Field(pattern=r"^[a-z][a-z0-9-]*$", description="os-release's ID: ubuntu, debian, fedora, arch") + version: str = Field(min_length=1, description="os-release's VERSION_ID, or rolling") + display: Literal["x11", "wayland"] + build: str | None = None + + +class Session(BaseModel): + """The user a desktop session runs as, and so every driver and app added to it.""" + + model_config = ConfigDict(extra="forbid") + + user: str = Field(pattern=r"^[a-z_][a-z0-9_-]{0,31}$") + + +class Setting(BaseModel): + """A value the computer reads, which an env or a deploy can override: ``build`` when it changes the image, ``deploy`` + when the computer reads it as it starts; ``user`` when it names the user's own setup, such as a secret's reference.""" + + model_config = ConfigDict(extra="forbid") + + default: str | int | float | bool + phase: Literal["build", "deploy"] + scope: Literal["task", "user"] = "task" + secret: bool = False + generate: bool = Field(default=False, description="minted afresh for every deploy") + + @model_validator(mode="after") + def _phase_fits(self) -> Self: + if self.phase == "build" and self.scope != "task": + raise ValueError("a build-phase setting is task-scoped, so one env version always composes one image") + if self.generate and self.phase != "deploy": + raise ValueError("a generated setting is minted for every deploy, so its phase is deploy") + return self + + +class ContainerForm(BaseModel): + """The computer as an OCI image a container runtime runs: ``init`` starts its programs, and deploy settings reach + them through ``input_channel``.""" + + model_config = ConfigDict(extra="forbid") + + image: ArtifactRef + init: Literal["supervisord"] + input_channel: Literal["env"] + + +class Forms(BaseModel): + model_config = ConfigDict(extra="forbid") + + container: ContainerForm + + +class ComputerArtifact(Artifact): + """A base computer, which composing adds drivers and apps to.""" + + model_config = ConfigDict(extra="forbid") + + type: Literal["computer"] = "computer" + description: str = Field(default="", description="What the computer is") + arch: list[Literal["amd64", "arm64"]] = Field(min_length=1, description="The architectures every form runs on") + os: LinuxOS + session: Session + forms: Forms + config: dict[str, Setting] = Field(default_factory=dict, description="Its settings, by name") + + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("forms.container.image", + artifact_type="docker_image"),) + toml_keys: ClassVar[dict[str, type]] = {"description": str, "arch": list, "os": dict, "session": dict, + "forms": dict, "config": dict} + + @field_validator("os", mode="before") + @classmethod + def _os_type_supported(cls, value: Any) -> Any: + if isinstance(value, dict) and value.get("type", "linux") not in OS_TYPES: + raise ValueError(f"os.type {value['type']!r} isn't supported yet; a computer is {', '.join(OS_TYPES)}") + return value + + @field_validator("forms", mode="before") + @classmethod + def _forms_supported(cls, value: Any) -> Any: + if isinstance(value, dict) and (others := sorted(set(value) - set(FORMS))): + raise ValueError(f"the {', '.join(others)} form{'s' * (len(others) > 1)} isn't supported yet; a computer has " + f"{', '.join(FORMS)}") + return value + + @field_validator("arch") + @classmethod + def _arch_once(cls, value: list[str]) -> list[str]: + if len(set(value)) != len(value): + raise ValueError(f"lists an architecture twice: {value}") + return value + + @field_validator("config") + @classmethod + def _setting_names(cls, value: dict[str, Setting]) -> dict[str, Setting]: + if bad := sorted(name for name in value if not re.fullmatch(r"[a-z][a-z0-9_]*", name)): + raise ValueError(f"setting names are lowercase letters, digits and _, since each reaches the computer as " + f"AGENTENV_SETTING_: {', '.join(map(repr, bad))}") + return value + + @classmethod + def put(cls, *, inspect: bool = True, **fields: Any) -> Self: + """Write a computer, reading each form's image first unless ``inspect`` is False. Refused, naming every + problem by form and architecture, when an image isn't what the computer declares.""" + from agent_env.artifact.store import get_artifact_store + + computer = cls(**fields) + found = computer.inspect() if inspect else {"container": _unread(computer.arch, "inspection was skipped")} + problems = [f"forms.{form} ({arch}): {result['problem']}" + for form, by_arch in found.items() for arch, result in by_arch.items() if result["status"] == "failed"] + if problems: + raise ValueError(f"computer {computer.id!r}: " + "; ".join(problems)) + computer.metadata = {**computer.metadata, "validated_images": found} + return get_artifact_store().put_document(computer) + + @classmethod + def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self: + """Write the computer an artifact.toml declares, its form's image as the bundle wrote it or the artifact the + key names.""" + fields = {key: value for key, value in data.items() if key not in ("id", "type", "version")} + container = (fields.get("forms") or {}).get("container") + if isinstance(container, dict) and "image" in container: + image = ctx.artifact(container["image"]) + container["image"] = {"id": image.id, "version": image.version} + try: + return cls.put(id=ctx.id, **fields) + except ValidationError as e: + ctx.refuse([ctx.config_problem(f"{'.'.join(map(str, error['loc']))}: {error['msg']}") for error in e.errors()]) + except ValueError as e: + ctx.refuse([str(e)]) + + def inspect(self) -> dict[str, dict[str, dict[str, Any]]]: + """What each form's image holds for each architecture, read from its registry: ``passed``, ``failed`` with the + problem, or ``uninspected`` with why, for an image no registry serves yet.""" + image = DockerImageArtifact.get(self.forms.container.image.id, self.forms.container.image.version) + return {"container": _inspect_container(image, self.arch, self.os)} + + +def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxOS) -> dict[str, dict[str, Any]]: + from agent_env.config import get_config + + if not names_registry(image.image_name): + how = "only its build context is stored" if image.context_only else "it's stored under a tag no registry serves" + return _unread(arches, f"{how}, so its first boot checks it") + ref = image.pinned_name + auth = get_config().get_image_store().auth(ref) + try: + manifests = _platform_manifests(ref, auth) + except ValueError as e: + return {arch: _result("failed", ref, problem=str(e)) for arch in arches} + results = {} + for arch in arches: + if (digest := manifests.get(arch)) is None: + results[arch] = _result("failed", ref, problem=f"it has no linux/{arch} image, only " + f"{', '.join(f'linux/{a}' for a in sorted(manifests)) or 'none'}") + continue + try: + release = _os_release(ref, digest, auth) + except (ValueError, OSError, tarfile.TarError) as e: + results[arch] = _result("failed", ref, manifest=digest, problem=f"its os-release couldn't be read: {e}") + continue + expected = {"ID": os.distribution, "VERSION_ID": None if os.version == "rolling" else os.version} + problems = [f"os-release has {key}={release.get(key)!r}, not {want!r}" for key, want in expected.items() + if release.get(key) != want] + results[arch] = _result("failed" if problems else "passed", ref, manifest=digest, + os_release={key: release[key] for key in expected if key in release}, + problem="; ".join(problems) or None) + return results + + +def _platform_manifests(ref: str, auth) -> dict[str, str]: + """Each Linux architecture's image manifest digest: an index's, or a single image's own by its config.""" + top = read_manifest(ref, auth) + if top.get("mediaType") in _INDEX_TYPES or "manifests" in top: + return {entry["platform"]["architecture"]: entry["digest"] for entry in top.get("manifests") or () + if (entry.get("platform") or {}).get("os") == "linux"} + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config" + read_blob(ref, top["config"]["digest"], auth, path) + config = json.loads(path.read_text()) + return {config["architecture"]: ref.rpartition("@")[2]} if config.get("os") == "linux" else {} + + +def _os_release(ref: str, digest: str, auth) -> dict[str, str]: + """The os-release of the image manifest ``digest``, from the lowest layer that holds it, normally the OS's own, so + only that layer is fetched.""" + name = ref.partition("@")[0] + manifest = read_manifest(f"{name}@{digest}", auth) + found: dict[str, bytes | str] = {} + with tempfile.TemporaryDirectory() as tmp: + for layer in manifest.get("layers") or (): + path = Path(tmp) / layer["digest"].replace(":", "-") + read_blob(ref, layer["digest"], auth, path) + with tarfile.open(path, "r:*") as tar: + for member in tar: + member_name = member.name.removeprefix("./") + if member_name in _OS_RELEASE: + if member.issym(): + found[member_name] = member.linkname + elif (extracted := tar.extractfile(member)) is not None: + found[member_name] = extracted.read() + path.unlink() + if (text := _resolved(found)) is not None: + return _parse_os_release(text) + raise ValueError("no layer holds /etc/os-release or /usr/lib/os-release") + + +def _resolved(found: dict[str, bytes | str]) -> str | None: + for name in _OS_RELEASE: + value = found.get(name) + if isinstance(value, str): # a link, to a path in the image + target = PurePosixPath(value) if value.startswith("/") else PurePosixPath("/", name).parent / value + value = found.get(_normalized(target)) + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return None + + +def _normalized(path: PurePosixPath) -> str: + parts: list[str] = [] + for part in path.parts[1:]: + if part == "..": + parts = parts[:-1] + elif part not in ("", "."): + parts.append(part) + return "/".join(parts) + + +def _parse_os_release(text: str) -> dict[str, str]: + release = {} + for line in text.splitlines(): + key, sep, value = line.strip().partition("=") + if sep and key and not key.startswith("#"): + release[key] = value.strip().strip("\"'") + return release + + +def _result(status: str, image: str | None, **found: Any) -> dict[str, Any]: + return {"status": status, **({"image": image} if image else {}), + **{key: value for key, value in found.items() if value is not None}, + "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds")} + + +def _unread(arches: list[str], why: str) -> dict[str, dict[str, Any]]: + return {arch: _result("uninspected", None, reason=why) for arch in arches} diff --git a/src/agent_env/artifact/registry.py b/src/agent_env/artifact/registry.py index b53581f25..dc2fef24b 100644 --- a/src/agent_env/artifact/registry.py +++ b/src/agent_env/artifact/registry.py @@ -12,6 +12,7 @@ from agent_env.plugins import _registration, _report from agent_env.artifact.artifact import Artifact from agent_env.artifact.artifacts.cli import CliArtifact +from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse @@ -31,6 +32,7 @@ def _get_type(cls: type[Artifact]) -> Any: ARTIFACT_REGISTRY: dict[str, type[Artifact]] = { _get_type(CliArtifact): CliArtifact, + _get_type(ComputerArtifact): ComputerArtifact, _get_type(FileArtifact): FileArtifact, _get_type(FileArtifactUniverse): FileArtifactUniverse, _get_type(DockerImageArtifact): DockerImageArtifact, diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index 68c988796..6face9809 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -293,13 +293,19 @@ def _toml_class(self, entry: BundleEntry) -> type: return cls def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: list[Reference]) -> bool: - """Record the image an image key has the bundle write: the folder's Dockerfile when ``image`` is left out, - ``{ dockerfile = "" }``, or a registry image, ``{ ref = "" }``. False when the key names an image - artifact instead.""" - if ref.artifact_type != "docker_image" or ref.path != ref.field: + """Record the image an image key has the bundle write: the folder's Dockerfile when a top-level ``image`` is + left out, ``{ dockerfile = "" }``, or a registry image, ``{ ref = "" }``. A key in a table, such as + a computer's ``forms.container.image``, says which. False when the key names an image artifact instead.""" + if ref.artifact_type != "docker_image" or "[]" in ref.path: return False - value = config.get(ref.path) - role = f"{entry.kind.store}_image" if ref.path == "image" else ref.path + *tables, key = ref.path.split(".") + owner = config + for table in tables: + owner = owner.get(table) if isinstance(owner, dict) else None + if not isinstance(owner, dict) or (tables and key not in owner): + return False + value = owner.get(key) + role = f"{entry.kind.store}_image" if ref.path == "image" else ref.path.replace(".", "_") if isinstance(value, dict) and "ref" in value: name = value["ref"] if value.keys() != {"ref"}: @@ -308,7 +314,7 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l elif problem := _registry_problem(ref.path, name): self._problem(entry, problem) else: - self._implied(ReferencedImage(derive_id(entry.id, role), entry, name), config, ref, references) + self._implied(ReferencedImage(derive_id(entry.id, role), entry, name), owner, key, ref, references) return True if value is None: dockerfile = default_dockerfile(ref.path) @@ -325,11 +331,11 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l if not (inside and path.is_file()): self._problem(entry, f"{ref.path}: there is no {dockerfile!r} in this folder to build") return True - self._implied(BuiltImage(derive_id(entry.id, role), entry, dockerfile), config, ref, references) + self._implied(BuiltImage(derive_id(entry.id, role), entry, dockerfile), owner, key, ref, references) return True - def _implied(self, image: BundleImage, config: dict, ref: EntityRef, references: list[Reference]) -> None: - config[ref.path] = image.id + def _implied(self, image: BundleImage, owner: dict, key: str, ref: EntityRef, references: list[Reference]) -> None: + owner[key] = image.id self.images.append(image) references.append(Reference(EntityKind.ARTIFACT, image.id, None, image, ref.path, ref.artifact_type)) diff --git a/src/agent_env/store/image_store/registry_api.py b/src/agent_env/store/image_store/registry_api.py index 403c7e5c0..467dfb741 100644 --- a/src/agent_env/store/image_store/registry_api.py +++ b/src/agent_env/store/image_store/registry_api.py @@ -1,10 +1,14 @@ -"""Read an image's digest from its registry over the OCI distribution API, to pin a tag to the image it names now.""" +"""Read an image from its registry over the OCI distribution API: its digest, to pin a tag to the image it names now, +and its manifests and blobs, to inspect what it holds without pulling it.""" from __future__ import annotations +import contextlib import hashlib import re +from collections.abc import Iterator from dataclasses import dataclass +from pathlib import Path import httpx @@ -144,3 +148,99 @@ def _bearer_token(client: httpx.Client, challenged: httpx.Response, basic: tuple if not isinstance(body, dict) or not (token := body.get("token") or body.get("access_token")): raise ValueError(f"{what}: the token service at {realm} answered without a token") return token + + +def read_manifest(ref: str, auth: RegistryAuth | None, *, timeout: float = _TIMEOUT_SECONDS) -> dict: + """The manifest the registry serves for ``ref``, by its digest when it gives one, else its tag: an index of one + manifest per platform, or a single image's manifest, as its ``mediaType`` says. Raises as ``pin_digest`` does.""" + image = _parse(ref) + with _Repository(image, auth, timeout, ref) as repository: + response = repository.get(f"manifests/{image.digest or image.tag or 'latest'}", accept=_MANIFEST_TYPES) + try: + return response.json() + except ValueError as e: + raise ValueError(f"{ref}: {image.host} answered with a manifest that isn't JSON") from e + + +def read_blob(ref: str, digest: str, auth: RegistryAuth | None, path: Path, *, + timeout: float = _TIMEOUT_SECONDS) -> None: + """Download the blob ``digest`` from ``ref``'s repository to ``path``, refusing one whose bytes don't hash to it.""" + image = _parse(ref) + if not digest.startswith("sha256:") or not _DIGEST.match(digest): + raise ValueError(f"{ref}: {digest!r} isn't a sha256 digest") + with _Repository(image, auth, timeout, ref) as repository: + hashed = hashlib.sha256() + try: + with repository.stream(f"blobs/{digest}") as response, path.open("wb") as out: + for chunk in response.iter_bytes(): + hashed.update(chunk) + out.write(chunk) + except httpx.HTTPError as e: + raise ValueError(f"{ref}: reading the blob {digest} from {image.host} failed: {type(e).__name__}: {e}") from e + if f"sha256:{hashed.hexdigest()}" != digest: + path.unlink(missing_ok=True) + raise ValueError(f"{ref}: the blob {image.host} served for {digest} hashes to sha256:{hashed.hexdigest()}") + + +class _Repository: + """One image's repository on its registry, read with the Bearer token the registry's first challenge asks for.""" + + def __init__(self, image: _ImageRef, auth: RegistryAuth | None, timeout: float, what: str) -> None: + scheme = "http" if is_loopback_host(image.host) else "https" + self._base = f"{scheme}://{image.host}/v2/{image.repository}" + self._basic = (auth.username, auth.password) if auth is not None else None + self._token: str | None = None + self._host, self._what = image.host, what + self._client = httpx.Client(timeout=timeout, follow_redirects=True) + + def __enter__(self) -> _Repository: + return self + + def __exit__(self, *_exc) -> None: + self._client.close() + + def get(self, path: str, *, accept: str | None = None) -> httpx.Response: + response = self._send(path, accept, stream=False) + self._raise_for(response) + return response + + @contextlib.contextmanager + def stream(self, path: str) -> Iterator[httpx.Response]: + """``path`` as a streamed response, closed when the block ends.""" + response = self._send(path, None, stream=True) + try: + if response.status_code != 200: + response.read() + self._raise_for(response) + yield response + finally: + response.close() + + def _send(self, path: str, accept: str | None, *, stream: bool) -> httpx.Response: + try: + for _ in range(2): + headers = {"Accept": accept} if accept else {} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + request = self._client.build_request("GET", f"{self._base}/{path}", headers=headers) + response = self._client.send(request, auth=None if self._token else self._basic, stream=stream) + if response.status_code != 401 or self._token: + return response + if stream: + response.read() + response.close() + if (token := _bearer_token(self._client, response, self._basic, self._what)) is None: + return response + self._token = token + return response + except httpx.HTTPError as e: + raise ValueError(f"{self._what}: couldn't read {path} from {self._host}: {type(e).__name__}: {e}") from e + + def _raise_for(self, response: httpx.Response) -> None: + if response.status_code == 200: + return + if response.status_code == 404: + raise NoSuchImageError(f"{self._what}: {self._host} has no such image") + if response.status_code in (401, 403): + raise RegistryRefusedError(f"{self._what}: {self._host} refused to serve it (HTTP {response.status_code})") + raise ValueError(f"{self._what}: {self._host} answered HTTP {response.status_code}") diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py new file mode 100644 index 000000000..245a2a2b5 --- /dev/null +++ b/tst/unit/artifact/test_computer_artifact.py @@ -0,0 +1,191 @@ +"""The computer artifact: what it declares is checked, each form's image is read from its registry before it's written +(its architectures, and the OS its lowest os-release layer names), and a mismatch refuses the put naming its form and +architecture. An image no registry serves yet is written uninspected.""" + +import hashlib +import io +import json +import tarfile + +import pytest + +from agent_env.artifact import Artifact +from agent_env.artifact.artifacts import computer as computer_module +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.store.image_store.registry_api import NoSuchImageError + +UBUNTU = b'PRETTY_NAME="Ubuntu 24.04.3 LTS"\nNAME="Ubuntu"\nVERSION_ID="24.04"\nID=ubuntu\n' +INDEX = "application/vnd.oci.image.index.v1+json" +MANIFEST = "application/vnd.oci.image.manifest.v1+json" + + +class Registry: + """A registry serving the manifests and blobs put in it, by digest, and recording the blobs fetched.""" + + def __init__(self): + self.manifests, self.blobs, self.fetched = {}, {}, [] + + def blob(self, data: bytes) -> str: + digest = f"sha256:{hashlib.sha256(data).hexdigest()}" + self.blobs[digest] = data + return digest + + def image(self, *layers: bytes, arch: str = "amd64") -> str: + config = self.blob(json.dumps({"os": "linux", "architecture": arch}).encode()) + manifest = {"mediaType": MANIFEST, "config": {"digest": config}, + "layers": [{"digest": self.blob(layer)} for layer in layers]} + return self._manifest(manifest) + + def index(self, **images: str) -> str: + entries = [{"digest": digest, "platform": {"os": "linux", "architecture": arch}} for arch, digest in images.items()] + entries.append({"digest": self.blob(b"attestation"), "platform": {"os": "unknown", "architecture": "unknown"}}) + return self._manifest({"mediaType": INDEX, "manifests": entries}) + + def _manifest(self, manifest: dict) -> str: + digest = self.blob(json.dumps(manifest).encode()) + self.manifests[digest] = manifest + return digest + + def read_manifest(self, ref, auth): + digest = ref.rpartition("@")[2] + if digest not in self.manifests: + raise NoSuchImageError(f"{ref}: ghcr.io has no such image") + return self.manifests[digest] + + def read_blob(self, ref, digest, auth, path): + self.fetched.append(digest) + path.write_bytes(self.blobs[digest]) + + +def layer(files: dict[str, bytes | str]) -> bytes: + """A tar.gz layer of ``files`` by path; a str is a link to that path.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for name, content in files.items(): + info = tarfile.TarInfo(name) + if isinstance(content, str): + info.type, info.linkname = tarfile.SYMTYPE, content + tar.addfile(info) + else: + info.size = len(content) + tar.addfile(info, io.BytesIO(content)) + return buffer.getvalue() + + +UBUNTU_LAYER = layer({"etc/os-release": "../usr/lib/os-release", "usr/lib/os-release": UBUNTU}) + + +@pytest.fixture +def registry(local_stores, monkeypatch): + registry = Registry() + monkeypatch.setattr(computer_module, "read_manifest", registry.read_manifest) + monkeypatch.setattr(computer_module, "read_blob", registry.read_blob) + return registry + + +def _image(name: str) -> DockerImageArtifact: + return get_artifact_store().put_document(DockerImageArtifact(id="desktop-image", description="", image_name=name)) + + +def _put(image: DockerImageArtifact, **changes) -> ComputerArtifact: + fields = {"id": "desktop", "description": "a desktop", "arch": ["amd64", "arm64"], + "os": {"type": "linux", "distribution": "ubuntu", "version": "24.04", "display": "x11"}, + "session": {"user": "user"}, + "forms": {"container": {"image": {"id": image.id, "version": image.version}, "init": "supervisord", + "input_channel": "env"}}, + "config": {"resolution": {"default": "1280x800", "phase": "deploy"}}} + return ComputerArtifact.put(**{**fields, **changes}) + + +def test_a_computer_whose_image_is_what_it_declares_is_written_with_what_was_read(registry): + amd64, arm64 = registry.image(UBUNTU_LAYER), registry.image(UBUNTU_LAYER, arch="arm64") + image = _image(f"ghcr.io/acme/desktop:1@{registry.index(amd64=amd64, arm64=arm64)}") + + computer = _put(image) + + found = Artifact.get("desktop", computer.version).metadata["validated_images"]["container"] + assert {arch: (found[arch]["status"], found[arch]["manifest"], found[arch]["os_release"]) for arch in found} == { + "amd64": ("passed", amd64, {"ID": "ubuntu", "VERSION_ID": "24.04"}), + "arm64": ("passed", arm64, {"ID": "ubuntu", "VERSION_ID": "24.04"})} + assert found["amd64"]["image"] == image.image_name + assert computer.forms.container.image.version == image.version + + +@pytest.mark.parametrize("served, changes, problem", [ + ("amd64-only", {}, "forms.container (arm64): it has no linux/arm64 image, only linux/amd64"), + ("both", {"os": {"type": "linux", "distribution": "ubuntu", "version": "22.04", "display": "x11"}}, + "os-release has VERSION_ID='24.04', not '22.04'"), + ("both", {"os": {"type": "linux", "distribution": "debian", "version": "24.04", "display": "x11"}}, + "os-release has ID='ubuntu', not 'debian'"), + ("nothing", {}, "forms.container (amd64): ghcr.io/acme/desktop:1@sha256:"), +], ids=["missing-arch", "wrong-version", "wrong-distribution", "unreachable"]) +def test_an_image_that_isnt_what_the_computer_declares_refuses_the_put(registry, served, changes, problem): + amd64 = registry.image(UBUNTU_LAYER) + digest = {"amd64-only": registry.index(amd64=amd64), + "both": registry.index(amd64=amd64, arm64=registry.image(UBUNTU_LAYER, arch="arm64")), + "nothing": "sha256:" + "0" * 64}[served] + + with pytest.raises(ValueError, match="computer 'desktop'") as refused: + _put(_image(f"ghcr.io/acme/desktop:1@{digest}"), **changes) + + assert problem in str(refused.value) + assert get_artifact_store().next_version("desktop") == 1 + + +def test_os_release_comes_from_the_lowest_layer_holding_it_and_a_single_image_is_read_by_its_config(registry): + desktop = layer({"etc/motd": b"welcome\n", "etc/os-release": b"ID=xfce\n"}) + single = registry.image(UBUNTU_LAYER, desktop) + + computer = _put(_image(f"ghcr.io/acme/desktop:1@{single}"), arch=["amd64"]) + + assert computer.metadata["validated_images"]["container"]["amd64"]["status"] == "passed" + manifest = registry.manifests[single] + assert manifest["layers"][1]["digest"] not in registry.fetched + + +def test_an_image_no_registry_serves_yet_or_a_skipped_inspection_is_written_uninspected(registry): + built_here = get_artifact_store().put_document(DockerImageArtifact( + id="desktop-image", description="", image_name="local/desktop-1234:abcd", + build_context_object_url="file:///context.tar.gz", dockerfile_path="container/Dockerfile")) + + from_context = _put(built_here, arch=["amd64"]) + skipped = ComputerArtifact.put(inspect=False, **{**from_context.model_dump(exclude={"version", "metadata"})}) + + assert from_context.metadata["validated_images"]["container"]["amd64"]["status"] == "uninspected" + assert "only its build context is stored" in from_context.metadata["validated_images"]["container"]["amd64"]["reason"] + assert skipped.metadata["validated_images"]["container"]["amd64"]["reason"] == "inspection was skipped" + assert registry.fetched == [] + + +@pytest.mark.parametrize("changes, message", [ + ({"os": {"type": "windows", "version": "11"}}, "os.type 'windows' isn't supported yet"), + ({"forms": {"disk": {}}}, "the disk form isn't supported yet"), + ({"arch": ["amd64", "amd64"]}, "lists an architecture twice"), + ({"arch": ["riscv64"]}, "arch.0"), + ({"session": {"user": "Root User"}}, "session.user"), + ({"config": {"Screen-Size": {"default": "1", "phase": "deploy"}}}, "setting names are lowercase"), + ({"config": {"theme": {"default": "dark", "phase": "build", "scope": "user"}}}, "a build-phase setting is task-scoped"), + ({"config": {"token": {"default": "", "phase": "build", "generate": True}}}, "a generated setting is minted"), + ({"ports": [8080]}, "ports"), +], ids=["os-type", "form", "arch-twice", "arch-unknown", "user", "setting-name", "build-user-scope", "build-generate", + "unknown-key"]) +def test_what_a_computer_declares_is_checked(registry, changes, message): + image = _image(f"ghcr.io/acme/desktop:1@{registry.index(amd64=registry.image(UBUNTU_LAYER))}") + with pytest.raises(ValueError, match=message): + _put(image, **changes) + + +def test_an_absolute_link_is_followed_and_an_image_without_os_release_or_not_linux_fails(registry): + linked = registry.image(layer({"etc/os-release": "/usr/lib/os-release", "usr/lib/os-release": UBUNTU})) + bare = registry.image(layer({"etc/motd": b"hi\n"})) + windows = registry._manifest({"mediaType": MANIFEST, "layers": [], + "config": {"digest": registry.blob(b'{"os": "windows", "architecture": "amd64"}')}}) + + assert _put(_image(f"ghcr.io/acme/desktop:1@{linked}"), arch=["amd64"]).metadata["validated_images"][ + "container"]["amd64"]["status"] == "passed" + with pytest.raises(ValueError, match="no layer holds /etc/os-release or /usr/lib/os-release"): + _put(_image(f"ghcr.io/acme/desktop:2@{bare}"), arch=["amd64"]) + with pytest.raises(ValueError, match="it has no linux/amd64 image, only none"): + _put(_image(f"ghcr.io/acme/desktop:3@{windows}"), arch=["amd64"]) diff --git a/tst/unit/bundle/computer_folder_test.py b/tst/unit/bundle/computer_folder_test.py new file mode 100644 index 000000000..c345ffbb9 --- /dev/null +++ b/tst/unit/bundle/computer_folder_test.py @@ -0,0 +1,133 @@ +"""Computer folders: an ``artifacts//`` computer's ``forms.container.image`` key has the bundle write the image it +names, built from a Dockerfile in the folder or pinned from a registry, and the computer is written over that image's +version, inspected when a registry serves it.""" + +import hashlib +import io +import json +import tarfile + +import pytest + +from agent_env.artifact.artifacts import computer as computer_module +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.bundle.parse import BundleError +from agent_env.bundle.materialize import materialize +from tst.unit.bundle._support import DIGEST, layout, local_builds, plan_of + +ROOT = "@local/~/desk" +COMPUTER = f"{ROOT}/desktop" +IMAGE = f"{COMPUTER}__forms_container_image" +CARD = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="crm")\nclass Server:\n pass\n' +OS = '{ type = "linux", distribution = "ubuntu", version = "24.04", display = "x11" }' + + +def _files(image: str, os: str = OS) -> dict[str, str]: + return { + "artifacts/desktop/artifact.toml": ( + f'type = "computer"\ndescription = "a desktop"\narch = ["amd64"]\nos = {os}\nsession = {{ user = "user" }}\n\n' + f'[forms.container]\nimage = {image}\ninit = "supervisord"\ninput_channel = "env"\n'), + "artifacts/desktop/container/Dockerfile": "FROM scratch\nCOPY container/start /start\n", + "artifacts/desktop/container/start": "#!/bin/sh\n", + "envs/crm/Dockerfile": "FROM scratch\nCOPY . /app\n", + "envs/crm/server.py": CARD, + "envs/crm/env.toml": 'environment_name = "crm"\n', + "tasks/t.json": json.dumps([{"id": "crm", "type": "deploy_env", "env_id": "crm"}, + {"id": "load", "type": "load_artifact", "env_id": "crm", "artifact_id": "desktop"}]), + } + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "desk" + + +def _run(root): + plan = plan_of(root) + return materialize(plan, build_here=local_builds(plan)) + + +def _computer(done) -> ComputerArtifact: + return ComputerArtifact.get(COMPUTER, done.version_of("artifact", COMPUTER)) + + +def test_a_computer_folder_builds_its_image_and_is_written_over_it_uninspected_until_a_registry_serves_it( + bundle_dir, builds): + layout(bundle_dir, _files('{ dockerfile = "container/Dockerfile" }')) + + done = _run(bundle_dir) + + computer = _computer(done) + image = DockerImageArtifact.get(IMAGE) + assert (computer.forms.container.image.id, computer.forms.container.image.version) == (IMAGE, image.version) + assert image.dockerfile_path == "container/Dockerfile" and image.context_only + built = next(call["build"] for call in builds if call["build"][0] == "container/Dockerfile") + assert built[1] == ["artifact.toml", "container/Dockerfile", "container/start"] + found = computer.metadata["validated_images"]["container"]["amd64"] + assert found["status"] == "uninspected" and "only its build context is stored" in found["reason"] + assert _computer(_run(bundle_dir)).version == computer.version + + +def test_a_computer_naming_a_registry_image_is_written_over_its_pin_and_inspected(bundle_dir, builds, registry, + monkeypatch): + blobs = {} + + def blob(data: bytes) -> str: + digest = f"sha256:{hashlib.sha256(data).hexdigest()}" + blobs[digest] = data + return digest + + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + release = b"ID=ubuntu\nVERSION_ID=\"24.04\"\n" + info = tarfile.TarInfo("etc/os-release") + info.size = len(release) + tar.addfile(info, io.BytesIO(release)) + image_manifest = {"layers": [{"digest": blob(buffer.getvalue())}], "config": {"digest": blob(b"{}")}} + amd64 = blob(json.dumps(image_manifest).encode()) + manifests = {DIGEST: {"manifests": [{"digest": amd64, "platform": {"os": "linux", "architecture": "amd64"}}]}, + amd64: image_manifest} + monkeypatch.setattr(computer_module, "read_manifest", lambda ref, auth: manifests[ref.rpartition("@")[2]]) + monkeypatch.setattr(computer_module, "read_blob", lambda ref, digest, auth, path: path.write_bytes(blobs[digest])) + layout(bundle_dir, _files('{ ref = "ghcr.io/acme/desktop:1" }')) + + done = _run(bundle_dir) + + found = _computer(done).metadata["validated_images"]["container"]["amd64"] + assert DockerImageArtifact.get(IMAGE).image_name == f"ghcr.io/acme/desktop:1@{DIGEST}" + assert (found["status"], found["manifest"], found["os_release"]) == ( + "passed", amd64, {"ID": "ubuntu", "VERSION_ID": "24.04"}) + assert "ghcr.io/acme/desktop:1" in registry + + +def test_a_computer_folder_that_declares_what_isnt_supported_is_refused_naming_the_folder(bundle_dir, builds): + layout(bundle_dir, _files('{ dockerfile = "container/Dockerfile" }', os='{ type = "windows", version = "11" }')) + + with pytest.raises(BundleError) as refused: + _run(bundle_dir) + + assert "artifacts/desktop: artifact.toml: os: Value error, os.type 'windows' isn't supported yet" in str(refused.value) + + +def test_a_registry_image_that_isnt_what_the_computer_declares_is_refused_naming_the_folder(bundle_dir, builds, + registry, monkeypatch): + monkeypatch.setattr(computer_module, "read_manifest", lambda ref, auth: {"manifests": []}) + layout(bundle_dir, _files('{ ref = "ghcr.io/acme/desktop:1" }')) + + with pytest.raises(BundleError) as refused: + _run(bundle_dir) + + assert ("artifacts/desktop: computer '@local/~/desk/desktop': forms.container (amd64): it has no linux/amd64 image" + in str(refused.value)) + + +def test_an_image_key_naming_an_artifact_that_isnt_an_image_is_refused(bundle_dir, builds): + layout(bundle_dir, {**_files('"notes"'), "artifacts/notes/notes.txt": "a note\n"}) + + with pytest.raises(BundleError) as refused: + _run(bundle_dir) + + assert "artifacts/desktop: forms.container.image: 'notes' is this bundle's file, but this field takes docker_image" in str( + refused.value) diff --git a/tst/unit/serialization/golden/artifact/computer.json b/tst/unit/serialization/golden/artifact/computer.json new file mode 100644 index 000000000..98fc3a625 --- /dev/null +++ b/tst/unit/serialization/golden/artifact/computer.json @@ -0,0 +1,49 @@ +{ + "arch": [ + "amd64", + "arm64" + ], + "config": { + "resolution": { + "default": "1280x800", + "generate": false, + "phase": "deploy", + "scope": "task", + "secret": false + } + }, + "description": "Ubuntu 24.04 with XFCE on Xvfb", + "forms": { + "container": { + "image": { + "id": "ubuntu-24.04__forms_container_image", + "version": 1 + }, + "init": "supervisord", + "input_channel": "env" + } + }, + "id": "ubuntu-24.04", + "metadata": { + "validated_images": { + "container": { + "amd64": { + "checked_at": "2026-10-27T18:00:00+00:00", + "status": "passed" + } + } + } + }, + "os": { + "build": null, + "display": "x11", + "distribution": "ubuntu", + "type": "linux", + "version": "24.04" + }, + "session": { + "user": "user" + }, + "type": "computer", + "version": 2 +} diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index e606c7eb8..f37c89081 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -29,6 +29,7 @@ import pytest from agent_env.artifact.artifacts.cli import CliArtifact +from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse @@ -95,6 +96,18 @@ def _website_env() -> WebsiteEnv: env_id="slack-env", env_version=2, ), + "computer": lambda: ComputerArtifact( + id="ubuntu-24.04", + version=2, + description="Ubuntu 24.04 with XFCE on Xvfb", + arch=["amd64", "arm64"], + os={"type": "linux", "distribution": "ubuntu", "version": "24.04", "display": "x11"}, + session={"user": "user"}, + forms={"container": {"image": ArtifactRef(id="ubuntu-24.04__forms_container_image", version=1), + "init": "supervisord", "input_channel": "env"}}, + config={"resolution": {"default": "1280x800", "phase": "deploy"}}, + metadata={"validated_images": {"container": {"amd64": {"status": "passed", "checked_at": "2026-10-27T18:00:00+00:00"}}}}, + ), "docker_image": lambda: _docker_image("slack-mcp-image", 5, "slack-mcp:v5"), "file": lambda: FileArtifact( id="slack-universe-data", diff --git a/tst/unit/store/registry_api_test.py b/tst/unit/store/registry_api_test.py index 91d7fff20..75feaa6e4 100644 --- a/tst/unit/store/registry_api_test.py +++ b/tst/unit/store/registry_api_test.py @@ -1,4 +1,5 @@ -"""Pinning an image ref to the digest its registry serves, over a fake registry's OCI distribution API.""" +"""Pinning an image ref to the digest its registry serves, and reading its manifests and blobs, over a fake +registry's OCI distribution API.""" import base64 import hashlib @@ -8,7 +9,14 @@ from agent_env.store import RegistryAuth from agent_env.store.image_store import registry_api -from agent_env.store.image_store.registry_api import NoSuchImageError, RegistryRefusedError, pin_digest, pinned_from +from agent_env.store.image_store.registry_api import ( + NoSuchImageError, + RegistryRefusedError, + pin_digest, + pinned_from, + read_blob, + read_manifest, +) DIGEST = "sha256:" + "a" * 64 OTHER = "sha256:" + "b" * 64 @@ -209,3 +217,58 @@ def test_a_digest_docker_couldnt_pull_by_is_refused_before_any_request(fake_regi ], ids=["tag", "latest", "loopback port", "digest", "another tag", "another name", "another digest", "unpinned"]) def test_a_pin_pins_the_ref_it_was_pinned_from(pinned, ref, pins): assert pinned_from(pinned, ref) is pins + + +def _ghcr(served): + """GHCR's anonymous token dance in front of ``served``, a handler for requests that carry the token.""" + def handler(request): + if request.url.path == "/token": + return httpx.Response(200, json={"token": "anon"}) + if request.headers.get("authorization") == "Bearer anon": + return served(request) + return httpx.Response(401, headers={"WWW-Authenticate": 'Bearer realm="https://ghcr.io/token",service="ghcr.io",' + 'scope="repository:org/desk:pull"'}) + return handler + + +def test_a_manifest_and_a_blob_are_read_with_the_registrys_token_and_the_blob_checked(fake_registry, tmp_path): + layer = b"layer bytes" + digest = f"sha256:{hashlib.sha256(layer).hexdigest()}" + index = {"mediaType": "application/vnd.oci.image.index.v1+json", "manifests": []} + + def served(request): + if request.url.path.endswith(f"/manifests/{DIGEST}"): + return httpx.Response(200, json=index) + if request.url.path.endswith(f"/blobs/{digest}"): + return httpx.Response(200, content=layer) + return httpx.Response(404) + + seen = fake_registry(_ghcr(served)) + + assert read_manifest(f"ghcr.io/org/desk:1@{DIGEST}", None) == index + read_blob(f"ghcr.io/org/desk:1@{DIGEST}", digest, None, tmp_path / "layer") + assert (tmp_path / "layer").read_bytes() == layer + assert [str(r.url).split("?")[0] for r in seen] == [ + f"https://ghcr.io/v2/org/desk/manifests/{DIGEST}", "https://ghcr.io/token", + f"https://ghcr.io/v2/org/desk/manifests/{DIGEST}", + f"https://ghcr.io/v2/org/desk/blobs/{digest}", "https://ghcr.io/token", f"https://ghcr.io/v2/org/desk/blobs/{digest}"] + assert "application/vnd.oci.image.index.v1+json" in seen[0].headers["accept"] + + +def test_a_blob_that_doesnt_hash_to_its_digest_is_refused_and_not_kept(fake_registry, tmp_path): + fake_registry(lambda request: httpx.Response(200, content=b"something else")) + + with pytest.raises(ValueError, match="hashes to sha256:"): + read_blob("localhost:5000/desk:1", DIGEST, None, tmp_path / "layer") + assert not (tmp_path / "layer").exists() + + +@pytest.mark.parametrize("status, error", [(404, NoSuchImageError), (403, RegistryRefusedError), (500, ValueError)]) +def test_what_the_registry_refuses_is_raised_as_pin_digest_raises_it(fake_registry, tmp_path, status, error): + seen = fake_registry(lambda request: httpx.Response(status)) + + with pytest.raises(error): + read_manifest("localhost:5000/desk:1", None) + with pytest.raises(error): + read_blob("localhost:5000/desk:1", DIGEST, None, tmp_path / "layer") + assert str(seen[0].url) == "http://localhost:5000/v2/desk/manifests/1" From 9bba513dffae2103789792ab5cfaab1a5d0766e3 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:35:07 -0700 Subject: [PATCH 03/22] feat(bundle): let a bundle give its artifacts aliases aliases.toml gains an [artifacts] table, read and checked like [agents] and [envs]: each alias names an artifact the bundle has by its folder. A task's reference to an installed bundle's artifact alias is pointed at that artifact and pulled in, unless the store has one of the alias's id, and an entry's reference to one is refused, as env aliases are. The run says what each artifact alias names, and plugin list shows them. An alias may now hold '.', so a versioned name like ubuntu-24.04 is one. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/parse.py | 10 +++-- src/agent_env/bundle/plan.py | 43 ++++++++++++++-------- src/agent_env/bundle/run.py | 7 ++-- src/agent_env/cli/plugin.py | 6 +-- src/agent_env/cli/run.py | 4 +- src/agent_env/plugins/_bundles.py | 21 ++++++----- tst/unit/bundle/aliases_test.py | 34 ++++++++++++----- tst/unit/bundle/installed_entities_test.py | 29 +++++++++++++++ 8 files changed, 107 insertions(+), 47 deletions(-) diff --git a/src/agent_env/bundle/parse.py b/src/agent_env/bundle/parse.py index f8d0ebbe3..c6b39184a 100644 --- a/src/agent_env/bundle/parse.py +++ b/src/agent_env/bundle/parse.py @@ -23,7 +23,7 @@ from agent_env.bundle._fs import Unreadable, fold, home, on_disk, read_regular, show, with_article from agent_env.entity.refs import EntityKind -from agent_env.plugins._bundles import AGENT_ALIASES, ALIASES_FILE, ENV_ALIASES, read_aliases +from agent_env.plugins._bundles import AGENT_ALIASES, ALIASES_FILE, ARTIFACT_ALIASES, ENV_ALIASES, read_aliases from agent_env.store.ids import LOCAL_PREFIX, MAX_AUTHORED_LOCAL_ID_BYTES, validate_local_id from agent_env.utils.paths import LEAVINGS, os_reason @@ -159,14 +159,16 @@ def parse(self) -> Bundle: ) def _aliases(self, entries: list[BundleEntry]) -> dict[str, dict[str, tuple[str, ...]]]: - """The aliases the bundle's aliases.toml gives its agents and envs. Each names one the bundle has, of its - table's kind, by its folder name, so it keeps the id its folder gives it, and none is another one's name or id.""" + """The aliases the bundle's aliases.toml gives its agents, envs and artifacts. Each names one the bundle has, of + its table's kind, by its folder name, so it keeps the id its folder gives it, and none is another one's name or + id.""" if self.aliases_file is None: return {} declared, problems = read_aliases(self.aliases_file) for problem in problems: self._problem(self.aliases_file, problem) - for table, kind, noun in ((AGENT_ALIASES, BundleKind.AGENT, "agent"), (ENV_ALIASES, BundleKind.ENV, "env")): + for table, kind, noun in ((AGENT_ALIASES, BundleKind.AGENT, "agent"), (ENV_ALIASES, BundleKind.ENV, "env"), + (ARTIFACT_ALIASES, BundleKind.ARTIFACT, "artifact")): own = {entry.name: entry for entry in entries if entry.kind is kind} taken = {key: entry for entry in own.values() for key in (entry.name, entry.id)} for name, aliases in declared.get(table, {}).items(): diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 0b56d3908..c8038bf84 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -41,7 +41,7 @@ from agent_env.env.published_images import published_bundle_image from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION, get_env_store -from agent_env.plugins._bundles import ENV_ALIASES, InstalledAliases, installed_aliases +from agent_env.plugins._bundles import ARTIFACT_ALIASES, ENV_ALIASES, InstalledAliases, installed_aliases from agent_env.providers.env_providers.constants import ( AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, @@ -124,11 +124,11 @@ def unwritten(self, image_id: str) -> DockerImageArtifact: @dataclass(frozen=True) class AliasUse: - """An agent or env alias the run names, and what it names: the one an installed bundle gives the alias or, when - the store has one of that id, that one, which shadows it.""" + """An agent, env or artifact alias the run names, and what it names: the one an installed bundle gives the alias + or, when the store has one of that id, that one, which shadows it.""" alias: str - target_id: str # the agent or env the installed bundle gives the alias + target_id: str # the agent, env or artifact the installed bundle gives the alias declared_by: str # "'s bundle" store_version: int | None = None # the store's of the alias's id, when it shadows the alias kind: EntityKind = EntityKind.AGENT @@ -175,6 +175,7 @@ class Plan: aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each agent alias the run names, by alias agent: AgentOverride | None = None # the agent --agent names for the solver env_aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each env alias the run names, by alias + artifact_aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each artifact alias the run names def bundle_of(self, entry: BundleEntry) -> Bundle: """The bundle ``entry`` comes from: the installed one for an entry pulled in from one, else the run's.""" @@ -244,6 +245,9 @@ def __init__(self, resolved: ResolvedBundle, installed: Installed | None = None) self.env_alias_table: InstalledAliases | None = None self.env_alias_uses: dict[str, AliasUse] = {} self.env_alias_stored: dict[str, int | None] = {} + self.artifact_alias_table: InstalledAliases | None = None + self.artifact_alias_uses: dict[str, AliasUse] = {} + self.artifact_alias_stored: dict[str, int | None] = {} self.pulling: set[_Key] = set() self.own = _by_id(resolved.bundle) # what a pulled entity's id names in this bundle images: dict[BundleEntry, list[BundleImage]] = {} @@ -297,18 +301,22 @@ def _default_agents(self, task: ResolvedEntry, agent_id: str | None = None) -> l # Agents named by an installed bundle's alias def _resolve_aliases(self) -> dict[str, AliasUse]: - """Point each task's reference to an agent or env by an alias an installed bundle gives it, and the configured - default agent when it's one, at what it names, pulled into the plan, unless the store has one of the alias's - id, which the reference keeps naming. A field naming the alias is rewritten to the id. A reference that can't be - pointed at the alias's entity is left with the problem the store ref check reports. Returns the agent aliases - the plan names; ``env_alias_uses`` holds the env aliases.""" + """Point each task's reference to an agent, env or artifact by an alias an installed bundle gives it, and the + configured default agent when it's one, at what it names, pulled into the plan, unless the store has one of the + alias's id, which the reference keeps naming. A field naming the alias is rewritten to the id. A reference that + can't be pointed at the alias's entity is left with the problem the store ref check reports. Returns the agent + aliases the plan names; ``env_alias_uses`` and ``artifact_alias_uses`` hold the others.""" if self.installed is None: return {} installed = self.installed() self.alias_table, self.env_alias_table = installed_aliases(installed), installed_aliases(installed, ENV_ALIASES) - tables = {EntityKind.AGENT: self.alias_table, EntityKind.ENV: self.env_alias_table} - uses = {EntityKind.AGENT: self.alias_uses, EntityKind.ENV: self.env_alias_uses} - stored = {EntityKind.AGENT: self.alias_stored, EntityKind.ENV: self.env_alias_stored} + self.artifact_alias_table = installed_aliases(installed, ARTIFACT_ALIASES) + tables = {EntityKind.AGENT: self.alias_table, EntityKind.ENV: self.env_alias_table, + EntityKind.ARTIFACT: self.artifact_alias_table} + uses = {EntityKind.AGENT: self.alias_uses, EntityKind.ENV: self.env_alias_uses, + EntityKind.ARTIFACT: self.artifact_alias_uses} + stored = {EntityKind.AGENT: self.alias_stored, EntityKind.ENV: self.env_alias_stored, + EntityKind.ARTIFACT: self.artifact_alias_stored} default = _UNREAD for index, entry in enumerate(self.entries): if entry.entry.kind is not BundleKind.TASK: @@ -528,7 +536,8 @@ def _planned(self, roots: list[ResolvedEntry], selected_tasks: list[ResolvedEntr raise BundleError(self.problems) writes = self._ordered(closure) return Plan(self.resolved, writes, tuple(selected_tasks), tuple(selected_evals), store_refs, store_latest, - dict(self.origins), self._published(writes), aliases, override, dict(self.env_alias_uses)) + dict(self.origins), self._published(writes), aliases, override, dict(self.env_alias_uses), + dict(self.artifact_alias_uses)) def check(self) -> None: """The checks a run makes before it reads a store. A bundle with nothing to run passes only when its @@ -731,14 +740,16 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: if found is None: at = f" version {ref.version}" if ref.version is not None else "" other = self.named.get(fold(ref.id)) - aliased = self.env_alias_table.target(ref.id) if ref.kind is EntityKind.ENV and self.env_alias_table else None + entry_aliases = {EntityKind.ENV: self.env_alias_table, EntityKind.ARTIFACT: self.artifact_alias_table} + aliased = table.target(ref.id) if (table := entry_aliases.get(ref.kind)) is not None else None if other is not None: hint = f"; this bundle's {other.kind.store} {other.name!r} has that name" elif aliased is not None: # a task's reference to it is pulled in; another entry's isn't, as aliases go return (f"{ref.id!r} is an alias of {aliased.target_id}, which comes with {aliased.declared_by}, and an " - "env alias names an env only in a task's steps; name the env by its id") + f"{ref.kind.value} alias names an {ref.kind.value} only in a task's steps; name the " + f"{ref.kind.value} by its id") else: - table = {EntityKind.AGENT: self.alias_table, EntityKind.ENV: self.env_alias_table}.get(ref.kind) + table = {EntityKind.AGENT: self.alias_table, **entry_aliases}.get(ref.kind) hint = table.hint(ref.id) if table is not None else "" return f"there is no {kind} {ref.id!r}{at} in the store{hint}" if isinstance(found, str): diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index 8c59875b2..e8133e461 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -407,8 +407,8 @@ def refuse_store_tasks(plan: Plan) -> None: def _say_agents(plan: Plan, say: Callable[[str], None]) -> None: - """Say what each agent and env alias names, and what the solver deploys in place of the tasks' agents.""" - for use in (*plan.aliases.values(), *plan.env_aliases.values()): + """Say what each agent, env and artifact alias names, and what the solver deploys in place of the tasks' agents.""" + for use in (*plan.aliases.values(), *plan.env_aliases.values(), *plan.artifact_aliases.values()): say(_aliased(use)) if plan.agent is not None: runs = _to_run(plan) @@ -544,7 +544,8 @@ def _task_run_command(ref: Reference) -> str: def _aliased(use: AliasUse) -> str: - """Which agent or env an alias the run names is: the one an installed bundle gives it, or the store's of its id.""" + """Which agent, env or artifact an alias the run names is: the one an installed bundle gives it, or the store's of + its id.""" if use.store_version is None: return f"{use.alias} → {use.target_id} (the alias {use.declared_by} gives it)" return (f"{use.alias} → the store's {use.kind.value} {use.alias!r} v{use.store_version}, which shadows the alias " diff --git a/src/agent_env/cli/plugin.py b/src/agent_env/cli/plugin.py index 591a950c0..7f4f2b3f7 100644 --- a/src/agent_env/cli/plugin.py +++ b/src/agent_env/cli/plugin.py @@ -35,7 +35,7 @@ ) from agent_env.plugins._cli import CLI_PLUGINS_GROUP, CLI_ROOT_OPTIONS_GROUP, cli_contributions, cli_discovery_errors from agent_env.plugins._discovery import discovery_error -from agent_env.plugins._bundles import ENV_ALIASES, InstalledAliases, installed_aliases +from agent_env.plugins._bundles import ALIAS_TABLES, InstalledAliases, installed_aliases from agent_env.plugins._report import ( ALIAS_REFUSED, ENTRY_POINTS_UNREADABLE, @@ -154,7 +154,7 @@ def _bundle_contributions(*, load: bool) -> tuple[dict[tuple[str, str], list[Con except Exception as e: return {}, {BUNDLES: Diagnostic(ENTRY_POINTS_UNREADABLE, discovery_error(e))} found: dict[tuple[str, str], list[Contribution]] = {} - aliases = (installed_aliases(bundles), installed_aliases(bundles, ENV_ALIASES)) + aliases = tuple(installed_aliases(bundles, table) for table in ALIAS_TABLES) for bundle in bundles: found.setdefault((bundle.package, bundle.version), []).append( _bundle_contribution(bundle, bundles, aliases, load=load)) @@ -164,7 +164,7 @@ def _bundle_contributions(*, load: bool) -> tuple[dict[tuple[str, str], list[Con def _bundle_contribution(bundle: InstalledBundle, bundles: tuple[InstalledBundle, ...], aliases: tuple[InstalledAliases, ...], *, load: bool) -> Contribution: """Whether ``bundle`` runs, by the checks ``agent-env run`` makes before it reads a store, and whether it gets each - alias it gives an agent or env. Without loading plugins, only that the folder parses.""" + alias it gives an agent, env or artifact. Without loading plugins, only that the folder parses.""" namesakes = tuple(Claimant(other.package, other.version, other.value) for other in bundles if other.name == bundle.name and other is not bundle) if bundle.problem: diff --git a/src/agent_env/cli/run.py b/src/agent_env/cli/run.py index c0d0fbe78..9a0dbd0d6 100644 --- a/src/agent_env/cli/run.py +++ b/src/agent_env/cli/run.py @@ -14,7 +14,7 @@ dry_run_bundle, run_bundle) from agent_env.bundle.installed import (InstalledBundle, checked, find_bundle, installed_bundles, locate_bundle, path_like, run_name) -from agent_env.plugins._bundles import ENV_ALIASES, installed_aliases +from agent_env.plugins._bundles import ALIAS_TABLES, installed_aliases from agent_env.plugins._discovery import discovery_error from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox from agent_env.task.interrupts import Interrupts @@ -144,7 +144,7 @@ def echo(name: str, package: str, contents: str, description: str) -> None: click.echo(f"{name:<{widths[0]}} {package:<{widths[1]}} {contents:<{widths[2]}} {description}".rstrip()) echo(*header) - aliases = (installed_aliases(bundles), installed_aliases(bundles, ENV_ALIASES)) + aliases = tuple(installed_aliases(bundles, table) for table in ALIAS_TABLES) for bundle, row in zip(bundles, rows): echo(*row) if bundle.root is not None: diff --git a/src/agent_env/plugins/_bundles.py b/src/agent_env/plugins/_bundles.py index 0c163b7ea..7aa63338d 100644 --- a/src/agent_env/plugins/_bundles.py +++ b/src/agent_env/plugins/_bundles.py @@ -4,8 +4,8 @@ found without importing any package code: only the top-level package is located, and the rest of the path is walked as folders. An installed bundle's ids are rooted at its distribution, ``@local//``. -A bundle can give its agents and envs short aliases in an ``aliases.toml`` at its root, which a reference names one by -when no agent or env of that id is in the store (``installed_aliases``). +A bundle can give its agents, envs and artifacts short aliases in an ``aliases.toml`` at its root, which a reference +names one by when the store has none of that id (``installed_aliases``). It imports nothing from ``agent_env.bundle``, so a task step can read what's installed without importing the bundle machinery, whose run imports the task steps. @@ -36,11 +36,13 @@ CORE = "agentenv-framework" ALIASES_FILE = "aliases.toml" # The tables an aliases.toml takes, each giving aliases to one kind of the bundle's entities, by folder name. -AGENT_ALIASES, ENV_ALIASES = "agents", "envs" -_NOUNS = {AGENT_ALIASES: "agent", ENV_ALIASES: "env"} +AGENT_ALIASES, ENV_ALIASES, ARTIFACT_ALIASES = "agents", "envs", "artifacts" +_NOUNS = {AGENT_ALIASES: "agent", ENV_ALIASES: "env", ARTIFACT_ALIASES: "artifact"} +ALIAS_TABLES = tuple(_NOUNS) # A plain id, never a bundle's namespaced one, which holds '@' and '/'. -_ALIAS = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?") -_ALIAS_RULE = "lowercase letters, digits and '-', at most 64 characters, starting and ending with a letter or digit" +_ALIAS = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?") +_ALIAS_RULE = ("lowercase letters, digits, '.' and '-', at most 64 characters, starting and ending with a letter or " + "digit") @dataclass(frozen=True) @@ -189,8 +191,8 @@ def hint(self, name: str) -> str: def read_aliases(path: Path) -> tuple[dict[str, dict[str, tuple[str, ...]]], list[str]]: - """The aliases a bundle's ``aliases.toml`` at ``path`` gives its agents and envs, by table and then folder name, and - its problems; none of either when there's no such file, and no aliases when it has a problem.""" + """The aliases a bundle's ``aliases.toml`` at ``path`` gives its agents, envs and artifacts, by table and then folder + name, and its problems; none of either when there's no such file, and no aliases when it has a problem.""" if not os.path.lexists(path): return {}, [] if not path.is_file(): @@ -201,7 +203,8 @@ def read_aliases(path: Path) -> tuple[dict[str, dict[str, tuple[str, ...]]], lis return {}, [f"can't be read: {os_reason(e)}"] except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: return {}, [f"isn't valid TOML: {e}"] - problems = [f"[{key}] isn't a table it takes; it takes [agents] and [envs]" for key in data if key not in _NOUNS] + takes = ", ".join(f"[{table}]" for table in ALIAS_TABLES[:-1]) + f" and [{ALIAS_TABLES[-1]}]" + problems = [f"[{key}] isn't a table it takes; it takes {takes}" for key in data if key not in _NOUNS] found: dict[str, dict[str, tuple[str, ...]]] = {} for table, noun in _NOUNS.items(): entries = data.get(table, {}) diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py index 6547b3373..a32069737 100644 --- a/tst/unit/bundle/aliases_test.py +++ b/tst/unit/bundle/aliases_test.py @@ -1,11 +1,19 @@ -"""Aliases installed bundles give their agents: the aliases.toml format, and the table of every installed bundle's.""" +"""Aliases installed bundles give their agents, envs and artifacts: the aliases.toml format, and the table of every +installed bundle's.""" import os import pytest from agent_env.bundle import BundleError, parse_bundle -from agent_env.plugins._bundles import ALIASES_FILE, ENV_ALIASES, InstalledBundle, installed_aliases, read_aliases +from agent_env.plugins._bundles import ( + ALIASES_FILE, + ARTIFACT_ALIASES, + ENV_ALIASES, + InstalledBundle, + installed_aliases, + read_aliases, +) from tst.unit.bundle._support import layout @@ -22,11 +30,13 @@ def _read(tmp_path, text): return read_aliases(tmp_path / ALIASES_FILE) -def test_an_aliases_file_gives_each_agent_and_env_its_aliases(tmp_path): - text = '[agents]\nclaude-code = ["claude-code", "claude"]\npi = ["pi"]\n[envs]\nmail = ["mail-mcp"]\n' +def test_an_aliases_file_gives_each_agent_env_and_artifact_its_aliases(tmp_path): + text = ('[agents]\nclaude-code = ["claude-code", "claude"]\npi = ["pi"]\n[envs]\nmail = ["mail-mcp"]\n' + '[artifacts]\n"ubuntu-24.04" = ["ubuntu-24.04", "ubuntu"]\n') assert _read(tmp_path, text) == ( - {"agents": {"claude-code": ("claude-code", "claude"), "pi": ("pi",)}, "envs": {"mail": ("mail-mcp",)}}, []) + {"agents": {"claude-code": ("claude-code", "claude"), "pi": ("pi",)}, "envs": {"mail": ("mail-mcp",)}, + "artifacts": {"ubuntu-24.04": ("ubuntu-24.04", "ubuntu")}}, []) def test_a_bundle_without_an_aliases_file_gives_none(tmp_path): @@ -44,7 +54,7 @@ def test_a_bundle_without_an_aliases_file_gives_none(tmp_path): ('[agents]\nx = []\n', "agents.x must be a list of aliases, not []"), ('[agents]\nx = ["a"]\ny = ["a"]\n', "agents.y: 'a' is listed for agents.x too; an alias names one agent"), ('[agents]\nx = ["a", "a"]\n', "agents.x: 'a' is listed twice"), - ('[skills]\nx = ["a"]\n', "[skills] isn't a table it takes; it takes [agents] and [envs]"), + ('[skills]\nx = ["a"]\n', "[skills] isn't a table it takes; it takes [agents], [envs] and [artifacts]"), ('[envs]\nx = ["a", "a"]\n', "envs.x: 'a' is listed twice; an alias names one env"), ('agents = 3\n', "[agents] must be a table"), ('[agents\n', "isn't valid TOML"), @@ -89,14 +99,17 @@ def test_agent_envs_own_aliases_are_refused_to_any_other_bundle(tmp_path): "can't give it",)} -def test_an_installed_bundle_gives_its_envs_aliases_of_their_own(tmp_path): - """An env alias and an agent alias are kept apart, each where a reference to its kind looks.""" - mail = _bundle(tmp_path, "mail-mcp", aliases='[envs]\nmail = ["mail-mcp"]\n[agents]\nhelper = ["mail-mcp"]\n') +def test_an_installed_bundle_gives_its_envs_and_artifacts_aliases_of_their_own(tmp_path): + """An env, an agent and an artifact alias are kept apart, each where a reference to its kind looks.""" + mail = _bundle(tmp_path, "mail-mcp", aliases='[envs]\nmail = ["mail-mcp"]\n[agents]\nhelper = ["mail-mcp"]\n' + '[artifacts]\ninbox = ["mail-mcp"]\n') envs, agents = installed_aliases((mail,), ENV_ALIASES), installed_aliases((mail,)) + artifacts = installed_aliases((mail,), ARTIFACT_ALIASES) assert envs.target("mail-mcp").target_id == "@local/agentenv-framework/mail-mcp/mail" assert agents.target("mail-mcp").target_id == "@local/agentenv-framework/mail-mcp/helper" + assert artifacts.target("mail-mcp").target_id == "@local/agentenv-framework/mail-mcp/inbox" assert envs.hint("mail_mcp") == "; did you mean the alias 'mail-mcp'?" @@ -153,7 +166,8 @@ def test_a_bundle_gives_its_agents_the_aliases_its_aliases_file_lists(tmp_path): ('[agents]\nsolver = ["helper"]\n', "aliases.toml: agents.solver: 'helper' is the agent 'helper''s name"), ('[agents]\nsolver = ["Solver"]\n', "aliases.toml: agents.solver: 'Solver' isn't an alias"), ('[envs]\nsolver = ["x"]\n', "aliases.toml: envs.solver: this bundle has no env 'solver'"), -], ids=["no-such-agent", "another-agents-name", "syntax", "an-agent-as-an-env"]) + ('[artifacts]\nsolver = ["x"]\n', "aliases.toml: artifacts.solver: this bundle has no artifact 'solver'"), +], ids=["no-such-agent", "another-agents-name", "syntax", "an-agent-as-an-env", "an-agent-as-an-artifact"]) def test_an_aliases_file_that_doesnt_fit_the_bundles_agents_is_refused(tmp_path, aliases, problem): root = layout(tmp_path / "kit", {**_AGENTS, ALIASES_FILE: aliases}) diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index a16bf49df..ff1dd5efc 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -450,6 +450,35 @@ def test_an_env_definition_naming_an_env_alias_is_refused_saying_to_name_the_env assert any(f"'crm-x' is an alias of {CRM}, which comes with demo-dist's kit bundle, and an env alias names an env only " "in a task's steps; name the env by its id" in problem for problem in problems), problems +ARTIFACT_ALIASED = {"artifacts/notes/notes.txt": "a note\n", "artifacts/img/Dockerfile": "FROM scratch\n", + ALIASES_FILE: '[artifacts]\nnotes = ["notes-x"]\nimg = ["img-x"]\n'} +CRM_HERE = {"envs/crm/Dockerfile": "FROM scratch\n", "envs/crm/env.toml": 'environment_name = "crm"\n'} + + +def test_an_artifact_named_by_an_installed_alias_is_pulled_in_and_the_task_names_it_by_id(bundle_dir, tmp_path): + kit = _kit(tmp_path, ARTIFACT_ALIASED) + + plan = _plan(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}, + {"id": "load", "type": "load_artifact", "env_id": "crm", "artifact_id": "notes-x", + "depends_on": ["env"]}], CRM_HERE), kit) + + assert (BundleKind.ARTIFACT, f"{KIT}/notes") in _writes(plan) and _step(plan, "load")["artifact_id"] == f"{KIT}/notes" + assert plan.artifact_aliases == {"notes-x": AliasUse("notes-x", f"{KIT}/notes", "demo-dist's kit bundle", + kind=EntityKind.ARTIFACT)} + assert plan.env_aliases == {} and plan.store_refs == () + + +def test_an_env_definition_naming_an_artifact_alias_is_refused_saying_to_name_the_artifact_by_its_id(bundle_dir, + tmp_path): + kit = _kit(tmp_path, ARTIFACT_ALIASED) + + problems = _problems(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}], + {"envs/crm/env.toml": 'environment_name = "crm"\nimage = "img-x"\n'}), kit) + + assert any(f"'img-x' is an alias of {KIT}/img, which comes with demo-dist's kit bundle, and an artifact alias names " + "an artifact only in a task's steps; name the artifact by its id" in problem for problem in problems), problems + + def test_the_stores_agent_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): """Someone's own agent of that id is the one they mean.""" kit = _kit(tmp_path, ALIASED) From 6eb4268835fa132deab37b3088c0be68bb52f9b9 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:44:46 -0700 Subject: [PATCH 04/22] feat(computer): ship the ubuntu-24.04 base in a computers bundle The computers bundle ships one base, ubuntu-24.04: Ubuntu 24.04 for amd64 and arm64 with XFCE on Xvfb, D-Bus and AT-SPI, and Python 3 with venv and Tk for drivers and the probe's Fixture Desk. tini starts supervisord, which runs every program as the session user, user (uid 1000); nothing runs as root. The resolution and time zone settings arrive as AGENTENV_SETTING_ and reach every program, and a value it can't use stops it at start. A driver or an app adds its services as programs in /etc/supervisor/conf.d. Its aliases.toml names it ubuntu-24.04. Its image joins the published images, so a release builds it on both platforms, checks its desktop comes up, and publishes ghcr.io/scaleapi/agentenv-ubuntu-24.04:src-; Dependabot keeps its pinned Ubuntu base current. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 1 + .github/scripts/infra_build.py | 10 +++- AGENTS.md | 11 +++- pyproject.toml | 1 + src/agent_env/computers/README.md | 22 +++++++ src/agent_env/computers/aliases.toml | 3 + .../artifacts/ubuntu-24.04/Dockerfile | 25 ++++++++ .../artifacts/ubuntu-24.04/artifact.toml | 18 ++++++ .../artifacts/ubuntu-24.04/start-desktop | 25 ++++++++ .../artifacts/ubuntu-24.04/supervisord.conf | 42 ++++++++++++++ .../artifacts/ubuntu-24.04/xfwm4.xml | 7 +++ src/agent_env/env/published_images.py | 9 ++- tst/unit/bundle/installed_test.py | 5 +- tst/unit/bundle/shipped_bundles_test.py | 2 +- tst/unit/bundle/shipped_computers_test.py | 58 +++++++++++++++++++ tst/unit/env/published_images_test.py | 3 +- tst/unit/test_clean_install_wheel.py | 6 +- tst/unit/test_infra_images_script.py | 2 +- 18 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 src/agent_env/computers/README.md create mode 100644 src/agent_env/computers/aliases.toml create mode 100644 src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile create mode 100644 src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml create mode 100755 src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop create mode 100644 src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf create mode 100644 src/agent_env/computers/artifacts/ubuntu-24.04/xfwm4.xml create mode 100644 tst/unit/bundle/shipped_computers_test.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fde20cd99..d893b5338 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -48,6 +48,7 @@ updates: - "/src/agent_env/agents/agents/pi" - "/src/agent_env/agents/agents/proxy" - "/src/agent_env/examples/mail-mcp/envs/mail" + - "/src/agent_env/computers/artifacts/ubuntu-24.04" schedule: interval: "weekly" open-pull-requests-limit: 5 diff --git a/.github/scripts/infra_build.py b/.github/scripts/infra_build.py index 4826777d2..0e5c2d5bc 100644 --- a/.github/scripts/infra_build.py +++ b/.github/scripts/infra_build.py @@ -39,6 +39,7 @@ "agentenv-pi": "src/agent_env/agents/agents/pi/Dockerfile", "agentenv-proxy": "src/agent_env/agents/agents/proxy/Dockerfile", "agentenv-mail-mcp": "src/agent_env/examples/mail-mcp/envs/mail/Dockerfile", + "agentenv-ubuntu-24.04": "src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile", } _TAG = re.compile(r"^src-[0-9a-f]{64}$") _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -57,8 +58,8 @@ # Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, the sidecars and the # browser answer their own commands, Claude Code's, Codex's, Gemini's, OpenCode's and pi's agents run their CLIs and -# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, the proxy imports its server, and -# the mail example's env imports its server. +# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, the proxy imports its server, the +# mail example's env imports its server, and the base computer's desktop comes up, every program running. _SMOKES = { "agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"], "agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"], @@ -79,6 +80,11 @@ 'pi --version && python3 -c "import a2a_server"'], "agentenv-proxy": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'python -c "import a2a_server"'], "agentenv-mail-mcp": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import server"], + "agentenv-ubuntu-24.04": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", + "start-desktop init & for _ in $(seq 60); do sleep 1;" + " s=$(supervisorctl -c /etc/supervisor/supervisord.conf status);" + " echo \"$s\" | grep -q ' RUNNING ' && ! echo \"$s\" | grep -qv ' RUNNING ' && exit 0;" + " done; supervisorctl -c /etc/supervisor/supervisord.conf status; exit 1"], } diff --git a/AGENTS.md b/AGENTS.md index e8491684b..30f8b2a2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ and deserialized through a registry. | Package | Contents | |---|---| | `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset` and `load_file_artifact_universe`. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed; its deploy is refused until agent-env creates computers). `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | -| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Built-ins: `CliArtifact` (`cli`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | +| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `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/`, `start_computer_recording` and `end_computer_recording`, whose open recordings `teardown_run` and `teardown_sandboxes` end first, and more). `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `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. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. 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. | @@ -120,6 +120,7 @@ and deserialized through a registry. | `explorer/` | Optional local web UI: `agent-env up`, binds loopback `:8234`. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | +| `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), aliased through `[artifacts]`. It has no tasks either. | | `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`. It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. `tst/integration/computer/` serves it on a scaffold desktop image and runs the conformance probe and an agent against it. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | @@ -167,6 +168,10 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin alias names (`Env.stored_id`). An alias names its env only where namespace routing is on. An env store refuses to create an env under an installed env alias's id (`EnvStore.put_document`), so a deployment recorded under an alias always reloads the alias's env; an env stored under such an id before keeps taking versions. +- `aliases.toml` gives artifacts aliases too (`[artifacts] "" = ["", ...]`; the `computers` bundle + names its base `ubuntu-24.04`), resolved by the planner (`Plan.artifact_aliases`) in a task's steps alone, as env + aliases are. `Artifact.get` reads no alias: the planner rewrites every alias a task names to its id. An alias may + hold '.', so quote the folder in the toml. `A2AAgent.validate()` deploys `mail` (`A2AAgent.VALIDATION_ENV_ID`) with namespace routing on, first writing the example (its writes and infra, no run) when the store has no env of that id: on a remote sandbox its image is context-only and built in the VM, so a service like the hub's worker needs no Docker. @@ -304,8 +309,8 @@ and every job that reads those folders runs it first, so an image's tag, its bui and creates the GitHub release, whose notes are the titles of the pull requests merged since the previous release. Once the repository variable `PUBLISH_INFRA_IMAGES` is `true`, it also publishes the infra images agent-env -ships and the shipped bundles', one per agent folder and one for the env `a2a-agent validate` deploys (the `mail-mcp` -example's; `agent_env.env.published_images`), whose `src-` tags GHCR lacks: after `main`'s CI passes, each is +ships and the shipped bundles', one per agent folder, one for the env `a2a-agent validate` deploys (the `mail-mcp` +example's) and one per shipped base computer (`agentenv-ubuntu-24.04`; `agent_env.env.published_images`), whose `src-` tags GHCR lacks: after `main`'s CI passes, each is built on a runner of each platform, checked, and tagged as one multi-platform image, and a tag that exists is never moved. The infra images are published before the protocol, and a failed infra image job sends neither package to PyPI. An agent's `requirements.txt`, and that env's, keeps its protocol floor, unpinned, so its image is built again diff --git a/pyproject.toml b/pyproject.toml index bd2424f40..c92f61075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] agents = "agent_env" +computers = "agent_env" drivers = "agent_env" hello = "agent_env.examples" echo-mcp = "agent_env.examples" diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md new file mode 100644 index 000000000..1caa7628f --- /dev/null +++ b/src/agent_env/computers/README.md @@ -0,0 +1,22 @@ +Base computers that come with agent-env, each a `computer` artifact a computer env composes a driver and apps onto. + +`artifacts/ubuntu-24.04/` is Ubuntu 24.04 (`amd64` and `arm64`) with XFCE on Xvfb, D-Bus and AT-SPI. tini starts +supervisord, which runs every program as the session user, `user` (uid 1000); nothing in it runs as root. Its +container form's image is built from the folder's `Dockerfile`, and a release publishes it as +`ghcr.io/scaleapi/agentenv-ubuntu-24.04:src-`, tagged by its sources, so a run pulls it by digest instead of +building it. Name it from another bundle as `ubuntu-24.04`, the alias `aliases.toml` gives it, or by its id, +`@local/agentenv-framework/computers/ubuntu-24.04`. + +Its settings reach the computer as environment variables, `AGENTENV_SETTING_`, read as it starts: + +| Setting | Default | What it does | +| --- | --- | --- | +| `resolution` | `1280x800` | The size of the Xvfb screen, as `x` | +| `timezone` | `UTC` | The time zone every program runs in, a name under `/usr/share/zoneinfo` | + +A value it can't use stops the computer at start, with the reason on stderr. Composing a driver or an app onto it adds +its services as supervisord programs in `/etc/supervisor/conf.d/`; they inherit the display (`DISPLAY=:99`), the +session bus and the time zone. + +This bundle has no tasks of its own: other bundles pull its computers by alias, and `agent-env run computers` has +nothing to run. diff --git a/src/agent_env/computers/aliases.toml b/src/agent_env/computers/aliases.toml new file mode 100644 index 000000000..c33416836 --- /dev/null +++ b/src/agent_env/computers/aliases.toml @@ -0,0 +1,3 @@ +# Ids a task can name these computers by; a store's artifact of the id wins over an alias. +[artifacts] +"ubuntu-24.04" = ["ubuntu-24.04"] diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile b/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile new file mode 100644 index 000000000..43e83a842 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile @@ -0,0 +1,25 @@ +# The ubuntu-24.04 base computer: Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, every program run by supervisord +# as the session user. Composing a driver or an app onto it adds its services as programs in /etc/supervisor/conf.d. +FROM public.ecr.aws/docker/library/ubuntu:24.04@sha256:24737cef17f4e22b1977df7d9a05e77c4e28b7da5b8911f8d8ccda136bf3573c +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb x11-utils x11-xserver-utils libxcb1 libxcb-xinerama0 \ + xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-settings xfconf thunar \ + dbus dbus-x11 at-spi2-core \ + supervisor tini \ + python3 python3-venv python3-tk \ + fonts-dejavu-core locales tzdata ca-certificates procps \ + && rm -rf /var/lib/apt/lists/* \ + && sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen \ + && userdel --remove ubuntu \ + && useradd --create-home --uid 1000 --shell /bin/bash user \ + && mkdir -p /etc/supervisor/conf.d /var/log/agentenv /run/agentenv \ + && chown user:user /var/log/agentenv /run/agentenv +COPY supervisord.conf /etc/supervisor/supervisord.conf +COPY xfwm4.xml /etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml +COPY start-desktop /usr/local/bin/start-desktop +USER user +WORKDIR /home/user +ENV DISPLAY=:99 XDG_SESSION_TYPE=x11 LANG=en_US.UTF-8 +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/local/bin/start-desktop", "init"] diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml b/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml new file mode 100644 index 000000000..654f2f5e4 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/artifact.toml @@ -0,0 +1,18 @@ +type = "computer" +description = "Ubuntu 24.04 with XFCE on Xvfb, D-Bus and AT-SPI, run by supervisord as the session user" +arch = ["amd64", "arm64"] +os = { type = "linux", distribution = "ubuntu", version = "24.04", display = "x11" } +session = { user = "user" } + +[forms.container] +image = { dockerfile = "Dockerfile" } +init = "supervisord" +input_channel = "env" + +[config.resolution] +default = "1280x800" +phase = "deploy" + +[config.timezone] +default = "UTC" +phase = "deploy" diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop new file mode 100755 index 000000000..d30fbc688 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop @@ -0,0 +1,25 @@ +#!/bin/sh +# The base's start, with the settings a deploy passes as AGENTENV_SETTING_, their defaults artifact.toml's: +# init applies the time zone for every program and starts supervisord, which runs xvfb at the resolution and then the +# XFCE session once X answers. +set -eu +resolution="${AGENTENV_SETTING_RESOLUTION:-1280x800}" +echo "$resolution" | grep -Eqx '[0-9]+x[0-9]+' || { echo "resolution is x, not '$resolution'" >&2; exit 2; } +case "${1:-}" in + init) + timezone="${AGENTENV_SETTING_TIMEZONE:-UTC}" + [ -f "/usr/share/zoneinfo/$timezone" ] || { echo "timezone '$timezone' isn't in /usr/share/zoneinfo" >&2; exit 2; } + export TZ="$timezone" + exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf + ;; + xvfb) + exec Xvfb "$DISPLAY" -screen 0 "${resolution}x24" -nolisten tcp -ac + ;; + session) + for _ in $(seq 100); do xdpyinfo >/dev/null 2>&1 && break; sleep 0.1; done + exec xfce4-session + ;; + *) + echo "usage: start-desktop init|xvfb|session" >&2; exit 2 + ;; +esac diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf new file mode 100644 index 000000000..5a4f3fd41 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf @@ -0,0 +1,42 @@ +; The base computer's own programs, all run as the session user. A composed driver or app adds its own in conf.d. +[supervisord] +nodaemon=true +logfile=/var/log/agentenv/supervisord.log +pidfile=/run/agentenv/supervisord.pid +childlogdir=/var/log/agentenv +environment=DBUS_SESSION_BUS_ADDRESS="unix:path=/run/agentenv/bus" + +[unix_http_server] +file=/run/agentenv/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///run/agentenv/supervisor.sock + +[program:xvfb] +command=/usr/local/bin/start-desktop xvfb +priority=10 +autorestart=true +startsecs=1 +redirect_stderr=true +stdout_logfile=/var/log/agentenv/xvfb.log + +[program:dbus] +command=/usr/bin/dbus-daemon --session --nofork --nopidfile --address=unix:path=/run/agentenv/bus +priority=10 +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/agentenv/dbus.log + +[program:desktop] +command=/usr/local/bin/start-desktop session +priority=20 +autorestart=true +startsecs=3 +redirect_stderr=true +stdout_logfile=/var/log/agentenv/desktop.log + +[include] +files = /etc/supervisor/conf.d/*.conf diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/xfwm4.xml b/src/agent_env/computers/artifacts/ubuntu-24.04/xfwm4.xml new file mode 100644 index 000000000..a9116c472 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/xfwm4.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/agent_env/env/published_images.py b/src/agent_env/env/published_images.py index 2bfb372ce..8dc156834 100644 --- a/src/agent_env/env/published_images.py +++ b/src/agent_env/env/published_images.py @@ -1,5 +1,5 @@ -"""The images agent-env publishes for the infra envs, the agents it ships and an example's env, for linux/amd64 and -linux/arm64: one per Dockerfile, built from the Dockerfile's folder. Each is tagged by its build context's content, so +"""The images agent-env publishes for the infra envs, the agents it ships, an example's env and its base computers, for +linux/amd64 and linux/arm64: one per Dockerfile, built from the Dockerfile's folder. Each is tagged by its build context's content, so an install holding the same sources finds the image built from them; a release wheel also records the digest its release published under each tag.""" @@ -27,6 +27,7 @@ # The agents bundle agent-env ships, whose agents a run deploys like a bundle's own. _AGENTS = Path(__file__).parents[1] / "agents" / "agents" _EXAMPLES = Path(__file__).parents[1] / "examples" +_COMPUTERS = Path(__file__).parents[1] / "computers" / "artifacts" @dataclass(frozen=True) @@ -56,8 +57,10 @@ def ref(self) -> str: for folder in sorted(_AGENTS.iterdir()) if (folder / "Dockerfile").is_file()) # The example env `a2a-agent validate` deploys, so a remote sandbox pulls its image rather than building it. EXAMPLE_IMAGES = (PublishedImage("agentenv-mail-mcp", _EXAMPLES / "mail-mcp" / "envs" / "mail" / "Dockerfile"),) +# The base computers the computers bundle ships, so a run pulls a base rather than building its desktop. +COMPUTER_IMAGES = (PublishedImage("agentenv-ubuntu-24.04", _COMPUTERS / "ubuntu-24.04" / "Dockerfile"),) # The shipped bundles' published images, whose requirements keep a protocol floor. -BUNDLE_IMAGES = AGENT_IMAGES + EXAMPLE_IMAGES +BUNDLE_IMAGES = AGENT_IMAGES + EXAMPLE_IMAGES + COMPUTER_IMAGES def published_digest(image: PublishedImage) -> str | None: diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index 0bf4e629d..ae34b07d9 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,8 +127,9 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "agents", "active"), (BUNDLES, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), - (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "active"), (BUNDLES, "next-env-event", "active")] + (BUNDLES, "agents", "active"), (BUNDLES, "computers", "active"), (BUNDLES, "drivers", "active"), + (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "active"), + (BUNDLES, "next-env-event", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 058861fce..2e807528e 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -26,7 +26,7 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["agents", "drivers", "echo-mcp", "hello", "mail-mcp", "next-env-event"] + assert SHIPPED == ["agents", "computers", "drivers", "echo-mcp", "hello", "mail-mcp", "next-env-event"] @pytest.mark.parametrize("name", SHIPPED) diff --git a/tst/unit/bundle/shipped_computers_test.py b/tst/unit/bundle/shipped_computers_test.py new file mode 100644 index 000000000..da7877dc2 --- /dev/null +++ b/tst/unit/bundle/shipped_computers_test.py @@ -0,0 +1,58 @@ +"""The computers bundle agent-env ships: a task naming ubuntu-24.04 writes its base, the image built from the folder's +Dockerfile, and the start script falls back to the defaults artifact.toml declares.""" + +import json +import re +import tomllib +from pathlib import Path + +import pytest + +import agent_env +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.bundle import parse_bundle +from agent_env.bundle.installed import installed_bundles +from agent_env.bundle.materialize import materialize +from agent_env.bundle.plan import AliasUse, plan_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.entity.refs import EntityKind +from tst.unit.bundle._support import layout, local_builds + +BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" +UBUNTU = "@local/agentenv-framework/computers/ubuntu-24.04" + + +@pytest.fixture +def unpublished(monkeypatch): + monkeypatch.setattr("agent_env.env.published_images._registry_digest", lambda ref: None) + + +def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_image( + tmp_path, monkeypatch, local_stores, cli_routing, builds, unpublished): + monkeypatch.setenv("HOME", str(tmp_path)) + layout(tmp_path / "uses", { + "envs/crm/Dockerfile": "FROM scratch\n", "envs/crm/env.toml": 'environment_name = "crm"\n', + "tasks/t.json": json.dumps([{"id": "crm", "type": "deploy_env", "env_id": "crm"}, + {"id": "base", "type": "load_artifact", "env_id": "crm", "artifact_id": "ubuntu-24.04", + "depends_on": ["crm"]}])}) + plan = plan_bundle(resolve_bundle(parse_bundle(tmp_path / "uses")), installed=installed_bundles) + + done = materialize(plan, build_here=local_builds(plan)) + + computer = ComputerArtifact.get(UBUNTU, done.version_of("artifact", UBUNTU)) + image = DockerImageArtifact.get(computer.forms.container.image.id, computer.forms.container.image.version) + assert plan.artifact_aliases["ubuntu-24.04"] == AliasUse( + "ubuntu-24.04", UBUNTU, "agentenv-framework's computers bundle", kind=EntityKind.ARTIFACT) + assert (computer.arch, computer.os.version, computer.session.user) == (["amd64", "arm64"], "24.04", "user") + assert image.id == f"{UBUNTU}__forms_container_image" and image.dockerfile_path == "Dockerfile" + built = next(call["build"] for call in builds if call["build"][2] == image.image_name) + assert built[1] == ["Dockerfile", "artifact.toml", "start-desktop", "supervisord.conf", "xfwm4.xml"] + assert computer.metadata["validated_images"]["container"]["arm64"]["status"] == "uninspected" + + +def test_the_start_script_falls_back_to_the_defaults_artifact_toml_declares(): + declared = tomllib.loads((BASE / "artifact.toml").read_text())["config"] + script = (BASE / "start-desktop").read_text() + fallbacks = dict(re.findall(r'\$\{AGENTENV_SETTING_([A-Z_]+):-([^}]*)\}', script)) + assert fallbacks == {name.upper(): setting["default"] for name, setting in declared.items()} diff --git a/tst/unit/env/published_images_test.py b/tst/unit/env/published_images_test.py index 27f6477cd..3973041e3 100644 --- a/tst/unit/env/published_images_test.py +++ b/tst/unit/env/published_images_test.py @@ -26,10 +26,11 @@ def test_every_shipped_dockerfile_is_published_once_under_a_name_of_its_own(): - """The infra envs', every shipped agent's and the env `a2a-agent validate` deploys.""" + """The infra envs', every shipped agent's, the env `a2a-agent validate` deploys and every shipped base computer's.""" shipped = [dockerfile for dockerfiles in bootstrap._DOCKERFILES.values() for dockerfile in dockerfiles] shipped += published_images._AGENTS.glob("*/Dockerfile") shipped.append(published_images._EXAMPLES / "mail-mcp" / "envs" / "mail" / "Dockerfile") + shipped += published_images._COMPUTERS.glob("*/Dockerfile") assert sorted(image.dockerfile for image in bootstrap.PUBLISHED_IMAGES) == sorted(shipped) assert len({image.name for image in bootstrap.PUBLISHED_IMAGES}) == len(bootstrap.PUBLISHED_IMAGES) diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 45afc9cc1..beb389838 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -62,7 +62,7 @@ def test_bundle_entry_points_that_differ_from_pyproject_are_named(tmp_path, entr def test_the_gate_checks_the_folder_of_every_shipped_bundle(): roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} - assert roots == {"agents": "src/agent_env/agents", "drivers": "src/agent_env/drivers", - "echo-mcp": "src/agent_env/examples/echo-mcp", "hello": "src/agent_env/examples/hello", - "mail-mcp": "src/agent_env/examples/mail-mcp", + assert roots == {"agents": "src/agent_env/agents", "computers": "src/agent_env/computers", + "drivers": "src/agent_env/drivers", "echo-mcp": "src/agent_env/examples/echo-mcp", + "hello": "src/agent_env/examples/hello", "mail-mcp": "src/agent_env/examples/mail-mcp", "next-env-event": "src/agent_env/examples/next-env-event"} diff --git a/tst/unit/test_infra_images_script.py b/tst/unit/test_infra_images_script.py index 97c549040..17df5029c 100644 --- a/tst/unit/test_infra_images_script.py +++ b/tst/unit/test_infra_images_script.py @@ -63,7 +63,7 @@ def test_plan_only_lists_the_infra_images_or_the_agents_images(script, monkeypat "agents": bundles} assert "agentenv-gateway" in planned["infra"] and bundles == [ "agentenv-claude-code", "agentenv-codex", "agentenv-gemini", "agentenv-opencode", "agentenv-openhands", - "agentenv-pi", "agentenv-proxy", "agentenv-mail-mcp"] + "agentenv-pi", "agentenv-proxy", "agentenv-mail-mcp", "agentenv-ubuntu-24.04"] def test_plan_missing_lists_the_images_the_registry_lacks_or_refuses_to_show(script, monkeypatch, capsys): From c8cec4df25706092c8dd012aff0a717ab41b5717 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:47:07 -0700 Subject: [PATCH 05/22] test(computer): boot the ubuntu-24.04 base and register it from the local registry Built from its folder, the base's desktop runs as user (uid 1000) with every program under supervisord, at the resolution and in the time zone the deploy passes, with a window manager and AT-SPI up; a setting it can't use stops it with exit 2. Pushed to the local registry, it registers as a computer whose image put reads back as Ubuntu 24.04. Co-Authored-By: Claude Opus 5.5 --- .../computer/base_computer_test.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tst/integration/computer/base_computer_test.py diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py new file mode 100644 index 000000000..6ddccd655 --- /dev/null +++ b/tst/integration/computer/base_computer_test.py @@ -0,0 +1,106 @@ +"""The ubuntu-24.04 base computer, built from its folder: its desktop runs as the session user with the settings a deploy +passes, a setting it can't use stops it at start, and pushed to the local registry it registers as a computer artifact +whose image put reads back. Needs Docker and the local registry on localhost:5000.""" + +import json +import platform +import subprocess +import time +import tomllib +import uuid +from pathlib import Path + +import pytest + +import agent_env +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import reset_artifact_store +from agent_env.config import configure, reset_config + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" +SUPERVISOR = ["supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status"] +ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine().lower()] + + +@pytest.fixture(scope="module") +def image(): + tag = f"localhost:5000/agentenv-test-ubuntu-24.04:{uuid.uuid4().hex[:8]}" + _docker("build", "-q", "-t", tag, str(BASE)) + yield tag + subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) + + +@pytest.fixture +def stores(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + configure() + reset_artifact_store() + yield + reset_artifact_store() + reset_config() + + +def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes(image): + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + _docker("run", "-d", "--name", name, "-e", "AGENTENV_SETTING_RESOLUTION=1600x900", + "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", image) + try: + for _ in range(60): + running = subprocess.run(["docker", "exec", name, *SUPERVISOR], capture_output=True, text=True).stdout + if running and all(" RUNNING " in line for line in running.splitlines()): + break + time.sleep(1) + users = set(_exec(name, "ps", "-eo", "user=").split()) + uid = _exec(name, "id", "-u", "user").strip() + screen = _exec(name, "xdpyinfo") + wm = _exec(name, "xprop", "-root", "_NET_SUPPORTING_WM_CHECK") + environment = _exec(name, "sh", "-c", 'tr "\\0" "\\n" < /proc/$(pgrep -x xfwm4)/environ') + accessibility = _exec(name, "pgrep", "-c", "at-spi2-registr") + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + assert {line.split()[0] for line in running.splitlines()} == {"dbus", "desktop", "xvfb"} + assert all(" RUNNING " in line for line in running.splitlines()), running + assert users == {"user"} and uid == "1000" + assert "dimensions: 1600x900 pixels" in screen and "window id #" in wm + assert "TZ=Europe/Paris" in environment.splitlines() and "DISPLAY=:99" in environment.splitlines() + assert int(accessibility) == 1 + + +@pytest.mark.parametrize("setting, value, error", [ + ("RESOLUTION", "big", "resolution is x, not 'big'"), + ("TIMEZONE", "Mars/Olympus", "timezone 'Mars/Olympus' isn't in /usr/share/zoneinfo"), +], ids=["resolution", "timezone"]) +def test_a_setting_it_cant_use_stops_it_at_start(image, setting, value, error): + stopped = subprocess.run(["docker", "run", "--rm", "-e", f"AGENTENV_SETTING_{setting}={value}", image], + capture_output=True, text=True, timeout=120) + + assert stopped.returncode == 2 and error in stopped.stderr + + +def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_back(image, stores): + _docker("push", "-q", image) + declared = tomllib.loads((BASE / "artifact.toml").read_text()) + pinned = DockerImageArtifact.put_ref("base-image", description="the base", image_name=image) + + computer = ComputerArtifact.put(id="base", arch=[ARCH], os=declared["os"], session=declared["session"], + config=declared["config"], + forms={"container": {**declared["forms"]["container"], + "image": {"id": pinned.id, "version": pinned.version}}}) + + found = computer.metadata["validated_images"]["container"][ARCH] + assert (found["status"], found["os_release"]) == ("passed", {"ID": "ubuntu", "VERSION_ID": "24.04"}), json.dumps(found) + assert found["image"] == pinned.image_name + + +def _docker(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["docker", *args], check=True, capture_output=True) + + +def _exec(name: str, *argv: str) -> str: + return subprocess.run(["docker", "exec", name, *argv], check=True, capture_output=True, text=True).stdout From 77bd3e3ff170e217811ef110c688d3fcceddfe26 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 11:52:28 -0700 Subject: [PATCH 06/22] feat(cli): put and get computer artifacts agent-env artifact computer put --id writes the computer a folder's artifact.toml declares, outside a bundle: its form's image key written as a bundle writes one, from a Dockerfile in the folder as its build context alone, a registry ref pinned now, or the image artifact it names. Its images are read from their registries first, and --no-inspect skips that, leaving each form uninspected. get prints a computer as JSON, with what put read. The fake OCI registry the computer tests share moves to tst/util. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 30 +++++++ src/agent_env/cli/artifact/__init__.py | 2 + src/agent_env/cli/artifact/computer.py | 40 ++++++++++ tst/unit/artifact/test_computer_artifact.py | 68 +--------------- tst/unit/cli/artifact_computer_test.py | 84 ++++++++++++++++++++ tst/util/oci_registry.py | 69 ++++++++++++++++ 6 files changed, 226 insertions(+), 67 deletions(-) create mode 100644 src/agent_env/cli/artifact/computer.py create mode 100644 tst/unit/cli/artifact_computer_test.py create mode 100644 tst/util/oci_registry.py diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index e3e8bcf95..a30fb5e24 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -9,6 +9,7 @@ import re import tarfile import tempfile +import tomllib from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self @@ -19,6 +20,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.ref import ArtifactRef from agent_env.entity.refs import EntityRef +from agent_env.store.ids import derive_id from agent_env.store.image_store.oci_registry_credentials import names_registry from agent_env.store.image_store.registry_api import read_blob, read_manifest @@ -154,6 +156,18 @@ def put(cls, *, inspect: bool = True, **fields: Any) -> Self: computer.metadata = {**computer.metadata, "validated_images": found} return get_artifact_store().put_document(computer) + @classmethod + def put_folder(cls, id: str, folder: Path, *, inspect: bool = True) -> Self: + """Write the computer ``folder``'s artifact.toml declares, outside a bundle, its form's image key written as a + bundle writes one: a Dockerfile in the folder as its build context alone, a registry ref pinned now, or the image + artifact it names.""" + fields = {key: value for key, value in tomllib.loads((folder / "artifact.toml").read_text()).items() + if key not in ("id", "type", "version")} + container = (fields.get("forms") or {}).get("container") + if isinstance(container, dict) and "image" in container: + container["image"] = _folder_image(derive_id(id, "forms_container_image"), folder, container["image"]) + return cls.put(id=id, inspect=inspect, **fields) + @classmethod def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self: """Write the computer an artifact.toml declares, its form's image as the bundle wrote it or the artifact the @@ -177,6 +191,22 @@ def inspect(self) -> dict[str, dict[str, dict[str, Any]]]: return {"container": _inspect_container(image, self.arch, self.os)} +def _folder_image(image_id: str, folder: Path, key: Any) -> dict[str, Any]: + if isinstance(key, dict) and key.keys() == {"dockerfile"}: + image = DockerImageArtifact.put_context(image_id, description=f"built from {folder.name}/{key['dockerfile']}", + context_path=str(folder), dockerfile_path=key["dockerfile"], platform=None) + elif isinstance(key, dict) and key.keys() == {"ref"}: + image = DockerImageArtifact.put_ref(image_id, description=f"referenced by {folder.name}", image_name=key["ref"]) + elif isinstance(key, dict) and key.keys() <= {"artifact", "version"} and "artifact" in key: + image = DockerImageArtifact.get(key["artifact"], key.get("version")) + elif isinstance(key, str): + image = DockerImageArtifact.get(key) + else: + raise ValueError(f"forms.container.image is {{ dockerfile = \"\" }}, {{ ref = \"\" }}, an image " + f"artifact's id or {{ artifact = \"\", version = }}, not {key!r}") + return {"id": image.id, "version": image.version} + + def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxOS) -> dict[str, dict[str, Any]]: from agent_env.config import get_config diff --git a/src/agent_env/cli/artifact/__init__.py b/src/agent_env/cli/artifact/__init__.py index f5475dac3..7a6244ba9 100644 --- a/src/agent_env/cli/artifact/__init__.py +++ b/src/agent_env/cli/artifact/__init__.py @@ -1,6 +1,7 @@ import click from .cli import cli +from .computer import computer from .file_artifact_universe import file_artifact_universe from .environment import environment from .environment_universe import environment_universe @@ -14,6 +15,7 @@ def artifact(): artifact.add_command(cli) +artifact.add_command(computer) artifact.add_command(file_artifact_universe) artifact.add_command(environment) artifact.add_command(environment_universe) diff --git a/src/agent_env/cli/artifact/computer.py b/src/agent_env/cli/artifact/computer.py new file mode 100644 index 000000000..3509d2fef --- /dev/null +++ b/src/agent_env/cli/artifact/computer.py @@ -0,0 +1,40 @@ +import json +import tomllib +from pathlib import Path +from typing import Optional + +import click + +from agent_env.artifact import ComputerArtifact + + +@click.group(name="computer") +def computer(): + """ComputerArtifact commands.""" + + +@computer.command("put") +@click.option("--id", "artifact_id", required=True, help="ComputerArtifact id") +@click.argument("folder", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path)) +@click.option("--no-inspect", is_flag=True, + help="Write it without reading its images from their registries; each form is then uninspected") +def computer_put(artifact_id: str, folder: Path, no_inspect: bool): + """Write the computer FOLDER's artifact.toml declares, with its form's image, after reading that image from its + registry to check it's what the computer says.""" + try: + result = ComputerArtifact.put_folder(artifact_id, folder, inspect=not no_inspect) + except (ValueError, OSError, tomllib.TOMLDecodeError) as e: + raise click.ClickException(str(e)) from None + click.echo(f"Created ComputerArtifact: id={result.id} version={result.version}") + for form, by_arch in result.metadata["validated_images"].items(): + for arch, found in by_arch.items(): + why = found.get("reason") or found.get("problem") + click.echo(f" {form} {arch}: {found['status']}" + (f" ({why})" if why else "")) + + +@computer.command("get") +@click.option("--id", "artifact_id", required=True, help="ComputerArtifact id") +@click.option("--version", type=int, default=None, help="The version (defaults to the latest)") +def computer_get(artifact_id: str, version: Optional[int]): + """Print a computer artifact as JSON, with what put read from its images.""" + click.echo(json.dumps(ComputerArtifact.get(artifact_id, version).model_dump(mode="json"), indent=2)) diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index 245a2a2b5..0b8846526 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -2,11 +2,6 @@ (its architectures, and the OS its lowest os-release layer names), and a mismatch refuses the put naming its form and architecture. An image no registry serves yet is written uninspected.""" -import hashlib -import io -import json -import tarfile - import pytest from agent_env.artifact import Artifact @@ -14,68 +9,7 @@ from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import get_artifact_store -from agent_env.store.image_store.registry_api import NoSuchImageError - -UBUNTU = b'PRETTY_NAME="Ubuntu 24.04.3 LTS"\nNAME="Ubuntu"\nVERSION_ID="24.04"\nID=ubuntu\n' -INDEX = "application/vnd.oci.image.index.v1+json" -MANIFEST = "application/vnd.oci.image.manifest.v1+json" - - -class Registry: - """A registry serving the manifests and blobs put in it, by digest, and recording the blobs fetched.""" - - def __init__(self): - self.manifests, self.blobs, self.fetched = {}, {}, [] - - def blob(self, data: bytes) -> str: - digest = f"sha256:{hashlib.sha256(data).hexdigest()}" - self.blobs[digest] = data - return digest - - def image(self, *layers: bytes, arch: str = "amd64") -> str: - config = self.blob(json.dumps({"os": "linux", "architecture": arch}).encode()) - manifest = {"mediaType": MANIFEST, "config": {"digest": config}, - "layers": [{"digest": self.blob(layer)} for layer in layers]} - return self._manifest(manifest) - - def index(self, **images: str) -> str: - entries = [{"digest": digest, "platform": {"os": "linux", "architecture": arch}} for arch, digest in images.items()] - entries.append({"digest": self.blob(b"attestation"), "platform": {"os": "unknown", "architecture": "unknown"}}) - return self._manifest({"mediaType": INDEX, "manifests": entries}) - - def _manifest(self, manifest: dict) -> str: - digest = self.blob(json.dumps(manifest).encode()) - self.manifests[digest] = manifest - return digest - - def read_manifest(self, ref, auth): - digest = ref.rpartition("@")[2] - if digest not in self.manifests: - raise NoSuchImageError(f"{ref}: ghcr.io has no such image") - return self.manifests[digest] - - def read_blob(self, ref, digest, auth, path): - self.fetched.append(digest) - path.write_bytes(self.blobs[digest]) - - -def layer(files: dict[str, bytes | str]) -> bytes: - """A tar.gz layer of ``files`` by path; a str is a link to that path.""" - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode="w:gz") as tar: - for name, content in files.items(): - info = tarfile.TarInfo(name) - if isinstance(content, str): - info.type, info.linkname = tarfile.SYMTYPE, content - tar.addfile(info) - else: - info.size = len(content) - tar.addfile(info, io.BytesIO(content)) - return buffer.getvalue() - - -UBUNTU_LAYER = layer({"etc/os-release": "../usr/lib/os-release", "usr/lib/os-release": UBUNTU}) - +from tst.util.oci_registry import MANIFEST, UBUNTU, UBUNTU_LAYER, Registry, layer @pytest.fixture def registry(local_stores, monkeypatch): diff --git a/tst/unit/cli/artifact_computer_test.py b/tst/unit/cli/artifact_computer_test.py new file mode 100644 index 000000000..4c57ec902 --- /dev/null +++ b/tst/unit/cli/artifact_computer_test.py @@ -0,0 +1,84 @@ +"""`artifact computer put FOLDER` writes the computer a folder's artifact.toml declares, its form's image written as a +bundle writes one and read back from its registry unless `--no-inspect`; `artifact computer get` prints it. The +registry is patched out.""" + +import json + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.artifacts import computer as computer_module +from agent_env.artifact.artifacts import docker_image +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.cli import cli +from tst.util.oci_registry import UBUNTU_LAYER, Registry + +DECLARED = ('type = "computer"\ndescription = "a desktop"\narch = ["amd64"]\n' + 'os = { type = "linux", distribution = "ubuntu", version = "24.04", display = "x11" }\n' + 'session = { user = "user" }\n\n[forms.container]\nimage = %s\ninit = "supervisord"\ninput_channel = "env"\n') + + +@pytest.fixture +def registry(local_stores, monkeypatch): + served = Registry() + index = served.index(amd64=served.image(UBUNTU_LAYER)) + monkeypatch.setattr(docker_image, "pin_digest", lambda ref, auth: f"{ref}@{index}") + monkeypatch.setattr(computer_module, "read_manifest", served.read_manifest) + monkeypatch.setattr(computer_module, "read_blob", served.read_blob) + return served + + +def _folder(tmp_path, image: str): + folder = tmp_path / "desktop" + folder.mkdir() + (folder / "artifact.toml").write_text(DECLARED % image) + (folder / "Dockerfile").write_text("FROM scratch\n") + return folder + + +def _run(*args): + return CliRunner().invoke(cli, list(args), catch_exceptions=False) + + +def test_a_folder_naming_a_registry_image_is_written_over_its_pin_and_inspected(registry, tmp_path): + result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, '{ ref = "ghcr.io/acme/desk:1" }'))) + + assert result.exit_code == 0, result.output + assert "Created ComputerArtifact: id=desktop version=1\n container amd64: passed\n" in result.output + image = ComputerArtifact.get("desktop").forms.container.image + assert DockerImageArtifact.get(image.id, image.version).image_name.startswith("ghcr.io/acme/desk:1@sha256:") + printed = json.loads(_run("artifact", "computer", "get", "--id", "desktop").output) + assert printed["metadata"]["validated_images"]["container"]["amd64"]["os_release"]["VERSION_ID"] == "24.04" + + +def test_a_folders_dockerfile_is_its_build_context_and_no_inspect_skips_the_registry(registry, tmp_path): + result = _run("artifact", "computer", "put", "--id", "desktop", "--no-inspect", + str(_folder(tmp_path, '{ dockerfile = "Dockerfile" }'))) + + assert result.exit_code == 0, result.output + assert "container amd64: uninspected (inspection was skipped)" in result.output + image = ComputerArtifact.get("desktop").forms.container.image + assert (image.id, DockerImageArtifact.get(image.id).context_only) == ("desktop__forms_container_image", True) + assert registry.fetched == [] + + +@pytest.mark.parametrize("image, message", [ + ("{ path = \"x\" }", "forms.container.image is { dockerfile = \"\" }"), + ('{ ref = "ghcr.io/acme/desk:1" }\narch2 = 1', "arch2"), +], ids=["image-key", "unknown-key"]) +def test_what_the_folder_declares_wrong_is_reported(registry, tmp_path, image, message): + result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, image))) + + assert result.exit_code == 1 and message in result.output + + +@pytest.mark.parametrize("image", ['"desk-image"', '{ artifact = "desk-image", version = 1 }'], ids=["id", "pinned"]) +def test_a_folder_naming_an_image_artifact_is_written_over_it(registry, tmp_path, image): + DockerImageArtifact.put_ref("desk-image", description="the desk", image_name="ghcr.io/acme/desk:1") + + result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, image))) + + assert result.exit_code == 0, result.output + pinned = ComputerArtifact.get("desktop").forms.container.image + assert (pinned.id, pinned.version) == ("desk-image", 1) and "container amd64: passed" in result.output diff --git a/tst/util/oci_registry.py b/tst/util/oci_registry.py new file mode 100644 index 000000000..d94dc61f8 --- /dev/null +++ b/tst/util/oci_registry.py @@ -0,0 +1,69 @@ +"""A registry serving OCI images from memory, for code that reads an image's manifests and layers without pulling it: +index, image manifest and blob by digest, each blob fetched recorded.""" + +import hashlib +import io +import json +import tarfile + +from agent_env.store.image_store.registry_api import NoSuchImageError + +UBUNTU = b'PRETTY_NAME="Ubuntu 24.04.3 LTS"\nNAME="Ubuntu"\nVERSION_ID="24.04"\nID=ubuntu\n' +INDEX = "application/vnd.oci.image.index.v1+json" +MANIFEST = "application/vnd.oci.image.manifest.v1+json" + + +class Registry: + """A registry serving the manifests and blobs put in it, by digest, and recording the blobs fetched.""" + + def __init__(self): + self.manifests, self.blobs, self.fetched = {}, {}, [] + + def blob(self, data: bytes) -> str: + digest = f"sha256:{hashlib.sha256(data).hexdigest()}" + self.blobs[digest] = data + return digest + + def image(self, *layers: bytes, arch: str = "amd64") -> str: + config = self.blob(json.dumps({"os": "linux", "architecture": arch}).encode()) + manifest = {"mediaType": MANIFEST, "config": {"digest": config}, + "layers": [{"digest": self.blob(layer)} for layer in layers]} + return self._manifest(manifest) + + def index(self, **images: str) -> str: + entries = [{"digest": digest, "platform": {"os": "linux", "architecture": arch}} for arch, digest in images.items()] + entries.append({"digest": self.blob(b"attestation"), "platform": {"os": "unknown", "architecture": "unknown"}}) + return self._manifest({"mediaType": INDEX, "manifests": entries}) + + def _manifest(self, manifest: dict) -> str: + digest = self.blob(json.dumps(manifest).encode()) + self.manifests[digest] = manifest + return digest + + def read_manifest(self, ref, auth): + digest = ref.rpartition("@")[2] + if digest not in self.manifests: + raise NoSuchImageError(f"{ref}: ghcr.io has no such image") + return self.manifests[digest] + + def read_blob(self, ref, digest, auth, path): + self.fetched.append(digest) + path.write_bytes(self.blobs[digest]) + + +def layer(files: dict[str, bytes | str]) -> bytes: + """A tar.gz layer of ``files`` by path; a str is a link to that path.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for name, content in files.items(): + info = tarfile.TarInfo(name) + if isinstance(content, str): + info.type, info.linkname = tarfile.SYMTYPE, content + tar.addfile(info) + else: + info.size = len(content) + tar.addfile(info, io.BytesIO(content)) + return buffer.getvalue() + + +UBUNTU_LAYER = layer({"etc/os-release": "../usr/lib/os-release", "usr/lib/os-release": UBUNTU}) From 011e6abde51588927cd48e0a5f0e771ac6538bcf Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:08:50 -0700 Subject: [PATCH 07/22] fix(computer): keep the base's desktop restarting however often X dies The XFCE session exits whenever X does, within supervisord's three start seconds, so ten quick Xvfb deaths left the desktop program FATAL. It now counts as started at once; start-desktop already waits for X. Co-Authored-By: Claude Opus 5.5 --- .../artifacts/ubuntu-24.04/supervisord.conf | 4 +- .../computer/base_computer_test.py | 42 +++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf index 5a4f3fd41..dfb4851e9 100644 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf @@ -30,11 +30,13 @@ autorestart=true redirect_stderr=true stdout_logfile=/var/log/agentenv/dbus.log +; The session exits whenever X does, often within its first seconds, so it counts as started at once and X dying +; however often never leaves it FATAL; start-desktop waits for X before each start. [program:desktop] command=/usr/local/bin/start-desktop session priority=20 autorestart=true -startsecs=3 +startsecs=0 redirect_stderr=true stdout_logfile=/var/log/agentenv/desktop.log diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 6ddccd655..6bf8d0ab0 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -1,6 +1,6 @@ """The ubuntu-24.04 base computer, built from its folder: its desktop runs as the session user with the settings a deploy -passes, a setting it can't use stops it at start, and pushed to the local registry it registers as a computer artifact -whose image put reads back. Needs Docker and the local registry on localhost:5000.""" +passes and comes back however often X dies, a setting it can't use stops it at start, and pushed to the local registry it +registers as a computer artifact whose image put reads back. Needs Docker and the local registry on localhost:5000.""" import json import platform @@ -51,11 +51,7 @@ def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes( _docker("run", "-d", "--name", name, "-e", "AGENTENV_SETTING_RESOLUTION=1600x900", "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", image) try: - for _ in range(60): - running = subprocess.run(["docker", "exec", name, *SUPERVISOR], capture_output=True, text=True).stdout - if running and all(" RUNNING " in line for line in running.splitlines()): - break - time.sleep(1) + running = _all_running(name) users = set(_exec(name, "ps", "-eo", "user=").split()) uid = _exec(name, "id", "-u", "user").strip() screen = _exec(name, "xdpyinfo") @@ -72,6 +68,29 @@ def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes( assert int(accessibility) == 1 +def test_the_desktop_comes_back_however_often_x_dies(image): + name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" + _docker("run", "-d", "--name", name, image) + try: + _all_running(name) + for _ in range(10): + killed = _exec(name, "pgrep", "-x", "Xvfb").strip() + _exec(name, "pkill", "-9", "-x", "Xvfb") + for _ in range(40): + time.sleep(0.25) + back = subprocess.run(["docker", "exec", name, "pgrep", "-x", "Xvfb"], capture_output=True, text=True) + if back.stdout.strip() not in ("", killed): + break + time.sleep(1.2) + time.sleep(5) + running = _all_running(name) + wm = _exec(name, "xprop", "-root", "_NET_SUPPORTING_WM_CHECK") + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + assert all(" RUNNING " in line for line in running.splitlines()), running + assert "window id #" in wm + + @pytest.mark.parametrize("setting, value, error", [ ("RESOLUTION", "big", "resolution is x, not 'big'"), ("TIMEZONE", "Mars/Olympus", "timezone 'Mars/Olympus' isn't in /usr/share/zoneinfo"), @@ -98,6 +117,15 @@ def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_b assert found["image"] == pinned.image_name +def _all_running(name: str) -> str: + for _ in range(60): + running = subprocess.run(["docker", "exec", name, *SUPERVISOR], capture_output=True, text=True).stdout + if running and all(" RUNNING " in line for line in running.splitlines()): + break + time.sleep(1) + return running + + def _docker(*args: str) -> subprocess.CompletedProcess: return subprocess.run(["docker", *args], check=True, capture_output=True) From b7d1c6dfc02881b12186a0fba9f02e89e7a06f55 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:23:31 -0700 Subject: [PATCH 08/22] fix(protocol): keep the Fixture Desk over the whole screen under a window manager The desk sizes itself to the screen at its origin and writes its widgets' screen boxes once it starts. A window manager that adopts it after that moves it under its decorations and panel, so the boxes it wrote were off by the move. Asking for fullscreen keeps it where it put itself. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/computer/conformance/fixture.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py index cd4b5bee6..63b8a1869 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py @@ -27,6 +27,7 @@ def main(state_dir: Path) -> None: root.title("Fixture Desk") width, height = root.winfo_screenwidth(), root.winfo_screenheight() root.geometry(f"{width}x{height}+0+0") + root.attributes("-fullscreen", True) root.configure(bg="#f0f0f0") state: dict = {"name": "", "notes": "", "keys": [], "saved_count": 0, "subscribed": False, "selected": None, "scroll_top": 0.0, "cells": {}, "right_clicks": 0, "double_clicks": 0, "drags": [], From f549dd1494ff4eaa465c026820794e6adf1d5606 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:23:38 -0700 Subject: [PATCH 09/22] test(computer): lay the scaffold's driver and desk on the ubuntu-24.04 base The scaffold's desktop was a hand-written image with a start script. It's now the base with the reference driver and the Fixture Desk added as supervisord programs, built from a base the tests build once. The base's start-desktop gains with-x, which runs any program once X answers, as it already ran the XFCE session. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/computers/README.md | 3 +- .../artifacts/ubuntu-24.04/start-desktop | 11 ++--- .../artifacts/ubuntu-24.04/supervisord.conf | 4 +- .../computer/base_computer_test.py | 40 ++++++++----------- tst/integration/computer/conftest.py | 19 +++++++++ .../computer/recording_chaos_test.py | 5 ++- .../computer/scaffold/envs/desktop/Dockerfile | 22 ++++------ .../scaffold/envs/desktop/desktop.conf | 14 +++++++ .../computer/scaffold/envs/desktop/start.sh | 9 ----- tst/integration/computer/scaffold_test.py | 30 +++++++------- 11 files changed, 87 insertions(+), 72 deletions(-) create mode 100644 tst/integration/computer/conftest.py create mode 100644 tst/integration/computer/scaffold/envs/desktop/desktop.conf delete mode 100644 tst/integration/computer/scaffold/envs/desktop/start.sh diff --git a/AGENTS.md b/AGENTS.md index 30f8b2a2c..fc1bb5713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ and deserialized through a registry. | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. | | `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | | `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_`), aliased through `[artifacts]`. It has no tasks either. | -| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`. It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. `tst/integration/computer/` serves it on a scaffold desktop image and runs the conformance probe and an agent against it. | +| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`. It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. `tst/integration/computer/` serves it on the `ubuntu-24.04` base in a scaffold image and runs the conformance probe and an agent against it. | | `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | ## Extension points diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md index 1caa7628f..0ee3aab9c 100644 --- a/src/agent_env/computers/README.md +++ b/src/agent_env/computers/README.md @@ -16,7 +16,8 @@ Its settings reach the computer as environment variables, `AGENTENV_SETTING_`, which waits for X, and +with `startsecs=0`, so X dying however often never leaves it `FATAL`, as the XFCE session does. This bundle has no tasks of its own: other bundles pull its computers by alias, and `agent-env run computers` has nothing to run. diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop index d30fbc688..4d4701015 100755 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop @@ -1,7 +1,7 @@ #!/bin/sh # The base's start, with the settings a deploy passes as AGENTENV_SETTING_, their defaults artifact.toml's: -# init applies the time zone for every program and starts supervisord, which runs xvfb at the resolution and then the -# XFCE session once X answers. +# init applies the time zone for every program and starts supervisord, which runs xvfb at the resolution, and with-x +# runs a program, the XFCE session first, once X answers. set -eu resolution="${AGENTENV_SETTING_RESOLUTION:-1280x800}" echo "$resolution" | grep -Eqx '[0-9]+x[0-9]+' || { echo "resolution is x, not '$resolution'" >&2; exit 2; } @@ -15,11 +15,12 @@ case "${1:-}" in xvfb) exec Xvfb "$DISPLAY" -screen 0 "${resolution}x24" -nolisten tcp -ac ;; - session) + with-x) + shift for _ in $(seq 100); do xdpyinfo >/dev/null 2>&1 && break; sleep 0.1; done - exec xfce4-session + exec "$@" ;; *) - echo "usage: start-desktop init|xvfb|session" >&2; exit 2 + echo "usage: start-desktop init|xvfb|with-x " >&2; exit 2 ;; esac diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf index dfb4851e9..f88c6c94b 100644 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf @@ -31,9 +31,9 @@ redirect_stderr=true stdout_logfile=/var/log/agentenv/dbus.log ; The session exits whenever X does, often within its first seconds, so it counts as started at once and X dying -; however often never leaves it FATAL; start-desktop waits for X before each start. +; however often never leaves it FATAL; with-x waits for X before each start. [program:desktop] -command=/usr/local/bin/start-desktop session +command=/usr/local/bin/start-desktop with-x xfce4-session priority=20 autorestart=true startsecs=0 diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 6bf8d0ab0..61d54d4b4 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -1,6 +1,7 @@ -"""The ubuntu-24.04 base computer, built from its folder: its desktop runs as the session user with the settings a deploy -passes and comes back however often X dies, a setting it can't use stops it at start, and pushed to the local registry it -registers as a computer artifact whose image put reads back. Needs Docker and the local registry on localhost:5000.""" +"""The ubuntu-24.04 base computer, built from its folder: its desktop runs as the session user with the settings a +deploy passes and comes back however often X dies, a setting it can't use stops it at start, and pushed to the local +registry it registers as a computer artifact whose image put reads back. Needs Docker and the local registry on +localhost:5000.""" import json import platform @@ -8,31 +9,21 @@ import time import tomllib import uuid -from pathlib import Path import pytest -import agent_env from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.config import configure, reset_config +from tst.integration.computer.conftest import BASE pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] -BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" SUPERVISOR = ["supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status"] ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine().lower()] -@pytest.fixture(scope="module") -def image(): - tag = f"localhost:5000/agentenv-test-ubuntu-24.04:{uuid.uuid4().hex[:8]}" - _docker("build", "-q", "-t", tag, str(BASE)) - yield tag - subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) - - @pytest.fixture def stores(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) @@ -46,10 +37,10 @@ def stores(monkeypatch, tmp_path): reset_config() -def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes(image): +def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes(base): name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" _docker("run", "-d", "--name", name, "-e", "AGENTENV_SETTING_RESOLUTION=1600x900", - "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", image) + "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", base) try: running = _all_running(name) users = set(_exec(name, "ps", "-eo", "user=").split()) @@ -68,9 +59,9 @@ def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes( assert int(accessibility) == 1 -def test_the_desktop_comes_back_however_often_x_dies(image): +def test_the_desktop_comes_back_however_often_x_dies(base): name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" - _docker("run", "-d", "--name", name, image) + _docker("run", "-d", "--name", name, base) try: _all_running(name) for _ in range(10): @@ -95,17 +86,17 @@ def test_the_desktop_comes_back_however_often_x_dies(image): ("RESOLUTION", "big", "resolution is x, not 'big'"), ("TIMEZONE", "Mars/Olympus", "timezone 'Mars/Olympus' isn't in /usr/share/zoneinfo"), ], ids=["resolution", "timezone"]) -def test_a_setting_it_cant_use_stops_it_at_start(image, setting, value, error): - stopped = subprocess.run(["docker", "run", "--rm", "-e", f"AGENTENV_SETTING_{setting}={value}", image], +def test_a_setting_it_cant_use_stops_it_at_start(base, setting, value, error): + stopped = subprocess.run(["docker", "run", "--rm", "-e", f"AGENTENV_SETTING_{setting}={value}", base], capture_output=True, text=True, timeout=120) assert stopped.returncode == 2 and error in stopped.stderr -def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_back(image, stores): - _docker("push", "-q", image) +def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_back(base, stores): + _docker("push", "-q", base) declared = tomllib.loads((BASE / "artifact.toml").read_text()) - pinned = DockerImageArtifact.put_ref("base-image", description="the base", image_name=image) + pinned = DockerImageArtifact.put_ref("base-image", description="the base", image_name=base) computer = ComputerArtifact.put(id="base", arch=[ARCH], os=declared["os"], session=declared["session"], config=declared["config"], @@ -113,7 +104,8 @@ def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_b "image": {"id": pinned.id, "version": pinned.version}}}) found = computer.metadata["validated_images"]["container"][ARCH] - assert (found["status"], found["os_release"]) == ("passed", {"ID": "ubuntu", "VERSION_ID": "24.04"}), json.dumps(found) + assert found["status"] == "passed", json.dumps(found) + assert found["os_release"] == {"ID": "ubuntu", "VERSION_ID": "24.04"} assert found["image"] == pinned.image_name diff --git a/tst/integration/computer/conftest.py b/tst/integration/computer/conftest.py new file mode 100644 index 000000000..c5545cce2 --- /dev/null +++ b/tst/integration/computer/conftest.py @@ -0,0 +1,19 @@ +"""The ubuntu-24.04 base computer, built from its folder once for the tests here and tagged for the local registry.""" + +import subprocess +import uuid +from pathlib import Path + +import pytest + +import agent_env + +BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" + + +@pytest.fixture(scope="session") +def base(): + tag = f"localhost:5000/agentenv-test-ubuntu-24.04:{uuid.uuid4().hex[:8]}" + subprocess.run(["docker", "build", "-q", "-t", tag, str(BASE)], check=True, capture_output=True) + yield tag + subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) diff --git a/tst/integration/computer/recording_chaos_test.py b/tst/integration/computer/recording_chaos_test.py index b92b45b54..9c75fa36f 100644 --- a/tst/integration/computer/recording_chaos_test.py +++ b/tst/integration/computer/recording_chaos_test.py @@ -28,9 +28,10 @@ @pytest.fixture(scope="module") -def image(tmp_path_factory): +def image(tmp_path_factory, base): tag = f"agentenv-test-recording-desk:{uuid.uuid4().hex[:8]}" - _docker("build", "-q", "-t", tag, str(_scaffold(tmp_path_factory.mktemp("desk") / "scaffold") / "envs" / "desktop")) + scaffold = _scaffold(tmp_path_factory.mktemp("desk") / "scaffold", base) + _docker("build", "-q", "-t", tag, str(scaffold / "envs" / "desktop")) yield tag subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) diff --git a/tst/integration/computer/scaffold/envs/desktop/Dockerfile b/tst/integration/computer/scaffold/envs/desktop/Dockerfile index b2a3c9140..c05df40f7 100644 --- a/tst/integration/computer/scaffold/envs/desktop/Dockerfile +++ b/tst/integration/computer/scaffold/envs/desktop/Dockerfile @@ -1,18 +1,12 @@ -# The scaffold: the reference driver on a hand-written Ubuntu 24.04 desktop, until agent-env composes computers. The -# test stages driver.py, fixture.py and the protocol package (protocol/) beside this file. -FROM public.ecr.aws/docker/library/ubuntu:24.04 -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-venv python3-tk xvfb x11-utils fonts-dejavu-core procps tini \ - libxcb1 libxcb-render0 libxcb-shm0 libxcb-randr0 libxcb-xfixes0 libxcb-xinerama0 \ - && rm -rf /var/lib/apt/lists/* +# The scaffold: the reference driver and the Fixture Desk on the ubuntu-24.04 base computer, until agent-env composes +# computers. The test builds the base and names it in BASE, and stages driver.py, fixture.py and the protocol package +# (protocol/) beside this file. +ARG BASE +FROM ${BASE} +USER root COPY protocol /opt/protocol RUN python3 -m venv /opt/driver \ && /opt/driver/bin/pip install --no-cache-dir "/opt/protocol[computer]" "mss>=9,<11" "python-xlib>=0.33,<0.34" -RUN useradd --create-home --uid 10001 user -COPY start.sh driver.py fixture.py /opt/desktop/ +COPY driver.py fixture.py /opt/desktop/ +COPY desktop.conf /etc/supervisor/conf.d/desktop.conf USER user -WORKDIR /home/user -ENV DISPLAY=:99 XDG_SESSION_TYPE=x11 -ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["/bin/sh", "/opt/desktop/start.sh"] diff --git a/tst/integration/computer/scaffold/envs/desktop/desktop.conf b/tst/integration/computer/scaffold/envs/desktop/desktop.conf new file mode 100644 index 000000000..036b30b9e --- /dev/null +++ b/tst/integration/computer/scaffold/envs/desktop/desktop.conf @@ -0,0 +1,14 @@ +[program:reference-driver] +command=/opt/driver/bin/python /opt/desktop/driver.py +priority=30 +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/agentenv/reference-driver.log + +[program:fixture-desk] +command=/usr/local/bin/start-desktop with-x python3 /opt/desktop/fixture.py /home/user/fixture +priority=30 +autorestart=true +startsecs=0 +redirect_stderr=true +stdout_logfile=/var/log/agentenv/fixture-desk.log diff --git a/tst/integration/computer/scaffold/envs/desktop/start.sh b/tst/integration/computer/scaffold/envs/desktop/start.sh deleted file mode 100644 index dce5b94a7..000000000 --- a/tst/integration/computer/scaffold/envs/desktop/start.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -# Xvfb and the Fixture Desk each restart if they die; the driver serves on MCP_PORT. -(while true; do Xvfb :99 -screen 0 1280x800x24 -nolisten tcp -ac >/tmp/xvfb.log 2>&1; sleep 0.5; done) & -for i in $(seq 100); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.1; done -(while true; do - for i in $(seq 100); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.1; done - python3 /opt/desktop/fixture.py "$HOME/fixture" >/tmp/fixture.log 2>&1; sleep 0.5 - done) & -exec /opt/driver/bin/python /opt/desktop/driver.py diff --git a/tst/integration/computer/scaffold_test.py b/tst/integration/computer/scaffold_test.py index 01b951bca..0d5a984eb 100644 --- a/tst/integration/computer/scaffold_test.py +++ b/tst/integration/computer/scaffold_test.py @@ -1,8 +1,8 @@ -"""The reference driver on a desktop of its own before agent-env can compose a computer: the scaffold, a hand-written -Ubuntu 24.04 image under this folder, deployed by ``agent-env run`` as an MCP server on the server provider, passes -the conformance probe at full three runs in a row, records a scripted drive of its desk that lines up with the drive's -screenshots, and an agent drives its desktop through the computer_* tools under a recording. Needs a Docker daemon and -the local registry; the agent's task, a model endpoint too.""" +"""The reference driver on a desktop of its own before agent-env can compose a computer: the scaffold, an image under +this folder that lays it on the ubuntu-24.04 base, deployed by ``agent-env run`` as an MCP server on the server +provider, passes the conformance probe at full three runs in a row, records a scripted drive of its desk that lines up +with the drive's screenshots, and an agent drives its desktop through the computer_* tools under a recording. Needs a +Docker daemon and the local registry; the agent's task, a model endpoint too.""" import json import logging @@ -58,11 +58,13 @@ def state(monkeypatch, tmp_path): reset_config() -def _scaffold(root: Path) -> Path: - """The scaffold bundle with the reference driver, the probe's fixture and this checkout's protocol beside its - Dockerfile.""" +def _scaffold(root: Path, base: str) -> Path: + """The scaffold bundle on the base image ``base``, with the reference driver, the probe's fixture and this + checkout's protocol beside its Dockerfile.""" shutil.copytree(SCAFFOLD, root, ignore=shutil.ignore_patterns("__pycache__")) desktop = root / "envs" / "desktop" + dockerfile = desktop / "Dockerfile" + dockerfile.write_text(dockerfile.read_text().replace("ARG BASE\n", f"ARG BASE={base}\n")) shutil.copy(DRIVER, desktop / "driver.py") shutil.copy(FIXTURE, desktop / "fixture.py") shutil.copytree(PROTOCOL / "src", desktop / "protocol" / "src", ignore=shutil.ignore_patterns("__pycache__")) @@ -104,15 +106,15 @@ def _thumb(image: Image.Image) -> bytes: return image.convert("L").resize((64, 40)).tobytes() -def test_the_reference_driver_passes_the_probe_at_full(state): - ran = _run(_scaffold(state / "scaffold"), "probe") +def test_the_reference_driver_passes_the_probe_at_full(state, base): + ran = _run(_scaffold(state / "scaffold", base), "probe") assert ran.exit_code == 0, ran.output assert "passed (probe: 1)" in ran.output, ran.output -def test_a_scripted_drive_is_recorded_on_the_clock_its_screenshots_carry(state): - ran = _run(_scaffold(state / "scaffold"), "recording") +def test_a_scripted_drive_is_recorded_on_the_clock_its_screenshots_carry(state, base): + ran = _run(_scaffold(state / "scaffold", base), "recording") assert ran.exit_code == 0, ran.output assert "passed (drive: 1)" in ran.output, ran.output @@ -139,8 +141,8 @@ def test_a_scripted_drive_is_recorded_on_the_clock_its_screenshots_carry(state): @skip_without_model_endpoint() -def test_an_agent_drives_the_reference_drivers_desktop_under_a_recording(state): - ran = _run(_scaffold(state / "scaffold"), "agent") +def test_an_agent_drives_the_reference_drivers_desktop_under_a_recording(state, base): + ran = _run(_scaffold(state / "scaffold", base), "agent") assert ran.exit_code == 0, ran.output assert "passed (check: 1)" in ran.output, ran.output From 04628c58acf27a996016dffbc59dfa07e75b709f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:31:15 -0700 Subject: [PATCH 10/22] fix(computer): refuse a computer whose image isn't a stored image artifact, inspected or not put read the form's image artifact only to inspect it, so put(inspect=False) wrote a computer naming a missing artifact, a missing version or a file. It now loads the image on every put and refuses one the store doesn't hold. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 21 ++++++++---- tst/unit/artifact/test_computer_artifact.py | 36 ++++++++++++++++---- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index a30fb5e24..464f00cdc 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -20,6 +20,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.ref import ArtifactRef from agent_env.entity.refs import EntityRef +from agent_env.store.base import NotFoundError from agent_env.store.ids import derive_id from agent_env.store.image_store.oci_registry_credentials import names_registry from agent_env.store.image_store.registry_api import read_blob, read_manifest @@ -144,11 +145,14 @@ def _setting_names(cls, value: dict[str, Setting]) -> dict[str, Setting]: @classmethod def put(cls, *, inspect: bool = True, **fields: Any) -> Self: """Write a computer, reading each form's image first unless ``inspect`` is False. Refused, naming every - problem by form and architecture, when an image isn't what the computer declares.""" + problem by form and architecture, when an image isn't what the computer declares, and inspected or not, when + it isn't a stored docker_image artifact.""" from agent_env.artifact.store import get_artifact_store computer = cls(**fields) - found = computer.inspect() if inspect else {"container": _unread(computer.arch, "inspection was skipped")} + image = computer.image() + found = {"container": _inspect_container(image, computer.arch, computer.os) if inspect + else _unread(computer.arch, "inspection was skipped")} problems = [f"forms.{form} ({arch}): {result['problem']}" for form, by_arch in found.items() for arch, result in by_arch.items() if result["status"] == "failed"] if problems: @@ -184,11 +188,14 @@ def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self: except ValueError as e: ctx.refuse([str(e)]) - def inspect(self) -> dict[str, dict[str, dict[str, Any]]]: - """What each form's image holds for each architecture, read from its registry: ``passed``, ``failed`` with the - problem, or ``uninspected`` with why, for an image no registry serves yet.""" - image = DockerImageArtifact.get(self.forms.container.image.id, self.forms.container.image.version) - return {"container": _inspect_container(image, self.arch, self.os)} + def image(self) -> DockerImageArtifact: + """The container form's image artifact, refused when the store has no docker_image artifact of that id and + version.""" + ref = self.forms.container.image + try: + return DockerImageArtifact.get(ref.id, ref.version) + except (NotFoundError, TypeError) as e: + raise ValueError(f"computer {self.id!r}: forms.container.image: {e}") from e def _folder_image(image_id: str, folder: Path, key: Any) -> dict[str, Any]: diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index 0b8846526..83ce928d9 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -8,6 +8,7 @@ from agent_env.artifact.artifacts import computer as computer_module from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.store import get_artifact_store from tst.util.oci_registry import MANIFEST, UBUNTU, UBUNTU_LAYER, Registry, layer @@ -24,13 +25,15 @@ def _image(name: str) -> DockerImageArtifact: def _put(image: DockerImageArtifact, **changes) -> ComputerArtifact: - fields = {"id": "desktop", "description": "a desktop", "arch": ["amd64", "arm64"], - "os": {"type": "linux", "distribution": "ubuntu", "version": "24.04", "display": "x11"}, - "session": {"user": "user"}, - "forms": {"container": {"image": {"id": image.id, "version": image.version}, "init": "supervisord", - "input_channel": "env"}}, - "config": {"resolution": {"default": "1280x800", "phase": "deploy"}}} - return ComputerArtifact.put(**{**fields, **changes}) + forms = {"container": {"image": {"id": image.id, "version": image.version}, "init": "supervisord", + "input_channel": "env"}} + return ComputerArtifact.put(**{**_fields(), "forms": forms, **changes}) + + +def _fields() -> dict: + return {"id": "desktop", "description": "a desktop", "arch": ["amd64", "arm64"], + "os": {"type": "linux", "distribution": "ubuntu", "version": "24.04", "display": "x11"}, + "session": {"user": "user"}, "config": {"resolution": {"default": "1280x800", "phase": "deploy"}}} def test_a_computer_whose_image_is_what_it_declares_is_written_with_what_was_read(registry): @@ -93,6 +96,25 @@ def test_an_image_no_registry_serves_yet_or_a_skipped_inspection_is_written_unin assert registry.fetched == [] +@pytest.mark.parametrize("inspect", [True, False], ids=["inspected", "uninspected"]) +@pytest.mark.parametrize("image_id, version, problem", [ + ("nothing", 1, "Artifact nothing version=1 not found"), + ("desktop-image", 2, "Artifact desktop-image version=2 not found"), + ("notes", 1, "artifact 'notes' is a 'file' artifact, not a DockerImageArtifact"), +], ids=["missing", "missing-version", "not-an-image"]) +def test_an_image_the_store_has_no_image_artifact_for_refuses_the_put_inspected_or_not( + registry, inspect, image_id, version, problem): + _image(f"ghcr.io/acme/desktop:1@{registry.index(amd64=registry.image(UBUNTU_LAYER))}") + get_artifact_store().put_document(FileArtifact(id="notes", description="", filename="a", content_type="text/plain", + object_url="mem://b/a")) + with pytest.raises(ValueError, match="computer 'desktop'") as refused: + ComputerArtifact.put(inspect=inspect, **{**_fields(), "forms": {"container": { + "image": {"id": image_id, "version": version}, "init": "supervisord", "input_channel": "env"}}}) + + assert str(refused.value) == f"computer 'desktop': forms.container.image: {problem}" + assert get_artifact_store().next_version("desktop") == 1 and registry.fetched == [] + + @pytest.mark.parametrize("changes, message", [ ({"os": {"type": "windows", "version": "11"}}, "os.type 'windows' isn't supported yet"), ({"forms": {"disk": {}}}, "the disk form isn't supported yet"), From c37df7a5e18737f16cd92cfcdba5b3770efc9d53 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:31:18 -0700 Subject: [PATCH 11/22] fix(computer): refuse a resolution outside 1 to 32767 pixels a side at the base's start Any digits passed the check, so a width Xvfb or X can't use left a container whose desktop never came up, not the exit 2 a setting it can't use gives. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/computers/README.md | 2 +- .../computers/artifacts/ubuntu-24.04/start-desktop | 4 +++- tst/integration/computer/base_computer_test.py | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md index 0ee3aab9c..c5d9275c8 100644 --- a/src/agent_env/computers/README.md +++ b/src/agent_env/computers/README.md @@ -11,7 +11,7 @@ Its settings reach the computer as environment variables, `AGENTENV_SETTING_x` | +| `resolution` | `1280x800` | The size of the Xvfb screen, as `x`, each 1 to 32767 pixels | | `timezone` | `UTC` | The time zone every program runs in, a name under `/usr/share/zoneinfo` | A value it can't use stops the computer at start, with the reason on stderr. Composing a driver or an app onto it adds diff --git a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop index 4d4701015..d3dbf99f9 100755 --- a/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop @@ -4,7 +4,9 @@ # runs a program, the XFCE session first, once X answers. set -eu resolution="${AGENTENV_SETTING_RESOLUTION:-1280x800}" -echo "$resolution" | grep -Eqx '[0-9]+x[0-9]+' || { echo "resolution is x, not '$resolution'" >&2; exit 2; } +width="${resolution%%x*}" height="${resolution#*x}" +echo "$resolution" | grep -Eqx '[1-9][0-9]{0,4}x[1-9][0-9]{0,4}' && [ "$width" -le 32767 ] && [ "$height" -le 32767 ] \ + || { echo "resolution is x, each 1 to 32767 pixels, not '$resolution'" >&2; exit 2; } case "${1:-}" in init) timezone="${AGENTENV_SETTING_TIMEZONE:-UTC}" diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 61d54d4b4..4e3d2648c 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -83,9 +83,11 @@ def test_the_desktop_comes_back_however_often_x_dies(base): @pytest.mark.parametrize("setting, value, error", [ - ("RESOLUTION", "big", "resolution is x, not 'big'"), + ("RESOLUTION", "big", "resolution is x, each 1 to 32767 pixels, not 'big'"), + ("RESOLUTION", "40000x800", "resolution is x, each 1 to 32767 pixels, not '40000x800'"), + ("RESOLUTION", "1280x0", "resolution is x, each 1 to 32767 pixels, not '1280x0'"), ("TIMEZONE", "Mars/Olympus", "timezone 'Mars/Olympus' isn't in /usr/share/zoneinfo"), -], ids=["resolution", "timezone"]) +], ids=["resolution", "resolution-too-wide", "resolution-zero", "timezone"]) def test_a_setting_it_cant_use_stops_it_at_start(base, setting, value, error): stopped = subprocess.run(["docker", "run", "--rm", "-e", f"AGENTENV_SETTING_{setting}={value}", base], capture_output=True, text=True, timeout=120) From ef7ac0aaad386cd88799c1b6302265d35afd70cb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:49:30 -0700 Subject: [PATCH 12/22] fix(computer): read an image recorded by its tag alone at the digest its registry serves DockerImageArtifact.put records the image store's tag with no digest, so a single-platform image's manifest digest came out as the whole tag and its layers couldn't be read. Inspection now pins such a ref first, as put_ref does, and records what it read by that digest. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 3 ++- tst/unit/artifact/test_computer_artifact.py | 13 +++++++++++++ tst/util/oci_registry.py | 9 +++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index 464f00cdc..d7ded1f55 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -23,7 +23,7 @@ from agent_env.store.base import NotFoundError from agent_env.store.ids import derive_id from agent_env.store.image_store.oci_registry_credentials import names_registry -from agent_env.store.image_store.registry_api import read_blob, read_manifest +from agent_env.store.image_store.registry_api import pin_digest, read_blob, read_manifest if TYPE_CHECKING: from agent_env.bundle.authoring import AuthoringContext @@ -223,6 +223,7 @@ def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxO ref = image.pinned_name auth = get_config().get_image_store().auth(ref) try: + ref = ref if "@" in ref else pin_digest(ref, auth) manifests = _platform_manifests(ref, auth) except ValueError as e: return {arch: _result("failed", ref, problem=str(e)) for arch in arches} diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index 83ce928d9..2642e8947 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -15,6 +15,7 @@ @pytest.fixture def registry(local_stores, monkeypatch): registry = Registry() + monkeypatch.setattr(computer_module, "pin_digest", registry.pin_digest) monkeypatch.setattr(computer_module, "read_manifest", registry.read_manifest) monkeypatch.setattr(computer_module, "read_blob", registry.read_blob) return registry @@ -71,6 +72,18 @@ def test_an_image_that_isnt_what_the_computer_declares_refuses_the_put(registry, assert get_artifact_store().next_version("desktop") == 1 +@pytest.mark.parametrize("served", ["single", "index"]) +def test_an_image_recorded_by_its_tag_alone_is_read_at_the_digest_its_registry_serves_for_it(registry, served): + single = registry.image(UBUNTU_LAYER) + registry.tags["localhost:5000/desktop-image:v1"] = single if served == "single" else registry.index(amd64=single) + + computer = _put(_image("localhost:5000/desktop-image:v1"), arch=["amd64"]) + + found = computer.metadata["validated_images"]["container"]["amd64"] + assert (found["status"], found["manifest"]) == ("passed", single) + assert found["image"] == f"localhost:5000/desktop-image:v1@{registry.tags['localhost:5000/desktop-image:v1']}" + + def test_os_release_comes_from_the_lowest_layer_holding_it_and_a_single_image_is_read_by_its_config(registry): desktop = layer({"etc/motd": b"welcome\n", "etc/os-release": b"ID=xfce\n"}) single = registry.image(UBUNTU_LAYER, desktop) diff --git a/tst/util/oci_registry.py b/tst/util/oci_registry.py index d94dc61f8..5daaa92b9 100644 --- a/tst/util/oci_registry.py +++ b/tst/util/oci_registry.py @@ -1,5 +1,5 @@ """A registry serving OCI images from memory, for code that reads an image's manifests and layers without pulling it: -index, image manifest and blob by digest, each blob fetched recorded.""" +index, image manifest and blob by digest, a tag pinned to the digest it names, each blob fetched recorded.""" import hashlib import io @@ -17,7 +17,7 @@ class Registry: """A registry serving the manifests and blobs put in it, by digest, and recording the blobs fetched.""" def __init__(self): - self.manifests, self.blobs, self.fetched = {}, {}, [] + self.manifests, self.blobs, self.tags, self.fetched = {}, {}, {}, [] def blob(self, data: bytes) -> str: digest = f"sha256:{hashlib.sha256(data).hexdigest()}" @@ -40,6 +40,11 @@ def _manifest(self, manifest: dict) -> str: self.manifests[digest] = manifest return digest + def pin_digest(self, ref, auth): + if ref not in self.tags: + raise NoSuchImageError(f"{ref}: ghcr.io has no such image") + return f"{ref}@{self.tags[ref]}" + def read_manifest(self, ref, auth): digest = ref.rpartition("@")[2] if digest not in self.manifests: From 6c5575124a4d8fc72cc72da71075c29a61123277 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 12:53:34 -0700 Subject: [PATCH 13/22] test(computer): a released install writes the shipped base over its published image The planner already registers a shipped image published for a bundle image's sources, the base's among them; this shows it does for ubuntu-24.04, with nothing built and both architectures read back from the registry. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 4 +- src/agent_env/env/published_images.py | 3 +- tst/unit/bundle/shipped_computers_test.py | 45 +++++++++++++++++++---- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index c8038bf84..dd6844037 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -1056,8 +1056,8 @@ def _ordered(self, closure: set[_Key]) -> tuple[Write, ...]: return tuple(writes) def _published(self, writes: Sequence[Write]) -> dict[str, Publication]: - """The published image of each image in ``writes`` built from the sources of a shipped bundle image, an agent's - or an example env's, by id.""" + """The published image of each image in ``writes`` built from the sources of a shipped bundle image, an agent's, + an example env's or a base computer's, by id.""" published = {} for write in writes: image = write.source diff --git a/src/agent_env/env/published_images.py b/src/agent_env/env/published_images.py index 8dc156834..cf7d4ad1e 100644 --- a/src/agent_env/env/published_images.py +++ b/src/agent_env/env/published_images.py @@ -71,7 +71,8 @@ def published_digest(image: PublishedImage) -> str | None: def published_bundle_image(sources: str) -> tuple[str, str] | None: - """The ref and digest of the image published for a shipped bundle's image (an agent's or an example env's) whose + """The ref and digest of the image published for a shipped bundle's image (an agent's, an example env's or a base + computer's) whose installed build context has the source digest ``sources`` (``BuildContext.source_digest(None)``), or None when no shipped image has those sources or the registry doesn't serve it anonymously, as a sandbox pulls it: a new package stays private until a package admin makes it public, and a sandbox running an image by name can't build it diff --git a/tst/unit/bundle/shipped_computers_test.py b/tst/unit/bundle/shipped_computers_test.py index da7877dc2..68b6d8b46 100644 --- a/tst/unit/bundle/shipped_computers_test.py +++ b/tst/unit/bundle/shipped_computers_test.py @@ -1,5 +1,6 @@ -"""The computers bundle agent-env ships: a task naming ubuntu-24.04 writes its base, the image built from the folder's -Dockerfile, and the start script falls back to the defaults artifact.toml declares.""" +"""The computers bundle agent-env ships: a task naming ubuntu-24.04 writes its base, its image built from the folder's +Dockerfile, or once a release has published it, the published image, read back from its registry; and the start script +falls back to the defaults artifact.toml declares.""" import json import re @@ -9,6 +10,7 @@ import pytest import agent_env +from agent_env.artifact.artifacts import computer as computer_module from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.bundle import parse_bundle @@ -17,7 +19,9 @@ from agent_env.bundle.plan import AliasUse, plan_bundle from agent_env.bundle.resolve import resolve_bundle from agent_env.entity.refs import EntityKind +from agent_env.env.published_images import COMPUTER_IMAGES from tst.unit.bundle._support import layout, local_builds +from tst.util.oci_registry import UBUNTU_LAYER, Registry BASE = Path(agent_env.__file__).parent / "computers" / "artifacts" / "ubuntu-24.04" UBUNTU = "@local/agentenv-framework/computers/ubuntu-24.04" @@ -31,12 +35,7 @@ def unpublished(monkeypatch): def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_image( tmp_path, monkeypatch, local_stores, cli_routing, builds, unpublished): monkeypatch.setenv("HOME", str(tmp_path)) - layout(tmp_path / "uses", { - "envs/crm/Dockerfile": "FROM scratch\n", "envs/crm/env.toml": 'environment_name = "crm"\n', - "tasks/t.json": json.dumps([{"id": "crm", "type": "deploy_env", "env_id": "crm"}, - {"id": "base", "type": "load_artifact", "env_id": "crm", "artifact_id": "ubuntu-24.04", - "depends_on": ["crm"]}])}) - plan = plan_bundle(resolve_bundle(parse_bundle(tmp_path / "uses")), installed=installed_bundles) + plan = _naming_ubuntu(tmp_path) done = materialize(plan, build_here=local_builds(plan)) @@ -51,8 +50,38 @@ def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_ima assert computer.metadata["validated_images"]["container"]["arm64"]["status"] == "uninspected" +def test_a_task_naming_ubuntu_24_04_after_a_release_writes_the_shipped_base_over_its_published_image( + tmp_path, monkeypatch, local_stores, cli_routing, builds): + served = Registry() + index = served.index(amd64=served.image(UBUNTU_LAYER), arm64=served.image(UBUNTU_LAYER, arch="arm64")) + monkeypatch.setattr("agent_env.env.published_images._registry_digest", lambda ref: index) + monkeypatch.setattr(computer_module, "read_manifest", served.read_manifest) + monkeypatch.setattr(computer_module, "read_blob", served.read_blob) + monkeypatch.setenv("HOME", str(tmp_path)) + plan = _naming_ubuntu(tmp_path) + + done = materialize(plan, build_here=local_builds(plan)) + + computer = ComputerArtifact.get(UBUNTU, done.version_of("artifact", UBUNTU)) + image = DockerImageArtifact.get(computer.forms.container.image.id, computer.forms.container.image.version) + assert (image.image_name, image.image_digest) == (COMPUTER_IMAGES[0].ref(), index) + assert not any(call["build"][2] == image.image_name for call in builds) + found = computer.metadata["validated_images"]["container"] + assert {arch: (r["status"], r["image"]) for arch, r in found.items()} == { + arch: ("passed", f"{image.image_name}@{index}") for arch in ("amd64", "arm64")} + + def test_the_start_script_falls_back_to_the_defaults_artifact_toml_declares(): declared = tomllib.loads((BASE / "artifact.toml").read_text())["config"] script = (BASE / "start-desktop").read_text() fallbacks = dict(re.findall(r'\$\{AGENTENV_SETTING_([A-Z_]+):-([^}]*)\}', script)) assert fallbacks == {name.upper(): setting["default"] for name, setting in declared.items()} + + +def _naming_ubuntu(root): + layout(root / "uses", { + "envs/crm/Dockerfile": "FROM scratch\n", "envs/crm/env.toml": 'environment_name = "crm"\n', + "tasks/t.json": json.dumps([{"id": "crm", "type": "deploy_env", "env_id": "crm"}, + {"id": "base", "type": "load_artifact", "env_id": "crm", "artifact_id": "ubuntu-24.04", + "depends_on": ["crm"]}])}) + return plan_bundle(resolve_bundle(parse_bundle(root / "uses")), installed=installed_bundles) From 6b37a128fd13a7f7bf67a20221b057c911e1c834 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:25:27 -0700 Subject: [PATCH 14/22] fix(artifact): keep an environment universe's legacy metadata key out of artifact metadata A stored universe's metadata key holds its legacy metadata file ids, the key artifact metadata now uses, so reading one filled its artifact metadata with them, and merging into it would have left a document no version can load. A universe reads the key as its legacy ids only and keeps no artifact metadata. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../artifact/artifacts/environment_universe.py | 15 ++++++++++++++- tst/unit/artifact/test_artifact_metadata.py | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc1bb5713..19b082bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ and deserialized through a registry. | Package | Contents | |---|---| | `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset` and `load_file_artifact_universe`. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed; its deploy is refused until agent-env creates computers). `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | -| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | +| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. | | `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/`, `start_computer_recording` and `end_computer_recording`, whose open recordings `teardown_run` and `teardown_sandboxes` end first, and more). `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `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. `store/routing.py` is the `@local` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@local` task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. 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. | diff --git a/src/agent_env/artifact/artifacts/environment_universe.py b/src/agent_env/artifact/artifacts/environment_universe.py index 933746ef6..458677e0b 100644 --- a/src/agent_env/artifact/artifacts/environment_universe.py +++ b/src/agent_env/artifact/artifacts/environment_universe.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional -from pydantic import AliasChoices, ConfigDict, Field, model_serializer +from pydantic import AliasChoices, ConfigDict, Field, model_serializer, model_validator from agent_env.artifact.artifact import FolderChild from agent_env.artifact.artifacts.environment import EnvironmentArtifact, SeedsEnvironments @@ -76,6 +76,15 @@ class EnvironmentUniverseArtifact(Universe, SeedsEnvironments): description="Legacy unpinned metadata FileArtifact ids", ) + @model_validator(mode="before") + @classmethod + def _metadata_is_legacy(cls, data: Any) -> Any: + """A stored universe's ``metadata`` key holds its legacy metadata file ids, never artifact metadata.""" + if isinstance(data, dict) and "metadata" in data: + data = {**data, "legacy_metadata": data["metadata"]} + del data["metadata"] + return data + # No return annotation: pydantic builds the serialization schema from one, and a dict drops the fields. @model_serializer(mode="wrap") def _serialize(self, handler: Any): @@ -106,6 +115,10 @@ def _serialize(self, handler: Any): data["environment_artifact_refs"] = refs return data + def merge_metadata(self, updates: dict[str, Any], retries: int = 3) -> None: + raise TypeError(f"artifact {self.id!r} is an environment universe, whose metadata key holds its legacy metadata " + f"file ids, so it keeps no artifact metadata") + @classmethod def put( cls, diff --git a/tst/unit/artifact/test_artifact_metadata.py b/tst/unit/artifact/test_artifact_metadata.py index e6edb69f5..e75445b1c 100644 --- a/tst/unit/artifact/test_artifact_metadata.py +++ b/tst/unit/artifact/test_artifact_metadata.py @@ -1,14 +1,29 @@ """An artifact version's metadata: written with it, then changed by compare-and-swap without touching its versioned -fields, merging with a concurrent writer's keys rather than overwriting them.""" +fields, merging with a concurrent writer's keys rather than overwriting them. An environment universe, whose stored +metadata key holds its legacy metadata file ids, keeps none.""" import pytest from agent_env.artifact import Artifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.artifacts.environment_universe import EnvironmentUniverseArtifact from agent_env.artifact.store import get_artifact_store from agent_env.store import ConcurrentModificationError, NotFoundError +def test_an_environment_universe_reads_its_metadata_key_as_its_legacy_file_ids_and_keeps_no_artifact_metadata( + local_stores): + legacy = get_artifact_store().put_document(EnvironmentUniverseArtifact.model_validate( + {"id": "universe", "description": "", "service_artifact_ids": ["env"], "metadata": {"readme": "file-1"}})) + + stored = Artifact.get("universe", legacy.version) + + assert (stored.legacy_metadata, stored.metadata) == ({"readme": "file-1"}, {}) + with pytest.raises(TypeError, match="keeps no artifact metadata"): + stored.merge_metadata({"validated": {"x": 1}}) + assert Artifact.get("universe", legacy.version).legacy_metadata == {"readme": "file-1"} + + def _image(**metadata) -> DockerImageArtifact: return get_artifact_store().put_document(DockerImageArtifact( id="desktop-image", description="a desktop", image_name="docker.io/library/ubuntu:24.04", metadata=metadata)) From dd93e6cd520f41f657b93f28f97295088d83ea28 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:34:40 -0700 Subject: [PATCH 15/22] refactor(bundle): key the planner's alias tables and uses by the kind they name The planner kept a table, uses and stored versions apiece for agent, env and artifact aliases, and the plan three fields; one mapping by kind holds them now, so a kind of alias adds one entry rather than a set of fields. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 84 +++++++++------------- src/agent_env/bundle/preflight.py | 3 +- src/agent_env/bundle/run.py | 2 +- tst/unit/bundle/agent_override_test.py | 4 +- tst/unit/bundle/installed_entities_test.py | 34 +++++---- tst/unit/bundle/shipped_computers_test.py | 2 +- 6 files changed, 61 insertions(+), 68 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index dd6844037..fcb8f5e70 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -41,7 +41,8 @@ from agent_env.env.published_images import published_bundle_image from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION, get_env_store -from agent_env.plugins._bundles import ARTIFACT_ALIASES, ENV_ALIASES, InstalledAliases, installed_aliases +from agent_env.plugins._bundles import (AGENT_ALIASES, ARTIFACT_ALIASES, ENV_ALIASES, InstalledAliases, + installed_aliases) from agent_env.providers.env_providers.constants import ( AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, @@ -122,6 +123,9 @@ def unwritten(self, image_id: str) -> DockerImageArtifact: source_digest=self.sources) +# The aliases.toml table of each kind a task's reference may name by an alias. +_ALIAS_TABLES = {EntityKind.AGENT: AGENT_ALIASES, EntityKind.ENV: ENV_ALIASES, EntityKind.ARTIFACT: ARTIFACT_ALIASES} + @dataclass(frozen=True) class AliasUse: """An agent, env or artifact alias the run names, and what it names: the one an installed bundle gives the alias @@ -172,10 +176,9 @@ class Plan: # Each agent image the bundle builds from a shipped agent's sources, by id: the image published for them, which the # run registers in place of its build context alone. published: Mapping[str, Publication] = field(default_factory=dict) - aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each agent alias the run names, by alias + # Each alias the run names, by the kind it names and then by alias. + alias_uses: Mapping[EntityKind, Mapping[str, AliasUse]] = field(default_factory=dict) agent: AgentOverride | None = None # the agent --agent names for the solver - env_aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each env alias the run names, by alias - artifact_aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each artifact alias the run names def bundle_of(self, entry: BundleEntry) -> Bundle: """The bundle ``entry`` comes from: the installed one for an entry pulled in from one, else the run's.""" @@ -239,15 +242,11 @@ def __init__(self, resolved: ResolvedBundle, installed: Installed | None = None) self.others: tuple[InstalledBundle, ...] | None = None # the installed bundles but this one, once listed self.pullable: dict[str, _Pullable | str] = {} # each installed bundle read, by id root, or why it can't be self.unpulled: dict[tuple[EntityKind, str, int | None], str] = {} # why an installed bundle's id isn't pulled - self.alias_table: InstalledAliases | None = None # the installed agent aliases, once a plan reads them - self.alias_uses: dict[str, AliasUse] = {} # each agent alias the plan names, by alias - self.alias_stored: dict[str, int | None] = {} # the store's latest version of each alias's id, as read - self.env_alias_table: InstalledAliases | None = None - self.env_alias_uses: dict[str, AliasUse] = {} - self.env_alias_stored: dict[str, int | None] = {} - self.artifact_alias_table: InstalledAliases | None = None - self.artifact_alias_uses: dict[str, AliasUse] = {} - self.artifact_alias_stored: dict[str, int | None] = {} + # By kind: its installed aliases, once a plan reads them; each of them the plan names, by alias; and the store's + # latest version of each alias's id, as read. + self.alias_tables: dict[EntityKind, InstalledAliases] = {} + self.alias_uses: dict[EntityKind, dict[str, AliasUse]] = {kind: {} for kind in _ALIAS_TABLES} + self.alias_stored: dict[EntityKind, dict[str, int | None]] = {kind: {} for kind in _ALIAS_TABLES} self.pulling: set[_Key] = set() self.own = _by_id(resolved.bundle) # what a pulled entity's id names in this bundle images: dict[BundleEntry, list[BundleImage]] = {} @@ -300,23 +299,16 @@ def _default_agents(self, task: ResolvedEntry, agent_id: str | None = None) -> l # Agents named by an installed bundle's alias - def _resolve_aliases(self) -> dict[str, AliasUse]: + def _resolve_aliases(self) -> None: """Point each task's reference to an agent, env or artifact by an alias an installed bundle gives it, and the configured default agent when it's one, at what it names, pulled into the plan, unless the store has one of the alias's id, which the reference keeps naming. A field naming the alias is rewritten to the id. A reference that - can't be pointed at the alias's entity is left with the problem the store ref check reports. Returns the agent - aliases the plan names; ``env_alias_uses`` and ``artifact_alias_uses`` hold the others.""" + can't be pointed at the alias's entity is left with the problem the store ref check reports; ``alias_uses`` + holds the aliases the plan names.""" if self.installed is None: - return {} + return installed = self.installed() - self.alias_table, self.env_alias_table = installed_aliases(installed), installed_aliases(installed, ENV_ALIASES) - self.artifact_alias_table = installed_aliases(installed, ARTIFACT_ALIASES) - tables = {EntityKind.AGENT: self.alias_table, EntityKind.ENV: self.env_alias_table, - EntityKind.ARTIFACT: self.artifact_alias_table} - uses = {EntityKind.AGENT: self.alias_uses, EntityKind.ENV: self.env_alias_uses, - EntityKind.ARTIFACT: self.artifact_alias_uses} - stored = {EntityKind.AGENT: self.alias_stored, EntityKind.ENV: self.env_alias_stored, - EntityKind.ARTIFACT: self.artifact_alias_stored} + self.alias_tables = {kind: installed_aliases(installed, table) for kind, table in _ALIAS_TABLES.items()} default = _UNREAD for index, entry in enumerate(self.entries): if entry.entry.kind is not BundleKind.TASK: @@ -324,9 +316,8 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: references, aliased = [], {} for ref in entry.references: use = None - if ref.kind in tables and ref.local is None: - use = self._alias_use(tables[ref.kind], ref.kind, ref.id, ref.version, uses[ref.kind], - stored[ref.kind]) + if ref.kind in self.alias_tables and ref.local is None: + use = self._alias_use(ref.kind, ref.id, ref.version) if isinstance(use, str): self.unpulled[ref.kind, ref.id, ref.version] = use elif use is not None and use.store_version is None: @@ -335,16 +326,13 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: references.append(ref) if _leaves_an_agent_unset(entry): if default is _UNREAD: - default = self._alias_use(self.alias_table, EntityKind.AGENT, - get_config().get_default_a2a_agent_id(), None, self.alias_uses, - self.alias_stored) + default = self._alias_use(EntityKind.AGENT, get_config().get_default_a2a_agent_id(), None) if isinstance(default, str): self.problems.append(f"[agents] default_a2a_agent_id: {default}") if isinstance(default, AliasUse) and default.store_version is None: references += self._default_agents(entry, default.target_id) if references != list(entry.references): self._replace(index, replace(entry, config=_renamed(entry.config, aliased), references=tuple(references))) - return self.alias_uses def _agent_override(self, name: str) -> AgentOverride | None: """The agent ``name`` names, as a task's reference to it would: this bundle's agent, an installed bundle's @@ -362,8 +350,7 @@ def _agent_override(self, name: str) -> AgentOverride | None: ref = Reference(EntityKind.AGENT, own.id, None, own, where, None) else: ref = Reference(EntityKind.AGENT, name, None, None, where, None) - use = None if self.alias_table is None else self._alias_use( - self.alias_table, EntityKind.AGENT, name, None, self.alias_uses, self.alias_stored) + use = self._alias_use(EntityKind.AGENT, name, None) if EntityKind.AGENT in self.alias_tables else None if isinstance(use, str): self.problems.append(f"{where}: {use}") return None @@ -388,19 +375,20 @@ def _unstored(self, ref: Reference) -> str | None: try: STORE_GETTERS[EntityKind.AGENT](ref.id, None) except NotFoundError: - hint = self.alias_table.hint(ref.id) if self.alias_table is not None else "" + hint = table.hint(ref.id) if (table := self.alias_tables.get(EntityKind.AGENT)) is not None else "" return f"there is no agent {ref.id!r} in this bundle, an installed bundle or the store{hint}" except (ValueError, KeyError, TypeError) as e: return f"agent {ref.id!r} can't be read ({type(e).__name__}: {e})" return None - def _alias_use(self, table: InstalledAliases, kind: EntityKind, id: str, version: int | None, - uses: dict[str, AliasUse], stored: dict[str, int | None]) -> AliasUse | str | None: + def _alias_use(self, kind: EntityKind, id: str, version: int | None) -> AliasUse | str | None: """What ``id``, naming one of ``kind``, names when it's an installed alias, the store's of that id read first - (its latest version, or None, cached in ``stored``), recorded in ``uses``; why it can't be named so; or None - when it's no alias, or names the store's as any store reference does: pinned, or with the alias refused.""" + (its latest version, or None, cached in ``alias_stored``), recorded in ``alias_uses``; why it can't be named + so; or None when it's no alias, or names the store's as any store reference does: pinned, or with the alias + refused.""" if id.startswith(LOCAL_PREFIX): return None + table, uses, stored = self.alias_tables[kind], self.alias_uses[kind], self.alias_stored[kind] refusal, named = table.refusal(id), table.target(id) if refusal is None and named is None: return None @@ -506,21 +494,20 @@ def _pull(self, pullable: _Pullable, target: BundleEntry | BundleImage) -> None: self._add(key, source, _needs(source)) def plan(self, tasks: Sequence[str], evals: Sequence[str], agent: str | None = None) -> Plan: - aliases = self._resolve_aliases() + self._resolve_aliases() override = self._agent_override(agent) if agent is not None else None self._check_writers() self._check_loops() selected_tasks, selected_evals = self._selection(tasks, evals) - return self._planned(selected_tasks + selected_evals, selected_tasks, selected_evals, aliases, override) + return self._planned(selected_tasks + selected_evals, selected_tasks, selected_evals, override) def plan_entities(self, ids: Sequence[str]) -> Plan: self._check_writers() self._check_loops() - return self._planned(self._select_entities(ids), [], [], {}, None) + return self._planned(self._select_entities(ids), [], [], None) def _planned(self, roots: list[ResolvedEntry], selected_tasks: list[ResolvedEntry], - selected_evals: list[ResolvedEntry], aliases: dict[str, AliasUse], - override: AgentOverride | None) -> Plan: + selected_evals: list[ResolvedEntry], override: AgentOverride | None) -> Plan: closure = self._closure(roots) store_refs, store_latest = self._check_store_refs(closure) if override is not None: @@ -536,8 +523,8 @@ def _planned(self, roots: list[ResolvedEntry], selected_tasks: list[ResolvedEntr raise BundleError(self.problems) writes = self._ordered(closure) return Plan(self.resolved, writes, tuple(selected_tasks), tuple(selected_evals), store_refs, store_latest, - dict(self.origins), self._published(writes), aliases, override, dict(self.env_alias_uses), - dict(self.artifact_alias_uses)) + dict(self.origins), self._published(writes), + {kind: dict(uses) for kind, uses in self.alias_uses.items()}, override) def check(self) -> None: """The checks a run makes before it reads a store. A bundle with nothing to run passes only when its @@ -740,8 +727,8 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: if found is None: at = f" version {ref.version}" if ref.version is not None else "" other = self.named.get(fold(ref.id)) - entry_aliases = {EntityKind.ENV: self.env_alias_table, EntityKind.ARTIFACT: self.artifact_alias_table} - aliased = table.target(ref.id) if (table := entry_aliases.get(ref.kind)) is not None else None + table = self.alias_tables.get(ref.kind) + aliased = table.target(ref.id) if table is not None and ref.kind is not EntityKind.AGENT else None if other is not None: hint = f"; this bundle's {other.kind.store} {other.name!r} has that name" elif aliased is not None: # a task's reference to it is pulled in; another entry's isn't, as aliases go @@ -749,7 +736,6 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: f"{ref.kind.value} alias names an {ref.kind.value} only in a task's steps; name the " f"{ref.kind.value} by its id") else: - table = {EntityKind.AGENT: self.alias_table, **entry_aliases}.get(ref.kind) hint = table.hint(ref.id) if table is not None else "" return f"there is no {kind} {ref.id!r}{at} in the store{hint}" if isinstance(found, str): diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index d7033d66f..baa408db0 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -498,7 +498,8 @@ def _docker_plugins(self, built_here: frozenset[str], infra: list[InfraSetup]) - def _default_agent_id(self) -> str: """The agent a step naming none deploys: the configured default, or the agent it names when it's an alias.""" default = get_config().get_default_a2a_agent_id() - return self.plan.aliases[default].agent_id if default in self.plan.aliases else default + agents = self.plan.alias_uses.get(EntityKind.AGENT, {}) + return agents[default].agent_id if default in agents else default def _default_agent(self) -> None: if not self.default_agent_users: diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index e8133e461..1f1786675 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -408,7 +408,7 @@ def refuse_store_tasks(plan: Plan) -> None: def _say_agents(plan: Plan, say: Callable[[str], None]) -> None: """Say what each agent, env and artifact alias names, and what the solver deploys in place of the tasks' agents.""" - for use in (*plan.aliases.values(), *plan.env_aliases.values(), *plan.artifact_aliases.values()): + for use in (use for uses in plan.alias_uses.values() for use in uses.values()): say(_aliased(use)) if plan.agent is not None: runs = _to_run(plan) diff --git a/tst/unit/bundle/agent_override_test.py b/tst/unit/bundle/agent_override_test.py index 33c5401ea..301320be9 100644 --- a/tst/unit/bundle/agent_override_test.py +++ b/tst/unit/bundle/agent_override_test.py @@ -15,6 +15,7 @@ from agent_env.bundle.plan import AgentOverride, plan_bundle, solver_steps from agent_env.bundle.resolve import resolve_bundle from agent_env.cli import cli +from agent_env.entity.refs import EntityKind from agent_env.plugins._bundles import ALIASES_FILE from agent_env.store.routing import namespace_routing from agent_env.task.task import Task @@ -79,7 +80,8 @@ def test_an_alias_names_the_agent_the_solver_deploys_and_the_task_stays_as_writt assert plan.agent == AgentOverride("solver-x", SOLVER, {f"{ROOT}/t": ("agent",)}) assert SOLVER in {write.id for write in plan.writes} - assert _step(plan, "agent")["a2a_agent_id"] == "theirs" and plan.aliases["solver-x"].target_id == SOLVER + assert _step(plan, "agent")["a2a_agent_id"] == "theirs" + assert plan.alias_uses[EntityKind.AGENT]["solver-x"].target_id == SOLVER def test_this_bundles_own_agent_is_named_by_its_folder(bundle_dir): diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index ff1dd5efc..8fc13d690 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -403,8 +403,8 @@ def test_an_agent_named_by_an_installed_alias_is_pulled_in_and_the_task_names_it assert _writes(plan) == [(BundleKind.ARTIFACT, f"{SOLVER}__agent_image"), (BundleKind.AGENT, SOLVER), (BundleKind.TASK, f"{ROOT}/t")] assert _step(plan)["a2a_agent_id"] == SOLVER - assert plan.aliases == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} - assert plan.aliases["solver-x"].agent_id == SOLVER and plan.store_refs == () + assert plan.alias_uses[EntityKind.AGENT] == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} + assert plan.alias_uses[EntityKind.AGENT]["solver-x"].agent_id == SOLVER and plan.store_refs == () ENV_ALIASED = {"envs/crm/Dockerfile": "FROM scratch\n", "envs/crm/env.toml": 'environment_name = "crm"\n', @@ -422,8 +422,9 @@ def test_an_env_named_by_an_installed_alias_is_pulled_in_and_the_task_names_it_b assert (BundleKind.ENV, CRM) in _writes(plan) assert _step(plan, "env")["env_id"] == CRM and _step(plan, "agent")["env_ids"] == [CRM] - assert plan.env_aliases == {"crm-x": AliasUse("crm-x", CRM, "demo-dist's kit bundle", kind=EntityKind.ENV)} - assert plan.aliases == {} and plan.store_refs == () + envs = plan.alias_uses[EntityKind.ENV] + assert envs == {"crm-x": AliasUse("crm-x", CRM, "demo-dist's kit bundle", kind=EntityKind.ENV)} + assert plan.alias_uses[EntityKind.AGENT] == {} and plan.store_refs == () def test_the_stores_env_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): @@ -436,7 +437,8 @@ def test_the_stores_env_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): plan = _plan(_task(bundle_dir, CRM_STEPS), kit) assert (BundleKind.ENV, CRM) not in _writes(plan) and _step(plan, "env")["env_id"] == "crm-x" - assert plan.env_aliases["crm-x"].store_version == 1 and [ref.id for ref in plan.store_refs] == ["crm-x"] + envs = plan.alias_uses[EntityKind.ENV] + assert envs["crm-x"].store_version == 1 and [ref.id for ref in plan.store_refs] == ["crm-x"] @@ -463,9 +465,9 @@ def test_an_artifact_named_by_an_installed_alias_is_pulled_in_and_the_task_names "depends_on": ["env"]}], CRM_HERE), kit) assert (BundleKind.ARTIFACT, f"{KIT}/notes") in _writes(plan) and _step(plan, "load")["artifact_id"] == f"{KIT}/notes" - assert plan.artifact_aliases == {"notes-x": AliasUse("notes-x", f"{KIT}/notes", "demo-dist's kit bundle", - kind=EntityKind.ARTIFACT)} - assert plan.env_aliases == {} and plan.store_refs == () + assert plan.alias_uses[EntityKind.ARTIFACT] == { + "notes-x": AliasUse("notes-x", f"{KIT}/notes", "demo-dist's kit bundle", kind=EntityKind.ARTIFACT)} + assert plan.alias_uses[EntityKind.ENV] == {} and plan.store_refs == () def test_an_env_definition_naming_an_artifact_alias_is_refused_saying_to_name_the_artifact_by_its_id(bundle_dir, @@ -487,8 +489,9 @@ def test_the_stores_agent_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path) plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]), kit) assert _writes(plan) == [(BundleKind.TASK, f"{ROOT}/t")] and _step(plan)["a2a_agent_id"] == "solver-x" - assert plan.aliases["solver-x"] == AliasUse("solver-x", SOLVER, "demo-dist's kit bundle", store_version=1) - assert plan.aliases["solver-x"].agent_id == "solver-x" and [ref.id for ref in plan.store_refs] == ["solver-x"] + agents = plan.alias_uses[EntityKind.AGENT] + assert agents["solver-x"] == AliasUse("solver-x", SOLVER, "demo-dist's kit bundle", store_version=1) + assert agents["solver-x"].agent_id == "solver-x" and [ref.id for ref in plan.store_refs] == ["solver-x"] def test_a_bundles_own_agent_named_like_an_installed_alias_is_the_one_it_names(bundle_dir, tmp_path): @@ -497,7 +500,7 @@ def test_a_bundles_own_agent_named_like_an_installed_alias_is_the_one_it_names(b plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}], {"agents/solver-x/Dockerfile": "FROM scratch\n"}), kit) - assert _step(plan)["a2a_agent_id"] == f"{ROOT}/solver-x" and plan.aliases == {} + assert _step(plan)["a2a_agent_id"] == f"{ROOT}/solver-x" and plan.alias_uses[EntityKind.AGENT] == {} def test_every_field_naming_an_alias_in_a_task_is_renamed(bundle_dir, tmp_path): @@ -529,7 +532,8 @@ def test_a_pinned_reference_to_the_stores_agent_of_an_alias_id_names_that_agent( plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x", "a2a_agent_version": 1}]), kit) - assert [(ref.id, ref.version) for ref in plan.store_refs] == [("solver-x", 1)] and plan.aliases == {} + assert [(ref.id, ref.version) for ref in plan.store_refs] == [("solver-x", 1)] + assert plan.alias_uses[EntityKind.AGENT] == {} def test_an_alias_two_installed_bundles_give_is_refused_naming_both(bundle_dir, tmp_path): @@ -554,7 +558,7 @@ def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(bundle_dir, plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]), kit, other) - assert [ref.id for ref in plan.store_refs] == ["solver-x"] and plan.aliases == {} + assert [ref.id for ref in plan.store_refs] == ["solver-x"] and plan.alias_uses[EntityKind.AGENT] == {} def test_an_agent_misspelling_an_alias_is_pointed_at_it(bundle_dir, tmp_path): @@ -584,7 +588,7 @@ def test_a_default_agent_set_to_an_alias_is_pulled_in_for_a_step_naming_none(bun plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent"}]), kit) assert (BundleKind.AGENT, SOLVER) in _writes(plan) and "a2a_agent_id" not in _step(plan) - assert plan.aliases == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} + assert plan.alias_uses[EntityKind.AGENT] == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} def test_a_default_agent_set_to_an_alias_no_step_falls_back_to_isnt_read(bundle_dir, tmp_path, default_agent): @@ -593,7 +597,7 @@ def test_a_default_agent_set_to_an_alias_no_step_falls_back_to_isnt_read(bundle_ plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": SOLVER}]), kit) - assert plan.aliases == {} + assert plan.alias_uses[EntityKind.AGENT] == {} def test_checking_a_bundle_naming_an_alias_reads_no_store(bundle_dir, tmp_path, monkeypatch): diff --git a/tst/unit/bundle/shipped_computers_test.py b/tst/unit/bundle/shipped_computers_test.py index 68b6d8b46..1d3650801 100644 --- a/tst/unit/bundle/shipped_computers_test.py +++ b/tst/unit/bundle/shipped_computers_test.py @@ -41,7 +41,7 @@ def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_ima computer = ComputerArtifact.get(UBUNTU, done.version_of("artifact", UBUNTU)) image = DockerImageArtifact.get(computer.forms.container.image.id, computer.forms.container.image.version) - assert plan.artifact_aliases["ubuntu-24.04"] == AliasUse( + assert plan.alias_uses[EntityKind.ARTIFACT]["ubuntu-24.04"] == AliasUse( "ubuntu-24.04", UBUNTU, "agentenv-framework's computers bundle", kind=EntityKind.ARTIFACT) assert (computer.arch, computer.os.version, computer.session.user) == (["amd64", "arm64"], "24.04", "user") assert image.id == f"{UBUNTU}__forms_container_image" and image.dockerfile_path == "Dockerfile" From 66fd5594666fc02cd84823791b7c863ca975e1f6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:37:40 -0700 Subject: [PATCH 16/22] refactor(computer): drop the artifact computer CLI for now Bundles write computers, and the CLI's folder put kept a second reader of the image key the bundle resolver owns. It can come back as a one-entry bundle run if a caller needs it. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/cli/artifact/__init__.py | 2 - src/agent_env/cli/artifact/computer.py | 40 ------------ tst/unit/cli/artifact_computer_test.py | 84 -------------------------- 3 files changed, 126 deletions(-) delete mode 100644 src/agent_env/cli/artifact/computer.py delete mode 100644 tst/unit/cli/artifact_computer_test.py diff --git a/src/agent_env/cli/artifact/__init__.py b/src/agent_env/cli/artifact/__init__.py index 7a6244ba9..f5475dac3 100644 --- a/src/agent_env/cli/artifact/__init__.py +++ b/src/agent_env/cli/artifact/__init__.py @@ -1,7 +1,6 @@ import click from .cli import cli -from .computer import computer from .file_artifact_universe import file_artifact_universe from .environment import environment from .environment_universe import environment_universe @@ -15,7 +14,6 @@ def artifact(): artifact.add_command(cli) -artifact.add_command(computer) artifact.add_command(file_artifact_universe) artifact.add_command(environment) artifact.add_command(environment_universe) diff --git a/src/agent_env/cli/artifact/computer.py b/src/agent_env/cli/artifact/computer.py deleted file mode 100644 index 3509d2fef..000000000 --- a/src/agent_env/cli/artifact/computer.py +++ /dev/null @@ -1,40 +0,0 @@ -import json -import tomllib -from pathlib import Path -from typing import Optional - -import click - -from agent_env.artifact import ComputerArtifact - - -@click.group(name="computer") -def computer(): - """ComputerArtifact commands.""" - - -@computer.command("put") -@click.option("--id", "artifact_id", required=True, help="ComputerArtifact id") -@click.argument("folder", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path)) -@click.option("--no-inspect", is_flag=True, - help="Write it without reading its images from their registries; each form is then uninspected") -def computer_put(artifact_id: str, folder: Path, no_inspect: bool): - """Write the computer FOLDER's artifact.toml declares, with its form's image, after reading that image from its - registry to check it's what the computer says.""" - try: - result = ComputerArtifact.put_folder(artifact_id, folder, inspect=not no_inspect) - except (ValueError, OSError, tomllib.TOMLDecodeError) as e: - raise click.ClickException(str(e)) from None - click.echo(f"Created ComputerArtifact: id={result.id} version={result.version}") - for form, by_arch in result.metadata["validated_images"].items(): - for arch, found in by_arch.items(): - why = found.get("reason") or found.get("problem") - click.echo(f" {form} {arch}: {found['status']}" + (f" ({why})" if why else "")) - - -@computer.command("get") -@click.option("--id", "artifact_id", required=True, help="ComputerArtifact id") -@click.option("--version", type=int, default=None, help="The version (defaults to the latest)") -def computer_get(artifact_id: str, version: Optional[int]): - """Print a computer artifact as JSON, with what put read from its images.""" - click.echo(json.dumps(ComputerArtifact.get(artifact_id, version).model_dump(mode="json"), indent=2)) diff --git a/tst/unit/cli/artifact_computer_test.py b/tst/unit/cli/artifact_computer_test.py deleted file mode 100644 index 4c57ec902..000000000 --- a/tst/unit/cli/artifact_computer_test.py +++ /dev/null @@ -1,84 +0,0 @@ -"""`artifact computer put FOLDER` writes the computer a folder's artifact.toml declares, its form's image written as a -bundle writes one and read back from its registry unless `--no-inspect`; `artifact computer get` prints it. The -registry is patched out.""" - -import json - -import pytest -from click.testing import CliRunner - -from agent_env.artifact.artifacts import computer as computer_module -from agent_env.artifact.artifacts import docker_image -from agent_env.artifact.artifacts.computer import ComputerArtifact -from agent_env.artifact.artifacts.docker_image import DockerImageArtifact -from agent_env.cli import cli -from tst.util.oci_registry import UBUNTU_LAYER, Registry - -DECLARED = ('type = "computer"\ndescription = "a desktop"\narch = ["amd64"]\n' - 'os = { type = "linux", distribution = "ubuntu", version = "24.04", display = "x11" }\n' - 'session = { user = "user" }\n\n[forms.container]\nimage = %s\ninit = "supervisord"\ninput_channel = "env"\n') - - -@pytest.fixture -def registry(local_stores, monkeypatch): - served = Registry() - index = served.index(amd64=served.image(UBUNTU_LAYER)) - monkeypatch.setattr(docker_image, "pin_digest", lambda ref, auth: f"{ref}@{index}") - monkeypatch.setattr(computer_module, "read_manifest", served.read_manifest) - monkeypatch.setattr(computer_module, "read_blob", served.read_blob) - return served - - -def _folder(tmp_path, image: str): - folder = tmp_path / "desktop" - folder.mkdir() - (folder / "artifact.toml").write_text(DECLARED % image) - (folder / "Dockerfile").write_text("FROM scratch\n") - return folder - - -def _run(*args): - return CliRunner().invoke(cli, list(args), catch_exceptions=False) - - -def test_a_folder_naming_a_registry_image_is_written_over_its_pin_and_inspected(registry, tmp_path): - result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, '{ ref = "ghcr.io/acme/desk:1" }'))) - - assert result.exit_code == 0, result.output - assert "Created ComputerArtifact: id=desktop version=1\n container amd64: passed\n" in result.output - image = ComputerArtifact.get("desktop").forms.container.image - assert DockerImageArtifact.get(image.id, image.version).image_name.startswith("ghcr.io/acme/desk:1@sha256:") - printed = json.loads(_run("artifact", "computer", "get", "--id", "desktop").output) - assert printed["metadata"]["validated_images"]["container"]["amd64"]["os_release"]["VERSION_ID"] == "24.04" - - -def test_a_folders_dockerfile_is_its_build_context_and_no_inspect_skips_the_registry(registry, tmp_path): - result = _run("artifact", "computer", "put", "--id", "desktop", "--no-inspect", - str(_folder(tmp_path, '{ dockerfile = "Dockerfile" }'))) - - assert result.exit_code == 0, result.output - assert "container amd64: uninspected (inspection was skipped)" in result.output - image = ComputerArtifact.get("desktop").forms.container.image - assert (image.id, DockerImageArtifact.get(image.id).context_only) == ("desktop__forms_container_image", True) - assert registry.fetched == [] - - -@pytest.mark.parametrize("image, message", [ - ("{ path = \"x\" }", "forms.container.image is { dockerfile = \"\" }"), - ('{ ref = "ghcr.io/acme/desk:1" }\narch2 = 1', "arch2"), -], ids=["image-key", "unknown-key"]) -def test_what_the_folder_declares_wrong_is_reported(registry, tmp_path, image, message): - result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, image))) - - assert result.exit_code == 1 and message in result.output - - -@pytest.mark.parametrize("image", ['"desk-image"', '{ artifact = "desk-image", version = 1 }'], ids=["id", "pinned"]) -def test_a_folder_naming_an_image_artifact_is_written_over_it(registry, tmp_path, image): - DockerImageArtifact.put_ref("desk-image", description="the desk", image_name="ghcr.io/acme/desk:1") - - result = _run("artifact", "computer", "put", "--id", "desktop", str(_folder(tmp_path, image))) - - assert result.exit_code == 0, result.output - pinned = ComputerArtifact.get("desktop").forms.container.image - assert (pinned.id, pinned.version) == ("desk-image", 1) and "container amd64: passed" in result.output From 5d696dc430b3bcaaf321398a49a9e5c87b3c0e8d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:37:44 -0700 Subject: [PATCH 17/22] refactor(computer): read what an image holds in store/image_store, and type what put records The platform manifests and os-release reading move out of the computer artifact into image_store/image_contents, which a disk form's inspection can reuse; the artifact keeps the check of what it declares against what was read, each result an ImageCheck. put_folder goes with the CLI. Tests serve their fake registry with Registry().serve(monkeypatch) in place of patching three functions apiece. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 175 ++++-------------- .../store/image_store/image_contents.py | 88 +++++++++ tst/unit/artifact/test_computer_artifact.py | 7 +- tst/unit/bundle/computer_folder_test.py | 29 +-- tst/unit/bundle/shipped_computers_test.py | 5 +- tst/util/oci_registry.py | 9 +- 6 files changed, 144 insertions(+), 169 deletions(-) create mode 100644 src/agent_env/store/image_store/image_contents.py diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index d7ded1f55..c9aeb7ed0 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -5,13 +5,8 @@ from __future__ import annotations -import json import re -import tarfile -import tempfile -import tomllib from datetime import datetime, timezone -from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator @@ -21,18 +16,14 @@ from agent_env.artifact.ref import ArtifactRef from agent_env.entity.refs import EntityRef from agent_env.store.base import NotFoundError -from agent_env.store.ids import derive_id from agent_env.store.image_store.oci_registry_credentials import names_registry -from agent_env.store.image_store.registry_api import pin_digest, read_blob, read_manifest +from agent_env.store.image_store.image_contents import os_release, platform_manifests if TYPE_CHECKING: from agent_env.bundle.authoring import AuthoringContext OS_TYPES = ("linux",) FORMS = ("container",) -_INDEX_TYPES = ("application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json") -# os-release(5): /etc/os-release first, else /usr/lib/os-release; on Ubuntu the first links to the second. -_OS_RELEASE = ("etc/os-release", "usr/lib/os-release") class LinuxOS(BaseModel): @@ -94,6 +85,21 @@ class Forms(BaseModel): container: ContainerForm +class ImageCheck(BaseModel): + """What reading a form's image for one architecture found, as ``metadata.validated_images`` records it: ``passed``, + ``failed`` with the problem, or ``uninspected`` with the reason no registry was read.""" + + model_config = ConfigDict(extra="forbid") + + status: Literal["passed", "failed", "uninspected"] + image: str | None = None # the image read, pinned to its digest + manifest: str | None = None # the digest of the architecture's image manifest + os_release: dict[str, str] | None = None + problem: str | None = None + reason: str | None = None + checked_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")) + + class ComputerArtifact(Artifact): """A base computer, which composing adds drivers and apps to.""" @@ -151,27 +157,17 @@ def put(cls, *, inspect: bool = True, **fields: Any) -> Self: computer = cls(**fields) image = computer.image() - found = {"container": _inspect_container(image, computer.arch, computer.os) if inspect - else _unread(computer.arch, "inspection was skipped")} - problems = [f"forms.{form} ({arch}): {result['problem']}" - for form, by_arch in found.items() for arch, result in by_arch.items() if result["status"] == "failed"] + checks = {"container": _inspect_container(image, computer.arch, computer.os) if inspect + else _unread(computer.arch, "inspection was skipped")} + problems = [f"forms.{form} ({arch}): {check.problem}" + for form, by_arch in checks.items() for arch, check in by_arch.items() if check.status == "failed"] if problems: raise ValueError(f"computer {computer.id!r}: " + "; ".join(problems)) - computer.metadata = {**computer.metadata, "validated_images": found} + computer.metadata = {**computer.metadata, "validated_images": { + form: {arch: check.model_dump(exclude_none=True) for arch, check in by_arch.items()} + for form, by_arch in checks.items()}} return get_artifact_store().put_document(computer) - @classmethod - def put_folder(cls, id: str, folder: Path, *, inspect: bool = True) -> Self: - """Write the computer ``folder``'s artifact.toml declares, outside a bundle, its form's image key written as a - bundle writes one: a Dockerfile in the folder as its build context alone, a registry ref pinned now, or the image - artifact it names.""" - fields = {key: value for key, value in tomllib.loads((folder / "artifact.toml").read_text()).items() - if key not in ("id", "type", "version")} - container = (fields.get("forms") or {}).get("container") - if isinstance(container, dict) and "image" in container: - container["image"] = _folder_image(derive_id(id, "forms_container_image"), folder, container["image"]) - return cls.put(id=id, inspect=inspect, **fields) - @classmethod def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self: """Write the computer an artifact.toml declares, its form's image as the bundle wrote it or the artifact the @@ -198,23 +194,7 @@ def image(self) -> DockerImageArtifact: raise ValueError(f"computer {self.id!r}: forms.container.image: {e}") from e -def _folder_image(image_id: str, folder: Path, key: Any) -> dict[str, Any]: - if isinstance(key, dict) and key.keys() == {"dockerfile"}: - image = DockerImageArtifact.put_context(image_id, description=f"built from {folder.name}/{key['dockerfile']}", - context_path=str(folder), dockerfile_path=key["dockerfile"], platform=None) - elif isinstance(key, dict) and key.keys() == {"ref"}: - image = DockerImageArtifact.put_ref(image_id, description=f"referenced by {folder.name}", image_name=key["ref"]) - elif isinstance(key, dict) and key.keys() <= {"artifact", "version"} and "artifact" in key: - image = DockerImageArtifact.get(key["artifact"], key.get("version")) - elif isinstance(key, str): - image = DockerImageArtifact.get(key) - else: - raise ValueError(f"forms.container.image is {{ dockerfile = \"\" }}, {{ ref = \"\" }}, an image " - f"artifact's id or {{ artifact = \"\", version = }}, not {key!r}") - return {"id": image.id, "version": image.version} - - -def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxOS) -> dict[str, dict[str, Any]]: +def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxOS) -> dict[str, ImageCheck]: from agent_env.config import get_config if not names_registry(image.image_name): @@ -223,102 +203,29 @@ def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxO ref = image.pinned_name auth = get_config().get_image_store().auth(ref) try: - ref = ref if "@" in ref else pin_digest(ref, auth) - manifests = _platform_manifests(ref, auth) + ref, manifests = platform_manifests(ref, auth) except ValueError as e: - return {arch: _result("failed", ref, problem=str(e)) for arch in arches} - results = {} + return {arch: ImageCheck(status="failed", image=ref, problem=str(e)) for arch in arches} + checks = {} for arch in arches: if (digest := manifests.get(arch)) is None: - results[arch] = _result("failed", ref, problem=f"it has no linux/{arch} image, only " - f"{', '.join(f'linux/{a}' for a in sorted(manifests)) or 'none'}") + served = ", ".join(f"linux/{a}" for a in sorted(manifests)) or "none" + checks[arch] = ImageCheck(status="failed", image=ref, problem=f"it has no linux/{arch} image, only {served}") continue try: - release = _os_release(ref, digest, auth) - except (ValueError, OSError, tarfile.TarError) as e: - results[arch] = _result("failed", ref, manifest=digest, problem=f"its os-release couldn't be read: {e}") + release = os_release(ref, digest, auth) + except ValueError as e: + checks[arch] = ImageCheck(status="failed", image=ref, manifest=digest, + problem=f"its os-release couldn't be read: {e}") continue expected = {"ID": os.distribution, "VERSION_ID": None if os.version == "rolling" else os.version} problems = [f"os-release has {key}={release.get(key)!r}, not {want!r}" for key, want in expected.items() if release.get(key) != want] - results[arch] = _result("failed" if problems else "passed", ref, manifest=digest, - os_release={key: release[key] for key in expected if key in release}, - problem="; ".join(problems) or None) - return results - - -def _platform_manifests(ref: str, auth) -> dict[str, str]: - """Each Linux architecture's image manifest digest: an index's, or a single image's own by its config.""" - top = read_manifest(ref, auth) - if top.get("mediaType") in _INDEX_TYPES or "manifests" in top: - return {entry["platform"]["architecture"]: entry["digest"] for entry in top.get("manifests") or () - if (entry.get("platform") or {}).get("os") == "linux"} - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "config" - read_blob(ref, top["config"]["digest"], auth, path) - config = json.loads(path.read_text()) - return {config["architecture"]: ref.rpartition("@")[2]} if config.get("os") == "linux" else {} - - -def _os_release(ref: str, digest: str, auth) -> dict[str, str]: - """The os-release of the image manifest ``digest``, from the lowest layer that holds it, normally the OS's own, so - only that layer is fetched.""" - name = ref.partition("@")[0] - manifest = read_manifest(f"{name}@{digest}", auth) - found: dict[str, bytes | str] = {} - with tempfile.TemporaryDirectory() as tmp: - for layer in manifest.get("layers") or (): - path = Path(tmp) / layer["digest"].replace(":", "-") - read_blob(ref, layer["digest"], auth, path) - with tarfile.open(path, "r:*") as tar: - for member in tar: - member_name = member.name.removeprefix("./") - if member_name in _OS_RELEASE: - if member.issym(): - found[member_name] = member.linkname - elif (extracted := tar.extractfile(member)) is not None: - found[member_name] = extracted.read() - path.unlink() - if (text := _resolved(found)) is not None: - return _parse_os_release(text) - raise ValueError("no layer holds /etc/os-release or /usr/lib/os-release") - - -def _resolved(found: dict[str, bytes | str]) -> str | None: - for name in _OS_RELEASE: - value = found.get(name) - if isinstance(value, str): # a link, to a path in the image - target = PurePosixPath(value) if value.startswith("/") else PurePosixPath("/", name).parent / value - value = found.get(_normalized(target)) - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace") - return None - - -def _normalized(path: PurePosixPath) -> str: - parts: list[str] = [] - for part in path.parts[1:]: - if part == "..": - parts = parts[:-1] - elif part not in ("", "."): - parts.append(part) - return "/".join(parts) - - -def _parse_os_release(text: str) -> dict[str, str]: - release = {} - for line in text.splitlines(): - key, sep, value = line.strip().partition("=") - if sep and key and not key.startswith("#"): - release[key] = value.strip().strip("\"'") - return release - - -def _result(status: str, image: str | None, **found: Any) -> dict[str, Any]: - return {"status": status, **({"image": image} if image else {}), - **{key: value for key, value in found.items() if value is not None}, - "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds")} - - -def _unread(arches: list[str], why: str) -> dict[str, dict[str, Any]]: - return {arch: _result("uninspected", None, reason=why) for arch in arches} + checks[arch] = ImageCheck(status="failed" if problems else "passed", image=ref, manifest=digest, + os_release={key: release[key] for key in expected if key in release}, + problem="; ".join(problems) or None) + return checks + + +def _unread(arches: list[str], why: str) -> dict[str, ImageCheck]: + return {arch: ImageCheck(status="uninspected", reason=why) for arch in arches} diff --git a/src/agent_env/store/image_store/image_contents.py b/src/agent_env/store/image_store/image_contents.py new file mode 100644 index 000000000..888cc1b98 --- /dev/null +++ b/src/agent_env/store/image_store/image_contents.py @@ -0,0 +1,88 @@ +"""What an image a registry serves holds, read without pulling it: the image manifest each Linux architecture runs, and +an image's os-release, from the lowest layer that holds it.""" + +from __future__ import annotations + +import json +import tarfile +import tempfile +from pathlib import Path, PurePosixPath + +from agent_env.store.image_store.oci_registry_credentials import RegistryAuth +from agent_env.store.image_store.registry_api import pin_digest, read_blob, read_manifest + +_INDEX_TYPES = ("application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json") +# os-release(5): /etc/os-release first, else /usr/lib/os-release; on Ubuntu the first links to the second. +_OS_RELEASE = ("etc/os-release", "usr/lib/os-release") + + +def platform_manifests(ref: str, auth: RegistryAuth | None) -> tuple[str, dict[str, str]]: + """``ref`` pinned to the digest its registry serves for it, and the digest of each Linux architecture's image + manifest under it: an index's entries, or a single image's own, by its config. Raises as ``pin_digest`` does.""" + pinned = ref if "@" in ref else pin_digest(ref, auth) + top = read_manifest(pinned, auth) + if top.get("mediaType") in _INDEX_TYPES or "manifests" in top: + return pinned, {entry["platform"]["architecture"]: entry["digest"] for entry in top.get("manifests") or () + if (entry.get("platform") or {}).get("os") == "linux"} + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config" + read_blob(pinned, top["config"]["digest"], auth, path) + config = json.loads(path.read_text()) + return pinned, {config["architecture"]: pinned.rpartition("@")[2]} if config.get("os") == "linux" else {} + + +def os_release(ref: str, digest: str, auth: RegistryAuth | None) -> dict[str, str]: + """The os-release fields of the image manifest ``digest`` in ``ref``'s repository, from the lowest layer that holds + the file, normally the OS's own, so only that layer is fetched. Raises ValueError when no layer holds it or one + can't be read.""" + manifest = read_manifest(f"{ref.partition('@')[0]}@{digest}", auth) + found: dict[str, bytes | str] = {} + with tempfile.TemporaryDirectory() as tmp: + for layer in manifest.get("layers") or (): + path = Path(tmp) / layer["digest"].replace(":", "-") + read_blob(ref, layer["digest"], auth, path) + try: + with tarfile.open(path, "r:*") as tar: + for member in tar: + name = member.name.removeprefix("./") + if name in _OS_RELEASE: + if member.issym(): + found[name] = member.linkname + elif (extracted := tar.extractfile(member)) is not None: + found[name] = extracted.read() + except (OSError, tarfile.TarError) as e: + raise ValueError(f"its layer {layer['digest']} isn't a readable tar: {e}") from e + path.unlink() + if (text := _resolved(found)) is not None: + return _parsed(text) + raise ValueError("no layer holds /etc/os-release or /usr/lib/os-release") + + +def _resolved(found: dict[str, bytes | str]) -> str | None: + for name in _OS_RELEASE: + value = found.get(name) + if isinstance(value, str): # a link, to a path in the image + target = PurePosixPath(value) if value.startswith("/") else PurePosixPath("/", name).parent / value + value = found.get(_normalized(target)) + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return None + + +def _normalized(path: PurePosixPath) -> str: + parts: list[str] = [] + for part in path.parts[1:]: + if part == "..": + parts = parts[:-1] + elif part not in ("", "."): + parts.append(part) + return "/".join(parts) + + +def _parsed(text: str) -> dict[str, str]: + release = {} + for line in text.splitlines(): + key, sep, value = line.strip().partition("=") + if sep and key and not key.startswith("#"): + release[key] = value.strip().strip("\"'") + return release diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index 2642e8947..e5088859a 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -5,7 +5,6 @@ import pytest from agent_env.artifact import Artifact -from agent_env.artifact.artifacts import computer as computer_module from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact @@ -14,11 +13,7 @@ @pytest.fixture def registry(local_stores, monkeypatch): - registry = Registry() - monkeypatch.setattr(computer_module, "pin_digest", registry.pin_digest) - monkeypatch.setattr(computer_module, "read_manifest", registry.read_manifest) - monkeypatch.setattr(computer_module, "read_blob", registry.read_blob) - return registry + return Registry().serve(monkeypatch) def _image(name: str) -> DockerImageArtifact: diff --git a/tst/unit/bundle/computer_folder_test.py b/tst/unit/bundle/computer_folder_test.py index c345ffbb9..7b7176505 100644 --- a/tst/unit/bundle/computer_folder_test.py +++ b/tst/unit/bundle/computer_folder_test.py @@ -2,19 +2,16 @@ names, built from a Dockerfile in the folder or pinned from a registry, and the computer is written over that image's version, inspected when a registry serves it.""" -import hashlib -import io import json -import tarfile import pytest -from agent_env.artifact.artifacts import computer as computer_module from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.bundle.parse import BundleError from agent_env.bundle.materialize import materialize from tst.unit.bundle._support import DIGEST, layout, local_builds, plan_of +from tst.util.oci_registry import UBUNTU_LAYER, Registry ROOT = "@local/~/desk" COMPUTER = f"{ROOT}/desktop" @@ -72,25 +69,9 @@ def test_a_computer_folder_builds_its_image_and_is_written_over_it_uninspected_u def test_a_computer_naming_a_registry_image_is_written_over_its_pin_and_inspected(bundle_dir, builds, registry, monkeypatch): - blobs = {} - - def blob(data: bytes) -> str: - digest = f"sha256:{hashlib.sha256(data).hexdigest()}" - blobs[digest] = data - return digest - - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode="w:gz") as tar: - release = b"ID=ubuntu\nVERSION_ID=\"24.04\"\n" - info = tarfile.TarInfo("etc/os-release") - info.size = len(release) - tar.addfile(info, io.BytesIO(release)) - image_manifest = {"layers": [{"digest": blob(buffer.getvalue())}], "config": {"digest": blob(b"{}")}} - amd64 = blob(json.dumps(image_manifest).encode()) - manifests = {DIGEST: {"manifests": [{"digest": amd64, "platform": {"os": "linux", "architecture": "amd64"}}]}, - amd64: image_manifest} - monkeypatch.setattr(computer_module, "read_manifest", lambda ref, auth: manifests[ref.rpartition("@")[2]]) - monkeypatch.setattr(computer_module, "read_blob", lambda ref, digest, auth, path: path.write_bytes(blobs[digest])) + served = Registry().serve(monkeypatch) + amd64 = served.image(UBUNTU_LAYER) + served.manifests[DIGEST] = served.manifests[served.index(amd64=amd64)] # the digest the registry fixture pins layout(bundle_dir, _files('{ ref = "ghcr.io/acme/desktop:1" }')) done = _run(bundle_dir) @@ -113,7 +94,7 @@ def test_a_computer_folder_that_declares_what_isnt_supported_is_refused_naming_t def test_a_registry_image_that_isnt_what_the_computer_declares_is_refused_naming_the_folder(bundle_dir, builds, registry, monkeypatch): - monkeypatch.setattr(computer_module, "read_manifest", lambda ref, auth: {"manifests": []}) + Registry().serve(monkeypatch).manifests[DIGEST] = {"manifests": []} layout(bundle_dir, _files('{ ref = "ghcr.io/acme/desktop:1" }')) with pytest.raises(BundleError) as refused: diff --git a/tst/unit/bundle/shipped_computers_test.py b/tst/unit/bundle/shipped_computers_test.py index 1d3650801..e8a095c12 100644 --- a/tst/unit/bundle/shipped_computers_test.py +++ b/tst/unit/bundle/shipped_computers_test.py @@ -10,7 +10,6 @@ import pytest import agent_env -from agent_env.artifact.artifacts import computer as computer_module from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.bundle import parse_bundle @@ -52,11 +51,9 @@ def test_a_task_naming_ubuntu_24_04_writes_the_shipped_base_over_its_folders_ima def test_a_task_naming_ubuntu_24_04_after_a_release_writes_the_shipped_base_over_its_published_image( tmp_path, monkeypatch, local_stores, cli_routing, builds): - served = Registry() + served = Registry().serve(monkeypatch) index = served.index(amd64=served.image(UBUNTU_LAYER), arm64=served.image(UBUNTU_LAYER, arch="arm64")) monkeypatch.setattr("agent_env.env.published_images._registry_digest", lambda ref: index) - monkeypatch.setattr(computer_module, "read_manifest", served.read_manifest) - monkeypatch.setattr(computer_module, "read_blob", served.read_blob) monkeypatch.setenv("HOME", str(tmp_path)) plan = _naming_ubuntu(tmp_path) diff --git a/tst/util/oci_registry.py b/tst/util/oci_registry.py index 5daaa92b9..c4e5963dc 100644 --- a/tst/util/oci_registry.py +++ b/tst/util/oci_registry.py @@ -1,11 +1,13 @@ """A registry serving OCI images from memory, for code that reads an image's manifests and layers without pulling it: -index, image manifest and blob by digest, a tag pinned to the digest it names, each blob fetched recorded.""" +index, image manifest and blob by digest, a tag pinned to the digest it names, each blob fetched recorded. +``Registry().serve(monkeypatch)`` puts it in place of every registry agent-env reads an image's contents from.""" import hashlib import io import json import tarfile +from agent_env.store.image_store import image_contents from agent_env.store.image_store.registry_api import NoSuchImageError UBUNTU = b'PRETTY_NAME="Ubuntu 24.04.3 LTS"\nNAME="Ubuntu"\nVERSION_ID="24.04"\nID=ubuntu\n' @@ -19,6 +21,11 @@ class Registry: def __init__(self): self.manifests, self.blobs, self.tags, self.fetched = {}, {}, {}, [] + def serve(self, monkeypatch) -> "Registry": + for name in ("pin_digest", "read_manifest", "read_blob"): + monkeypatch.setattr(image_contents, name, getattr(self, name)) + return self + def blob(self, data: bytes) -> str: digest = f"sha256:{hashlib.sha256(data).hexdigest()}" self.blobs[digest] = data From 09d9e86067315fd55d5a2e1e960f8d4994ae0941 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:39:40 -0700 Subject: [PATCH 18/22] refactor(store): read a registry through one request path pin_digest's check and the manifest and blob reads each chose the scheme, traded Basic credentials for a Bearer token and mapped a registry's answers to errors. The check now runs on the repository reader they share, with HEAD added, so a refusal from any read gives the same advice. Co-Authored-By: Claude Opus 5.5 --- .../store/image_store/registry_api.py | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/agent_env/store/image_store/registry_api.py b/src/agent_env/store/image_store/registry_api.py index 467dfb741..9de283c47 100644 --- a/src/agent_env/store/image_store/registry_api.py +++ b/src/agent_env/store/image_store/registry_api.py @@ -93,38 +93,15 @@ def _parse(ref: str) -> _ImageRef: def _check(image: _ImageRef, reference: str, auth: RegistryAuth | None, timeout: float) -> str: """The digest the registry serves for ``reference`` (a tag or a digest) in ``image``'s repository.""" - # A registry on this machine serves plain HTTP, as Docker allows for a loopback one. - scheme = "http" if is_loopback_host(image.host) else "https" - url = f"{scheme}://{image.host}/v2/{image.repository}/manifests/{reference}" what = f"{image.name}{'@' if _DIGEST.match(reference) else ':'}{reference}" - basic = (auth.username, auth.password) if auth is not None else None - try: - with httpx.Client(timeout=timeout, follow_redirects=True) as client: - headers = {"Accept": _MANIFEST_TYPES} - # Basic credentials go with every request until a Bearer token replaces them. - credentials = basic - response = client.head(url, headers=headers, auth=credentials) - if response.status_code == 401 and (token := _bearer_token(client, response, basic, what)) is not None: - headers["Authorization"] = f"Bearer {token}" - credentials = None - response = client.head(url, headers=headers) - if response.status_code == 200 and not response.headers.get("docker-content-digest"): - response = client.get(url, headers=headers, auth=credentials) - if response.status_code == 200: - return f"sha256:{hashlib.sha256(response.content).hexdigest()}" - except httpx.HTTPError as e: - raise ValueError(f"{what}: couldn't read its manifest from {image.host}: {type(e).__name__}: {e}") from e - if response.status_code == 200: - served = response.headers["docker-content-digest"] - if not _DIGEST.match(served): - raise ValueError(f"{what}: {image.host} answered with {served!r}, which isn't a digest") - return served - if response.status_code == 404: - raise NoSuchImageError(f"{what}: {image.host} has no such image") - if response.status_code in (401, 403): - raise RegistryRefusedError(f"{what}: {image.host} refused to serve it (HTTP {response.status_code}); agent-env reaches a " - "private registry only with the configured image store's credentials, for its own registry") - raise ValueError(f"{what}: {image.host} answered HTTP {response.status_code}") + path = f"manifests/{reference}" + with _Repository(image, auth, timeout, what) as repository: + served = repository.head(path, accept=_MANIFEST_TYPES).headers.get("docker-content-digest") + if not served: + return f"sha256:{hashlib.sha256(repository.get(path, accept=_MANIFEST_TYPES).content).hexdigest()}" + if not _DIGEST.match(served): + raise ValueError(f"{what}: {image.host} answered with {served!r}, which isn't a digest") + return served def _bearer_token(client: httpx.Client, challenged: httpx.Response, basic: tuple[str, str] | None, @@ -186,6 +163,7 @@ class _Repository: """One image's repository on its registry, read with the Bearer token the registry's first challenge asks for.""" def __init__(self, image: _ImageRef, auth: RegistryAuth | None, timeout: float, what: str) -> None: + # A registry on this machine serves plain HTTP, as Docker allows for a loopback one. scheme = "http" if is_loopback_host(image.host) else "https" self._base = f"{scheme}://{image.host}/v2/{image.repository}" self._basic = (auth.username, auth.password) if auth is not None else None @@ -199,15 +177,20 @@ def __enter__(self) -> _Repository: def __exit__(self, *_exc) -> None: self._client.close() + def head(self, path: str, *, accept: str | None = None) -> httpx.Response: + response = self._send("HEAD", path, accept, stream=False) + self._raise_for(response) + return response + def get(self, path: str, *, accept: str | None = None) -> httpx.Response: - response = self._send(path, accept, stream=False) + response = self._send("GET", path, accept, stream=False) self._raise_for(response) return response @contextlib.contextmanager def stream(self, path: str) -> Iterator[httpx.Response]: """``path`` as a streamed response, closed when the block ends.""" - response = self._send(path, None, stream=True) + response = self._send("GET", path, None, stream=True) try: if response.status_code != 200: response.read() @@ -216,13 +199,14 @@ def stream(self, path: str) -> Iterator[httpx.Response]: finally: response.close() - def _send(self, path: str, accept: str | None, *, stream: bool) -> httpx.Response: + def _send(self, method: str, path: str, accept: str | None, *, stream: bool) -> httpx.Response: try: for _ in range(2): headers = {"Accept": accept} if accept else {} if self._token: headers["Authorization"] = f"Bearer {self._token}" - request = self._client.build_request("GET", f"{self._base}/{path}", headers=headers) + request = self._client.build_request(method, f"{self._base}/{path}", headers=headers) + # Basic credentials go with every request until a Bearer token replaces them. response = self._client.send(request, auth=None if self._token else self._basic, stream=stream) if response.status_code != 401 or self._token: return response @@ -234,7 +218,8 @@ def _send(self, path: str, accept: str | None, *, stream: bool) -> httpx.Respons self._token = token return response except httpx.HTTPError as e: - raise ValueError(f"{self._what}: couldn't read {path} from {self._host}: {type(e).__name__}: {e}") from e + thing = "its manifest" if path.startswith("manifests/") else f"the blob {path.removeprefix('blobs/')}" + raise ValueError(f"{self._what}: couldn't read {thing} from {self._host}: {type(e).__name__}: {e}") from e def _raise_for(self, response: httpx.Response) -> None: if response.status_code == 200: @@ -242,5 +227,7 @@ def _raise_for(self, response: httpx.Response) -> None: if response.status_code == 404: raise NoSuchImageError(f"{self._what}: {self._host} has no such image") if response.status_code in (401, 403): - raise RegistryRefusedError(f"{self._what}: {self._host} refused to serve it (HTTP {response.status_code})") + raise RegistryRefusedError(f"{self._what}: {self._host} refused to serve it (HTTP {response.status_code}); " + "agent-env reaches a private registry only with the configured image store's " + "credentials, for its own registry") raise ValueError(f"{self._what}: {self._host} answered HTTP {response.status_code}") From 40fe2a7bd3ed9aaad525fbd63697807d7d2c0383 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 13:48:11 -0700 Subject: [PATCH 19/22] test(computer): share the docker helper, refuse a scaffold without its base arg, read an unreadable layer Co-Authored-By: Claude Opus 5.5 --- tst/integration/computer/base_computer_test.py | 12 ++++-------- tst/integration/computer/conftest.py | 9 +++++++-- .../computer/recording_chaos_test.py | 17 +++++++---------- tst/integration/computer/scaffold_test.py | 4 +++- tst/unit/artifact/test_computer_artifact.py | 5 ++++- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py index 4e3d2648c..b208e24ef 100644 --- a/tst/integration/computer/base_computer_test.py +++ b/tst/integration/computer/base_computer_test.py @@ -16,7 +16,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import reset_artifact_store from agent_env.config import configure, reset_config -from tst.integration.computer.conftest import BASE +from tst.integration.computer.conftest import BASE, docker pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -39,7 +39,7 @@ def stores(monkeypatch, tmp_path): def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes(base): name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" - _docker("run", "-d", "--name", name, "-e", "AGENTENV_SETTING_RESOLUTION=1600x900", + docker("run", "-d", "--name", name, "-e", "AGENTENV_SETTING_RESOLUTION=1600x900", "-e", "AGENTENV_SETTING_TIMEZONE=Europe/Paris", base) try: running = _all_running(name) @@ -61,7 +61,7 @@ def test_the_desktop_runs_as_the_session_user_with_the_settings_a_deploy_passes( def test_the_desktop_comes_back_however_often_x_dies(base): name = f"agentenv-test-base-{uuid.uuid4().hex[:8]}" - _docker("run", "-d", "--name", name, base) + docker("run", "-d", "--name", name, base) try: _all_running(name) for _ in range(10): @@ -96,7 +96,7 @@ def test_a_setting_it_cant_use_stops_it_at_start(base, setting, value, error): def test_pushed_to_a_registry_it_registers_as_a_computer_whose_image_put_reads_back(base, stores): - _docker("push", "-q", base) + docker("push", "-q", base) declared = tomllib.loads((BASE / "artifact.toml").read_text()) pinned = DockerImageArtifact.put_ref("base-image", description="the base", image_name=base) @@ -120,9 +120,5 @@ def _all_running(name: str) -> str: return running -def _docker(*args: str) -> subprocess.CompletedProcess: - return subprocess.run(["docker", *args], check=True, capture_output=True) - - def _exec(name: str, *argv: str) -> str: return subprocess.run(["docker", "exec", name, *argv], check=True, capture_output=True, text=True).stdout diff --git a/tst/integration/computer/conftest.py b/tst/integration/computer/conftest.py index c5545cce2..c4543ba42 100644 --- a/tst/integration/computer/conftest.py +++ b/tst/integration/computer/conftest.py @@ -1,4 +1,5 @@ -"""The ubuntu-24.04 base computer, built from its folder once for the tests here and tagged for the local registry.""" +"""The ubuntu-24.04 base computer, built from its folder once for the tests here and tagged for the local registry, and +``docker``, which runs the docker CLI for them.""" import subprocess import uuid @@ -14,6 +15,10 @@ @pytest.fixture(scope="session") def base(): tag = f"localhost:5000/agentenv-test-ubuntu-24.04:{uuid.uuid4().hex[:8]}" - subprocess.run(["docker", "build", "-q", "-t", tag, str(BASE)], check=True, capture_output=True) + docker("build", "-q", "-t", tag, str(BASE)) yield tag subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) + + +def docker(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["docker", *args], check=True, capture_output=True) diff --git a/tst/integration/computer/recording_chaos_test.py b/tst/integration/computer/recording_chaos_test.py index 9c75fa36f..6cd1e2189 100644 --- a/tst/integration/computer/recording_chaos_test.py +++ b/tst/integration/computer/recording_chaos_test.py @@ -17,6 +17,7 @@ from agentenv_protocol.computer import COMPUTER_READY, COMPUTER_RECORDING, Recording, RecordingManifest from agentenv_protocol.computer.conformance.client import Driver from agentenv_protocol.computer.recording import DISK_CHECK_S, RECORDINGS_DIR +from tst.integration.computer.conftest import docker from tst.integration.computer.scaffold_test import _scaffold, _when pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -31,7 +32,7 @@ def image(tmp_path_factory, base): tag = f"agentenv-test-recording-desk:{uuid.uuid4().hex[:8]}" scaffold = _scaffold(tmp_path_factory.mktemp("desk") / "scaffold", base) - _docker("build", "-q", "-t", tag, str(scaffold / "envs" / "desktop")) + docker("build", "-q", "-t", tag, str(scaffold / "envs" / "desktop")) yield tag subprocess.run(["docker", "rmi", "-f", tag], capture_output=True) @@ -41,7 +42,7 @@ async def test_a_recording_outlives_its_x_server_with_a_gap_and_a_new_segment(im async with _desk(image) as (name, driver): started = await _start(driver) await asyncio.sleep(3) - _docker("exec", name, "pkill", "-9", "Xvfb") + docker("exec", name, "pkill", "-9", "Xvfb") await asyncio.sleep(6) manifest = await _end(driver, started) decoded = _decoded(name, manifest, tmp_path) @@ -69,7 +70,7 @@ async def test_a_full_disk_ends_a_recording_as_limit_and_what_it_recorded_plays( async with _desk(image, "--mount", tmpfs) as (name, driver): started = await _start(driver) await asyncio.sleep(2) - _docker("exec", name, "dd", "if=/dev/zero", f"of={RECORDINGS}/fill", "bs=1M", "count=150") + docker("exec", name, "dd", "if=/dev/zero", f"of={RECORDINGS}/fill", "bs=1M", "count=150") await asyncio.sleep(2 * DISK_CHECK_S) manifest = await _end(driver, started) decoded = _decoded(name, manifest, tmp_path) @@ -100,7 +101,7 @@ async def test_a_restarted_driver_forgets_its_recording_and_keeps_the_segments_i async with _desk(image) as (name, driver): started = await _start(driver) await asyncio.sleep(SEGMENT_S + 2) - _docker("restart", "-t", "2", name) + docker("restart", "-t", "2", name) driver = await _ready(name) with pytest.raises(ExtensionCallError) as refused: await _end(driver, started) @@ -112,7 +113,7 @@ async def test_a_restarted_driver_forgets_its_recording_and_keeps_the_segments_i async def _desk(image: str, *docker_args: str): """The desktop in a container of its own, and a client for its driver once it is ready.""" name = f"agentenv-test-desk-{uuid.uuid4().hex[:8]}" - _docker("run", "-d", "--name", name, "-p", f"127.0.0.1::{PORT}", *docker_args, + docker("run", "-d", "--name", name, "-p", f"127.0.0.1::{PORT}", *docker_args, "-e", 'AGENTENV_COMPUTER_SETTINGS={"data_paths": ["fixture"]}', image) try: yield name, await _ready(name) @@ -158,7 +159,7 @@ def _decoded(name: str, manifest: RecordingManifest, tmp: Path) -> list[int]: counts = [] for segment in manifest.segments: local = tmp / segment.path.replace("/", "-") - local.write_bytes(_docker("exec", name, "cat", f"{RECORDINGS}/{segment.path}").stdout) + local.write_bytes(docker("exec", name, "cat", f"{RECORDINGS}/{segment.path}").stdout) with av.open(str(local)) as video: counts.append(sum(1 for _ in video.decode(video=0))) return counts @@ -167,7 +168,3 @@ def _decoded(name: str, manifest: RecordingManifest, tmp: Path) -> list[int]: def _gaps(manifest: RecordingManifest) -> list[float]: segments = manifest.segments return [(_when(b.started_at) - _when(a.ended_at)).total_seconds() for a, b in zip(segments, segments[1:])] - - -def _docker(*args: str) -> subprocess.CompletedProcess: - return subprocess.run(["docker", *args], check=True, capture_output=True) diff --git a/tst/integration/computer/scaffold_test.py b/tst/integration/computer/scaffold_test.py index 0d5a984eb..f269ce6f8 100644 --- a/tst/integration/computer/scaffold_test.py +++ b/tst/integration/computer/scaffold_test.py @@ -64,7 +64,9 @@ def _scaffold(root: Path, base: str) -> Path: shutil.copytree(SCAFFOLD, root, ignore=shutil.ignore_patterns("__pycache__")) desktop = root / "envs" / "desktop" dockerfile = desktop / "Dockerfile" - dockerfile.write_text(dockerfile.read_text().replace("ARG BASE\n", f"ARG BASE={base}\n")) + text = dockerfile.read_text() + assert "\nARG BASE\n" in text, "the scaffold's Dockerfile names its base by ARG BASE, which _scaffold sets" + dockerfile.write_text(text.replace("\nARG BASE\n", f"\nARG BASE={base}\n")) shutil.copy(DRIVER, desktop / "driver.py") shutil.copy(FIXTURE, desktop / "fixture.py") shutil.copytree(PROTOCOL / "src", desktop / "protocol" / "src", ignore=shutil.ignore_patterns("__pycache__")) diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index e5088859a..f55c8c35d 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -141,9 +141,10 @@ def test_what_a_computer_declares_is_checked(registry, changes, message): _put(image, **changes) -def test_an_absolute_link_is_followed_and_an_image_without_os_release_or_not_linux_fails(registry): +def test_an_absolute_link_is_followed_and_an_image_without_os_release_or_not_linux_or_unreadable_fails(registry): linked = registry.image(layer({"etc/os-release": "/usr/lib/os-release", "usr/lib/os-release": UBUNTU})) bare = registry.image(layer({"etc/motd": b"hi\n"})) + garbled = registry.image(b"not a tar") windows = registry._manifest({"mediaType": MANIFEST, "layers": [], "config": {"digest": registry.blob(b'{"os": "windows", "architecture": "amd64"}')}}) @@ -153,3 +154,5 @@ def test_an_absolute_link_is_followed_and_an_image_without_os_release_or_not_lin _put(_image(f"ghcr.io/acme/desktop:2@{bare}"), arch=["amd64"]) with pytest.raises(ValueError, match="it has no linux/amd64 image, only none"): _put(_image(f"ghcr.io/acme/desktop:3@{windows}"), arch=["amd64"]) + with pytest.raises(ValueError, match="its os-release couldn't be read: its layer sha256:[0-9a-f]+ isn't a readable tar"): + _put(_image(f"ghcr.io/acme/desktop:4@{garbled}"), arch=["amd64"]) From 68dbe795431c6b034437da5790f06c8f2fb06aeb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 14:26:23 -0700 Subject: [PATCH 20/22] fix(store): report a blob that can't be written during inspection as a failed check Moving the os-release reading out of the computer artifact left the layer's download and removal outside the error the caller turns into a failed form, so a full disk printed a traceback. Both reads now raise ValueError for a file error, naming the blob. Co-Authored-By: Claude Opus 5.5 --- .../store/image_store/image_contents.py | 13 ++++++++----- tst/unit/artifact/test_computer_artifact.py | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/agent_env/store/image_store/image_contents.py b/src/agent_env/store/image_store/image_contents.py index 888cc1b98..f71f619d9 100644 --- a/src/agent_env/store/image_store/image_contents.py +++ b/src/agent_env/store/image_store/image_contents.py @@ -26,8 +26,11 @@ def platform_manifests(ref: str, auth: RegistryAuth | None) -> tuple[str, dict[s if (entry.get("platform") or {}).get("os") == "linux"} with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "config" - read_blob(pinned, top["config"]["digest"], auth, path) - config = json.loads(path.read_text()) + try: + read_blob(pinned, top["config"]["digest"], auth, path) + config = json.loads(path.read_text()) + except OSError as e: + raise ValueError(f"its config {top['config']['digest']} couldn't be read: {e}") from e return pinned, {config["architecture"]: pinned.rpartition("@")[2]} if config.get("os") == "linux" else {} @@ -40,8 +43,8 @@ def os_release(ref: str, digest: str, auth: RegistryAuth | None) -> dict[str, st with tempfile.TemporaryDirectory() as tmp: for layer in manifest.get("layers") or (): path = Path(tmp) / layer["digest"].replace(":", "-") - read_blob(ref, layer["digest"], auth, path) try: + read_blob(ref, layer["digest"], auth, path) with tarfile.open(path, "r:*") as tar: for member in tar: name = member.name.removeprefix("./") @@ -50,9 +53,9 @@ def os_release(ref: str, digest: str, auth: RegistryAuth | None) -> dict[str, st found[name] = member.linkname elif (extracted := tar.extractfile(member)) is not None: found[name] = extracted.read() + path.unlink() except (OSError, tarfile.TarError) as e: - raise ValueError(f"its layer {layer['digest']} isn't a readable tar: {e}") from e - path.unlink() + raise ValueError(f"its layer {layer['digest']} couldn't be read: {e}") from e if (text := _resolved(found)) is not None: return _parsed(text) raise ValueError("no layer holds /etc/os-release or /usr/lib/os-release") diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index f55c8c35d..2cf6f3941 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -9,6 +9,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.store import get_artifact_store +from agent_env.store.image_store import image_contents from tst.util.oci_registry import MANIFEST, UBUNTU, UBUNTU_LAYER, Registry, layer @pytest.fixture @@ -154,5 +155,21 @@ def test_an_absolute_link_is_followed_and_an_image_without_os_release_or_not_lin _put(_image(f"ghcr.io/acme/desktop:2@{bare}"), arch=["amd64"]) with pytest.raises(ValueError, match="it has no linux/amd64 image, only none"): _put(_image(f"ghcr.io/acme/desktop:3@{windows}"), arch=["amd64"]) - with pytest.raises(ValueError, match="its os-release couldn't be read: its layer sha256:[0-9a-f]+ isn't a readable tar"): + with pytest.raises(ValueError, match="its os-release couldn't be read: its layer sha256:[0-9a-f]+ couldn't be read"): _put(_image(f"ghcr.io/acme/desktop:4@{garbled}"), arch=["amd64"]) + + +@pytest.mark.parametrize("served, problem", [ + ("index", "its os-release couldn't be read: its layer sha256:[0-9a-f]+ couldn't be read"), + ("single", "its config sha256:[0-9a-f]+ couldn't be read"), +]) +def test_a_blob_that_cant_be_written_where_it_is_read_fails_the_put_naming_it(registry, monkeypatch, served, problem): + def full(ref, digest, auth, path): + raise OSError(28, "No space left on device") + + single = registry.image(UBUNTU_LAYER) + digest = registry.index(amd64=single) if served == "index" else single + monkeypatch.setattr(image_contents, "read_blob", full) + + with pytest.raises(ValueError, match=rf"forms.container \(amd64\): {problem}: \[Errno 28\] No space left"): + _put(_image(f"ghcr.io/acme/desktop:1@{digest}"), arch=["amd64"]) From 3e2870d6dff6b8d936076059c2df1c514f15680f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:31:34 -0700 Subject: [PATCH 21/22] test(installer): count the computers bundle among the seven agent-env ships Co-Authored-By: Claude Opus 5.5 --- tst/installer/test_container_journey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 0c475c11d..af104de73 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +6 bundles +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +7 bundles +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout From 18a1aab008baf5090392cf9bd2159ef4c84cbe46 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:45:40 -0700 Subject: [PATCH 22/22] fix(computer): write an image whose zstd layer this Python can't read uninspected, not refused tarfile reads zstd from Python 3.14; before it, a layer of that media type is recorded as uninspected with the reason, as a context-only image is. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 8 ++++++-- src/agent_env/store/image_store/image_contents.py | 11 ++++++++++- tst/unit/artifact/test_computer_artifact.py | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index c9aeb7ed0..a713434b5 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -17,7 +17,7 @@ from agent_env.entity.refs import EntityRef from agent_env.store.base import NotFoundError from agent_env.store.image_store.oci_registry_credentials import names_registry -from agent_env.store.image_store.image_contents import os_release, platform_manifests +from agent_env.store.image_store.image_contents import UnreadableLayerError, os_release, platform_manifests if TYPE_CHECKING: from agent_env.bundle.authoring import AuthoringContext @@ -87,7 +87,7 @@ class Forms(BaseModel): class ImageCheck(BaseModel): """What reading a form's image for one architecture found, as ``metadata.validated_images`` records it: ``passed``, - ``failed`` with the problem, or ``uninspected`` with the reason no registry was read.""" + ``failed`` with the problem, or ``uninspected`` with the reason it wasn't read.""" model_config = ConfigDict(extra="forbid") @@ -214,6 +214,10 @@ def _inspect_container(image: DockerImageArtifact, arches: list[str], os: LinuxO continue try: release = os_release(ref, digest, auth) + except UnreadableLayerError as e: + checks[arch] = ImageCheck(status="uninspected", image=ref, manifest=digest, + reason=f"{e}, so its first boot checks it") + continue except ValueError as e: checks[arch] = ImageCheck(status="failed", image=ref, manifest=digest, problem=f"its os-release couldn't be read: {e}") diff --git a/src/agent_env/store/image_store/image_contents.py b/src/agent_env/store/image_store/image_contents.py index f71f619d9..c15e6e594 100644 --- a/src/agent_env/store/image_store/image_contents.py +++ b/src/agent_env/store/image_store/image_contents.py @@ -14,6 +14,12 @@ _INDEX_TYPES = ("application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json") # os-release(5): /etc/os-release first, else /usr/lib/os-release; on Ubuntu the first links to the second. _OS_RELEASE = ("etc/os-release", "usr/lib/os-release") +# tarfile reads zstd from Python 3.14. +_READS_ZSTD = "zst" in tarfile.TarFile.OPEN_METH + + +class UnreadableLayerError(ValueError): + """A layer compressed in a way this Python can't read.""" def platform_manifests(ref: str, auth: RegistryAuth | None) -> tuple[str, dict[str, str]]: @@ -37,11 +43,14 @@ def platform_manifests(ref: str, auth: RegistryAuth | None) -> tuple[str, dict[s def os_release(ref: str, digest: str, auth: RegistryAuth | None) -> dict[str, str]: """The os-release fields of the image manifest ``digest`` in ``ref``'s repository, from the lowest layer that holds the file, normally the OS's own, so only that layer is fetched. Raises ValueError when no layer holds it or one - can't be read.""" + can't be read, UnreadableLayerError when this Python can't decompress one it reaches.""" manifest = read_manifest(f"{ref.partition('@')[0]}@{digest}", auth) found: dict[str, bytes | str] = {} with tempfile.TemporaryDirectory() as tmp: for layer in manifest.get("layers") or (): + if layer.get("mediaType", "").endswith("+zstd") and not _READS_ZSTD: + raise UnreadableLayerError(f"its layer {layer['digest']} is zstd-compressed, which Python reads from " + "3.14 on") path = Path(tmp) / layer["digest"].replace(":", "-") try: read_blob(ref, layer["digest"], auth, path) diff --git a/tst/unit/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py index 2cf6f3941..ff257825a 100644 --- a/tst/unit/artifact/test_computer_artifact.py +++ b/tst/unit/artifact/test_computer_artifact.py @@ -1,6 +1,7 @@ """The computer artifact: what it declares is checked, each form's image is read from its registry before it's written (its architectures, and the OS its lowest os-release layer names), and a mismatch refuses the put naming its form and -architecture. An image no registry serves yet is written uninspected.""" +architecture. An image no registry serves yet, or one with a layer this Python can't decompress, is written +uninspected.""" import pytest @@ -91,17 +92,27 @@ def test_os_release_comes_from_the_lowest_layer_holding_it_and_a_single_image_is assert manifest["layers"][1]["digest"] not in registry.fetched -def test_an_image_no_registry_serves_yet_or_a_skipped_inspection_is_written_uninspected(registry): +def test_an_image_no_registry_serves_yet_a_skipped_inspection_or_a_layer_python_cant_read_is_written_uninspected( + registry, monkeypatch): built_here = get_artifact_store().put_document(DockerImageArtifact( id="desktop-image", description="", image_name="local/desktop-1234:abcd", build_context_object_url="file:///context.tar.gz", dockerfile_path="container/Dockerfile")) + monkeypatch.setattr(image_contents, "_READS_ZSTD", False) + zstd = registry._manifest({"mediaType": MANIFEST, "config": {"digest": registry.blob(b'{"os": "linux"}')}, "layers": [ + {"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", "digest": registry.blob(b"zstd frames")}]}) from_context = _put(built_here, arch=["amd64"]) skipped = ComputerArtifact.put(inspect=False, **{**from_context.model_dump(exclude={"version", "metadata"})}) + compressed = _put(_image(f"ghcr.io/acme/desktop:1@{registry.index(amd64=zstd)}"), arch=["amd64"]) assert from_context.metadata["validated_images"]["container"]["amd64"]["status"] == "uninspected" assert "only its build context is stored" in from_context.metadata["validated_images"]["container"]["amd64"]["reason"] assert skipped.metadata["validated_images"]["container"]["amd64"]["reason"] == "inspection was skipped" + assert compressed.metadata["validated_images"]["container"]["amd64"] == { + "status": "uninspected", "image": compressed.image().image_name, "manifest": zstd, + "reason": f"its layer {registry.manifests[zstd]['layers'][0]['digest']} is zstd-compressed, which Python reads " + "from 3.14 on, so its first boot checks it", + "checked_at": compressed.metadata["validated_images"]["container"]["amd64"]["checked_at"]} assert registry.fetched == []