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..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. 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 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. | @@ -120,7 +120,8 @@ 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. | -| `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. | +| `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 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 @@ -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/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": [], 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/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/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/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py new file mode 100644 index 000000000..a713434b5 --- /dev/null +++ b/src/agent_env/artifact/artifacts/computer.py @@ -0,0 +1,235 @@ +"""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 re +from datetime import datetime, timezone +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.base import NotFoundError +from agent_env.store.image_store.oci_registry_credentials import names_registry +from agent_env.store.image_store.image_contents import UnreadableLayerError, os_release, platform_manifests + +if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext + +OS_TYPES = ("linux",) +FORMS = ("container",) + + +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 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 it wasn't 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.""" + + 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, 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) + image = computer.image() + 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": { + 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 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 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 _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): + 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: + ref, manifests = platform_manifests(ref, auth) + except ValueError as e: + 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: + 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 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}") + 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] + 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/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/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/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/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..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 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,13 +123,16 @@ 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 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 @@ -172,9 +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 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.""" @@ -238,12 +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] = {} + # 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]] = {} @@ -296,19 +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]: - """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.""" + 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; ``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) - 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.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: @@ -316,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: @@ -327,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 @@ -354,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 @@ -380,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 @@ -498,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: @@ -528,7 +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.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 @@ -731,14 +727,15 @@ 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 + 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 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) 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): @@ -1045,8 +1042,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/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/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/bundle/run.py b/src/agent_env/bundle/run.py index 8c59875b2..1f1786675 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 (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) @@ -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/computers/README.md b/src/agent_env/computers/README.md new file mode 100644 index 000000000..c5d9275c8 --- /dev/null +++ b/src/agent_env/computers/README.md @@ -0,0 +1,23 @@ +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`, 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 +its services as supervisord programs in `/etc/supervisor/conf.d/`; they inherit the display (`DISPLAY=:99`), the +session bus and the time zone. A program that needs X runs as `start-desktop with-x `, 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/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..d3dbf99f9 --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/start-desktop @@ -0,0 +1,28 @@ +#!/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 with-x +# runs a program, the XFCE session first, once X answers. +set -eu +resolution="${AGENTENV_SETTING_RESOLUTION:-1280x800}" +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}" + [ -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 + ;; + with-x) + shift + for _ in $(seq 100); do xdpyinfo >/dev/null 2>&1 && break; sleep 0.1; done + exec "$@" + ;; + *) + 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 new file mode 100644 index 000000000..f88c6c94b --- /dev/null +++ b/src/agent_env/computers/artifacts/ubuntu-24.04/supervisord.conf @@ -0,0 +1,44 @@ +; 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 + +; 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; with-x waits for X before each start. +[program:desktop] +command=/usr/local/bin/start-desktop with-x xfce4-session +priority=20 +autorestart=true +startsecs=0 +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..cf7d4ad1e 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: @@ -68,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/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/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..c15e6e594 --- /dev/null +++ b/src/agent_env/store/image_store/image_contents.py @@ -0,0 +1,100 @@ +"""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") +# 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]]: + """``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" + 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 {} + + +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, 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) + 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() + path.unlink() + except (OSError, tarfile.TarError) as e: + 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") + + +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/src/agent_env/store/image_store/registry_api.py b/src/agent_env/store/image_store/registry_api.py index 403c7e5c0..9de283c47 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 @@ -89,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, @@ -144,3 +125,109 @@ 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: + # 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 + 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 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("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("GET", path, None, stream=True) + try: + if response.status_code != 200: + response.read() + self._raise_for(response) + yield response + finally: + response.close() + + 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(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 + 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: + 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: + 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}); " + "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}") 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 diff --git a/tst/integration/computer/base_computer_test.py b/tst/integration/computer/base_computer_test.py new file mode 100644 index 000000000..b208e24ef --- /dev/null +++ b/tst/integration/computer/base_computer_test.py @@ -0,0 +1,124 @@ +"""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 +import subprocess +import time +import tomllib +import uuid + +import pytest + +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, docker + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +SUPERVISOR = ["supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status"] +ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine().lower()] + + +@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(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", base) + try: + running = _all_running(name) + 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 + + +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) + 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, 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", "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) + + 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(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=base) + + 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"] == "passed", json.dumps(found) + assert found["os_release"] == {"ID": "ubuntu", "VERSION_ID": "24.04"} + 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 _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 new file mode 100644 index 000000000..c4543ba42 --- /dev/null +++ b/tst/integration/computer/conftest.py @@ -0,0 +1,24 @@ +"""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 +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]}" + 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 b92b45b54..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] @@ -28,9 +29,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) @@ -40,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) @@ -68,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) @@ -99,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) @@ -111,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) @@ -157,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 @@ -166,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/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..f269ce6f8 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,15 @@ 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" + 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__")) @@ -104,15 +108,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 +143,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 diff --git a/tst/unit/artifact/test_artifact_metadata.py b/tst/unit/artifact/test_artifact_metadata.py new file mode 100644 index 000000000..e75445b1c --- /dev/null +++ b/tst/unit/artifact/test_artifact_metadata.py @@ -0,0 +1,62 @@ +"""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. 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)) + + +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/artifact/test_computer_artifact.py b/tst/unit/artifact/test_computer_artifact.py new file mode 100644 index 000000000..ff257825a --- /dev/null +++ b/tst/unit/artifact/test_computer_artifact.py @@ -0,0 +1,186 @@ +"""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, or one with a layer this Python can't decompress, is written +uninspected.""" + +import pytest + +from agent_env.artifact import Artifact +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 agent_env.store.image_store import image_contents +from tst.util.oci_registry import MANIFEST, UBUNTU, UBUNTU_LAYER, Registry, layer + +@pytest.fixture +def registry(local_stores, monkeypatch): + return Registry().serve(monkeypatch) + + +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: + 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): + 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 + + +@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) + + 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_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 == [] + + +@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"), + ({"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_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"}')}}) + + 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"]) + 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"]) 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/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/computer_folder_test.py b/tst/unit/bundle/computer_folder_test.py new file mode 100644 index 000000000..7b7176505 --- /dev/null +++ b/tst/unit/bundle/computer_folder_test.py @@ -0,0 +1,114 @@ +"""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 json + +import pytest + +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" +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): + 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) + + 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): + Registry().serve(monkeypatch).manifests[DIGEST] = {"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/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index a16bf49df..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"] @@ -450,6 +452,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.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, + 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) @@ -458,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): @@ -468,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): @@ -500,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): @@ -525,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): @@ -555,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): @@ -564,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/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..e8a095c12 --- /dev/null +++ b/tst/unit/bundle/shipped_computers_test.py @@ -0,0 +1,84 @@ +"""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 +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 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" + + +@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)) + 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 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" + 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_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().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.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) 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/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/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/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" 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" 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): diff --git a/tst/util/oci_registry.py b/tst/util/oci_registry.py new file mode 100644 index 000000000..c4e5963dc --- /dev/null +++ b/tst/util/oci_registry.py @@ -0,0 +1,81 @@ +"""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. +``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' +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.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 + 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 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: + 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})