diff --git a/AGENTS.md b/AGENTS.md index ed76e47de..7c8914c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,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`. | +| `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, with the `port` it serves on and its install by OS, `[install.]`: files, a `python` requirements file for a venv, and the services the base's init runs; its deploy is refused until agent-env creates computers), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; builds end `unverified`, and its deploy is refused until a computer can be booted). `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. | | `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact 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/`. | @@ -153,7 +153,7 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin - A bundle can give its agents aliases in an `aliases.toml` at its root (`[agents] = ["", ...]`, read by `plugins/_bundles.py`): the shipped `agents` bundle names Claude Code `claude-code`. A reference to an alias names the agent its bundle gives it unless the run's bundle has an entity of that name or the store an agent of that - id, which wins. The planner pulls the agent in and writes the task naming it by its id (`Plan.aliases`); a step run + id, which wins. The planner pulls the agent in and writes the task naming it by its id (`Plan.alias_uses`, by kind); a step run outside a bundle run gets it from `A2AAgent.get`, which reads the store first and an alias after (the planner reads the store itself, as `STORE_GETTERS` does). agent-env's own bundles' aliases are theirs, and an alias two other bundles give is refused, naming both. `agent-env a2a-agent list` lists the installed agents with their aliases, then @@ -162,15 +162,16 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin simulator: `plan.solver_steps`), pulled in and checked as a task's reference to it would be, through per-step `step_params` overrides, so the tasks stay as written. - `aliases.toml` gives envs aliases the same way (`[envs] = ["", ...]`; the `mail-mcp` example names - its env `mail`), resolved by the planner (`Plan.env_aliases`) and by `Env.get`, only in a task's steps: an env - definition (a MultiEnv's servers) naming one is refused. The env `Env.get` gives for an alias answers to the alias's + its env `mail`), resolved by the planner and by `Env.get`. The planner resolves an alias in any entry's references as + in a task's steps, so a MultiEnv's servers or a computer env's driver may name one, and writes the entry naming the + env's id, the entry pulled in from another bundle as well as the bundle's own. The env `Env.get` gives for an alias answers to the alias's id, so a deployment records, and steps look it up by, the id the task names; its metadata writes go to the env the 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 + names its base `ubuntu-24.04`), resolved by the planner wherever env aliases are, as a computer env's `computer` names + its base. `Artifact.get` reads no alias: the planner rewrites every alias an entry or 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 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/serve.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve.py new file mode 100644 index 000000000..1b9ed64a3 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/serve.py @@ -0,0 +1,38 @@ +"""Serve a computer driver from its file, as a composed computer's program starts it: +``python -m agentenv_protocol.computer.serve :``, run in the driver's folder, serves the class on +``MCP_PORT``. The file's folder is importable, so it may import its own modules.""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from .driver import ComputerDriverEnvironment + +USAGE = "usage: python -m agentenv_protocol.computer.serve :" + + +def main(argv: list[str]) -> None: + if len(argv) != 1 or ":" not in argv[0]: + raise SystemExit(USAGE) + file, _, name = argv[0].partition(":") + driver_class(Path(file), name)().serve() + + +def driver_class(file: Path, name: str) -> type[ComputerDriverEnvironment]: + """The class ``name`` in ``file``, imported as a module of its own; exits saying why when it isn't a driver.""" + if not file.is_file(): + raise SystemExit(f"{file} isn't a file") + sys.path.insert(0, str(file.resolve().parent)) + spec = importlib.util.spec_from_file_location(file.stem, file) + module = importlib.util.module_from_spec(spec) + sys.modules[file.stem] = module + spec.loader.exec_module(module) + cls = getattr(module, name, None) + if not (isinstance(cls, type) and issubclass(cls, ComputerDriverEnvironment)): + raise SystemExit(f"{file} has no ComputerDriverEnvironment subclass named {name}") + return cls + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/packages/agentenv-protocol/tests/test_computer_serve.py b/packages/agentenv-protocol/tests/test_computer_serve.py new file mode 100644 index 000000000..bcf5e0b23 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_computer_serve.py @@ -0,0 +1,75 @@ +"""Serving a driver from its file: the class is imported with its folder importable and served; anything that isn't +a driver class exits saying why.""" +from __future__ import annotations + +import sys + +import pytest + +pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("PIL") + +from agentenv_protocol.computer import ComputerDriverEnvironment # noqa: E402 +from agentenv_protocol.computer.serve import USAGE, main # noqa: E402 + +DRIVER = '''from desk_helpers import SIZE + +from agentenv_protocol.computer import (ComputerDriverEnvironment, Display, capture, click, computer_driver, display, + drag, press, scroll, type_text) + + +@computer_driver(name="desk", supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64"]}]) +class Desk(ComputerDriverEnvironment): + @display + def size(self): return Display(*SIZE) + + @capture + def grab(self): ... + + @click + def click(self, x, y, button, count): ... + + @drag + def drag(self, path, button): ... + + @scroll + def scroll(self, x, y, dx, dy): ... + + @type_text + def type(self, text): ... + + @press + def press(self, keys): ... +''' + + +@pytest.fixture +def served(monkeypatch): + calls = [] + monkeypatch.setattr(ComputerDriverEnvironment, "serve", lambda self: calls.append(type(self).__name__)) + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr(sys, "modules", dict(sys.modules)) + return calls + + +def test_the_class_is_imported_from_its_file_with_its_folder_importable_and_served(tmp_path, served): + (tmp_path / "driver.py").write_text(DRIVER) + (tmp_path / "desk_helpers.py").write_text("SIZE = (10, 10)\n") + + main([f"{tmp_path / 'driver.py'}:Desk"]) + + assert served == ["Desk"] + + +@pytest.mark.parametrize("argv, message", [ + ([], USAGE), (["driver.py"], USAGE), (["missing.py:Desk"], "missing.py isn't a file"), + (["driver.py:Helper"], "has no ComputerDriverEnvironment subclass named Helper"), +], ids=["nothing", "no-class", "no-file", "not-a-driver"]) +def test_what_isnt_a_driver_class_exits_saying_why(tmp_path, served, monkeypatch, argv, message): + (tmp_path / "driver.py").write_text("class Helper:\n pass\n") + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as stopped: + main(argv) + + assert message in str(stopped.value) and served == [] diff --git a/src/agent_env/artifact/artifacts/computer.py b/src/agent_env/artifact/artifacts/computer.py index a713434b5..98858bfc9 100644 --- a/src/agent_env/artifact/artifacts/computer.py +++ b/src/agent_env/artifact/artifacts/computer.py @@ -24,6 +24,8 @@ OS_TYPES = ("linux",) FORMS = ("container",) +#: What a setting holds: its default's type, which a value an env or a deploy gives must share. +SettingValue = str | int | float | bool class LinuxOS(BaseModel): @@ -53,7 +55,7 @@ class Setting(BaseModel): model_config = ConfigDict(extra="forbid") - default: str | int | float | bool + default: SettingValue phase: Literal["build", "deploy"] scope: Literal["task", "user"] = "task" secret: bool = False diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 663281ee8..fd64c9d68 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -132,7 +132,7 @@ def put( repository = image_repository(id) image_ref = image_store.image_ref(repository, f"v{version}") image_store.ensure_repository(repository) - _push_local_image(image_name, image_ref, image_store) + push_local_image(image_name, image_ref, image_store) platform = normalize_platform(platform) if platform else _image_platform(image_ref) with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: @@ -584,20 +584,13 @@ def push_context_image(id: str, context_path: str, dockerfile_path: str, before() local = f"{local_image_repository(id)}:{tag}" build_image(Path(context_path) / context.dockerfile, Path(context_path), local, platform=DEFAULT_BUILD_PLATFORM) - _push_local_image(local, ref, store) + push_local_image(local, ref, store) return ref, pin_digest(ref, store.auth(ref)).rpartition("@")[2] -def _push_local_image(local_tag: str, image_ref: str, image_store) -> None: - auth = image_store.auth(image_ref) - if auth is not None: - login = subprocess.run( - ["docker", "login", "--username", auth.username, "--password-stdin", auth.registry], - input=auth.password, text=True, capture_output=True, - ) - if login.returncode != 0: - raise RuntimeError(f"docker login failed: {login.stderr}") - +def push_local_image(local_tag: str, image_ref: str, image_store) -> None: + """Push this machine's ``local_tag`` to ``image_ref`` in ``image_store``, signing in with its credentials first.""" + docker_login(image_store.auth(image_ref)) for cmd, label in [ (["docker", "tag", local_tag, image_ref], "tag"), (["docker", "push", image_ref], "push"), @@ -608,6 +601,16 @@ def _push_local_image(local_tag: str, image_ref: str, image_store) -> None: logger.info(f"Pushed {local_tag} -> {image_ref}") +def docker_login(auth) -> None: + """Sign this machine's Docker in to ``auth``'s registry, when there are credentials to sign in with.""" + if auth is None: + return + login = subprocess.run(["docker", "login", "--username", auth.username, "--password-stdin", auth.registry], + input=auth.password, text=True, capture_output=True) + if login.returncode != 0: + raise RuntimeError(f"docker login failed: {login.stderr}") + + def _parse_copy_sources(dockerfile_text: str, dockerfile_rel_path: str | None = None) -> list[str]: """Parse COPY/ADD source paths from Dockerfile text. Returns top-level directories to include in context tar.""" paths: list[str] = [] diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 0cf1f0560..bca0fb3cc 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -7,7 +7,7 @@ import stat import tempfile import unicodedata -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -49,6 +49,8 @@ class AuthoringContext: bundle: Bundle entry: BundleEntry + #: Called just before writing the entry builds an image with this machine's Docker, which a run says it does. + on_build: Callable[[], None] | None = None @property def id(self) -> str: diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 1382a4b7b..f85afab91 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -156,6 +156,8 @@ def _write_entity(plan: Plan, write: Write, here: bool, on_build: Callable[[Writ if write.id in plan.published: return _write_published_image(plan, write) return _write_build_context(plan, write, here, on_build) + if write.kind is BundleKind.ENV: + return _write_env(plan, write, _before(on_build, write)) return _WRITERS[write.kind](plan, write) @@ -281,10 +283,11 @@ def _write_agent(plan: Plan, write: Write) -> int: AuthoringContext(plan.bundle_of(entry), entry)).version -def _write_env(plan: Plan, write: Write) -> int: +def _write_env(plan: Plan, write: Write, on_build: Callable[[], None] | None = None) -> int: entry = write.source.entry cls = get_env_registry()[entry.type] - return cls.from_toml(_pinned(plan, write, cls.toml_refs), AuthoringContext(plan.bundle_of(entry), entry)).version + return cls.from_toml(_pinned(plan, write, cls.toml_refs), + AuthoringContext(plan.bundle_of(entry), entry, on_build=on_build)).version def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 84fb3f8e9..fd6d09a1a 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -34,6 +34,7 @@ from agent_env.entity.refs import EntityKind, RefRole, default_agent_refs, ref_sites from agent_env.env.env import Env from agent_env.env.envs._deployment import provider_refusal +from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.computer_driver import ComputerDriverEnv from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv @@ -64,7 +65,8 @@ from ._fs import fold, relative, with_article from .authoring import AuthoringContext, entry_files, staged_build_context from .parse import NAMED_BY, Bundle, BundleEntry, BundleError, BundleKind, parse_bundle -from .resolve import BuiltImage, BundleImage, Reference, ResolvedBundle, ResolvedEntry, build_step, resolve_bundle +from .resolve import (BuiltImage, BundleImage, Reference, ResolvedBundle, ResolvedEntry, build_step, entity_class, + resolve_bundle) if TYPE_CHECKING: from .installed import InstalledBundle @@ -96,7 +98,7 @@ def _stored_env(id: str, version: int | None) -> Env: _FOLDER_HOOKS = {"written_besides": Artifact, "seeded_environments": SeedsEnvironments} # The env types whose from_toml reads only the toml and what it names, so a type built by one of them is written from # its env.toml and the ledger can list what it's made from. -_ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv, ComputerDriverEnv) +_ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv, ComputerDriverEnv, ComputerEnv) # The env types whose from_toml also reads every file in the env's folder, which the ledger hashes as an artifact's. _ENV_FROM_FOLDERS = (ComputerDriverEnv,) @@ -275,6 +277,7 @@ def _localized(self, entry: ResolvedEntry) -> ResolvedEntry: """``entry`` with each reference to an installed bundle's entity pointing at it, pulled into the plan, as a reference to the bundle's own entity points at that; for a task, with a reference for each step that deploys the configured default agent when this bundle or an installed one defines it.""" + entry = self._pointed(entry) references = [ref if ref.local is not None else self._pulled(ref) for ref in entry.references] if entry.entry.kind is BundleKind.TASK and self.default_agents: references += self._default_agents(entry) @@ -301,32 +304,28 @@ def _default_agents(self, task: ResolvedEntry, agent_id: str | None = None) -> l # Agents named by an installed bundle's alias - 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.""" + def _resolve_aliases(self, *, runs: bool = True) -> None: + """Point each entry's reference to an agent, env or artifact by an alias an installed bundle gives it, and each + task's to 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. Without ``runs``, as when entities are written alone, tasks + and evals are left as they are, since none of them runs.""" if self.installed is None: return installed = self.installed() self.alias_tables = {kind: installed_aliases(installed, table) for kind, table in _ALIAS_TABLES.items()} + own = {_key(entry.entry) for entry in self.entries} + for key, node in list(self.nodes.items()): # those pulled in before the aliases were read + if key not in own and isinstance(node, ResolvedEntry) and (pointed := self._pointed(node)) is not node: + self.nodes[key], self.edges[key] = pointed, _needs(pointed) default = _UNREAD for index, entry in enumerate(self.entries): - if entry.entry.kind is not BundleKind.TASK: + if not runs and entry.entry.kind in (BundleKind.TASK, BundleKind.EVAL): continue - references, aliased = [], {} - for ref in entry.references: - use = None - 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: - aliased[ref.kind, ref.id] = use.target_id - ref = self._pulled(replace(ref, id=use.target_id)) - references.append(ref) - if self.default_agents and _leaves_an_agent_unset(entry): + pointed = self._pointed(entry) + references = list(pointed.references) + if entry.entry.kind is BundleKind.TASK and self.default_agents and _leaves_an_agent_unset(entry): if default is _UNREAD: default = self._alias_use(EntityKind.AGENT, get_config().get_default_a2a_agent_id(), None) if isinstance(default, str): @@ -334,7 +333,31 @@ def _resolve_aliases(self) -> None: 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))) + self._replace(index, replace(pointed, references=tuple(references))) + + def _pointed(self, entry: ResolvedEntry) -> ResolvedEntry: + """``entry`` with each store reference naming an installed alias pointed at what the alias names, pulled in, and + each field naming the alias naming its id; none is pointed until ``_resolve_aliases`` reads the aliases.""" + references, aliased = [], {} + for ref in entry.references: + if ref.local is None and (use := self._alias_of(ref)) is not None: + aliased[ref.kind, ref.id] = use.target_id + ref = self._pulled(replace(ref, id=use.target_id)) + references.append(ref) + if not aliased: + return entry + return replace(entry, config=_renamed(entry, aliased), references=tuple(references)) + + def _alias_of(self, ref: Reference) -> AliasUse | None: + """The installed alias ``ref``, a store reference, names, to point it at: None when it names none, or the store's + entity of its id shadows it. Why an alias can't be named so is kept for the store ref check to report.""" + if ref.kind not in self.alias_tables: + return None + use = self._alias_use(ref.kind, ref.id, ref.version) + if isinstance(use, str): + self.unpulled[ref.kind, ref.id, ref.version] = use + return None + return use if use is not None and use.store_version is None else None 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 @@ -504,6 +527,7 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str], agent: str | None = N return self._planned(selected_tasks + selected_evals, selected_tasks, selected_evals, override) def plan_entities(self, ids: Sequence[str]) -> Plan: + self._resolve_aliases(runs=False) self._check_writers() self._check_loops() return self._planned(self._select_entities(ids), [], [], None) @@ -729,16 +753,10 @@ 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)) - 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 " - 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: - hint = table.hint(ref.id) if table is not None else "" + hint = table.hint(ref.id) if (table := self.alias_tables.get(ref.kind)) is not None else "" return f"there is no {kind} {ref.id!r}{at} in the store{hint}" if isinstance(found, str): return f"{kind} {ref.id!r} {found}" @@ -1134,19 +1152,23 @@ def _leaves_an_agent_unset(task: ResolvedEntry) -> bool: return False -def _renamed(steps: Any, ids: Mapping[tuple[EntityKind, str], str]) -> Any: - """A copy of a task's ``steps`` with each field naming an alias in ``ids``, by kind, naming what it names - instead.""" +def _renamed(entry: ResolvedEntry, ids: Mapping[tuple[EntityKind, str], str]) -> Any: + """A copy of ``entry``'s config with each field naming an alias in ``ids``, by kind, naming what it names instead: + a task's steps by their types' refs, any other entry's toml by its class's ``toml_refs``.""" if not ids: - return steps - steps = copy.deepcopy(steps) - registry = get_task_step_registry() - for step in steps: - cls = registry.get(step.get("type")) if isinstance(step.get("type"), str) else None - for site in ref_sites(cls.entity_refs or () if cls is not None else (), step): - if site.ref.role is RefRole.INPUT and (site.ref.kind, site.value) in ids: - site.rewrite(ids[site.ref.kind, site.value]) - return steps + return entry.config + config = copy.deepcopy(entry.config) + if entry.entry.kind is BundleKind.TASK: + registry = get_task_step_registry() + sites = [site for step in config + for site in ref_sites(getattr(registry.get(step.get("type")) if isinstance(step.get("type"), str) + else None, "entity_refs", None) or (), step)] + else: + sites = list(ref_sites(getattr(entity_class(entry.entry), "toml_refs", ()), config, inline_pins=True)) + for site in sites: + if site.ref.role is RefRole.INPUT and (site.ref.kind, site.value) in ids: + site.rewrite(ids[site.ref.kind, site.value]) + return config def _needs(entry: ResolvedEntry) -> list[_Key]: diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index 6face9809..33a76567b 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -272,20 +272,13 @@ def _image_folder(self, entry: BundleEntry) -> bool: # Parts of a config def _toml_class(self, entry: BundleEntry) -> type: - group = None - if entry.kind is BundleKind.ENV: - if entry.type in _INFRA_ENVS: - self._problem(entry, f"a {entry.type} env isn't written from a bundle: config names the one every " - f"deploy uses ({_INFRA_ENVS[entry.type]}), and a run builds it when it's missing") - raise _Skip - cls, group = get_env_registry().get(entry.type), _registration.ENVS - elif entry.kind is BundleKind.AGENT: - cls = A2AAgent if entry.type == A2AAgent.type else None - elif entry.kind is BundleKind.EVAL: - cls = Eval if entry.type == Eval.type else None - else: - cls, group = get_artifact_registry().get(canonical_type(entry.type)), _registration.ARTIFACTS - if cls is None: + if entry.kind is BundleKind.ENV and entry.type in _INFRA_ENVS: + self._problem(entry, f"a {entry.type} env isn't written from a bundle: config names the one every " + f"deploy uses ({_INFRA_ENVS[entry.type]}), and a run builds it when it's missing") + raise _Skip + if (cls := entity_class(entry)) is None: + group = {BundleKind.ENV: _registration.ENVS, BundleKind.AGENT: None, BundleKind.EVAL: None}.get( + entry.kind, _registration.ARTIFACTS) name = canonical_type(entry.type) if group == _registration.ARTIFACTS else entry.type note = _registration.failure_note(group, name) if group else "" self._problem(entry, f"{entry.type!r} is not a known {entry.kind.store} type{note}") @@ -472,6 +465,18 @@ def _problem(self, entry: BundleEntry, message: str) -> None: self.problems.append(f"{relative(self.bundle.root, entry.path)}: {message}") +def entity_class(entry: BundleEntry) -> type | None: + """The class that writes ``entry``: its env type's, its artifact type's, the agent's or the eval's; None for a type + nothing registers.""" + if entry.kind is BundleKind.ENV: + return get_env_registry().get(entry.type) + if entry.kind is BundleKind.AGENT: + return A2AAgent if entry.type == A2AAgent.type else None + if entry.kind is BundleKind.EVAL: + return Eval if entry.type == Eval.type else None + return get_artifact_registry().get(canonical_type(entry.type)) + + def _registry_problem(key: str, name: Any) -> str | None: """Why ``name``, a registry image ``key`` gives, can't be pulled as written.""" if isinstance(name, str) and names_registry(name): diff --git a/src/agent_env/cli/env/__init__.py b/src/agent_env/cli/env/__init__.py index eed7e2ccf..71f3f826d 100644 --- a/src/agent_env/cli/env/__init__.py +++ b/src/agent_env/cli/env/__init__.py @@ -1,5 +1,6 @@ import click +from .build import build from .deploy import deploy from .gateway import gateway from .get_instance import get_instance @@ -18,6 +19,7 @@ def env(): pass +env.add_command(build) env.add_command(deploy) env.add_command(gateway) env.add_command(get_instance) diff --git a/src/agent_env/cli/env/build.py b/src/agent_env/cli/env/build.py new file mode 100644 index 000000000..f2ac2a72b --- /dev/null +++ b/src/agent_env/cli/env/build.py @@ -0,0 +1,34 @@ +"""Build the forms and arches of a computer env not built yet: those that failed, and those a put that stopped left +building.""" + +import click + +from agent_env.env import Env +from agent_env.env.envs.computer import composer +from agent_env.env.envs.computer.env import ComputerEnv +from agent_env.store.base import NotFoundError + + +@click.command() +@click.option("--id", "env_id", required=True, help="The computer env's id") +@click.option("--version", type=int, default=None, help="Its version; the latest when left out") +def build(env_id: str, version: int | None): + """Build a computer env's forms and arches not built yet, reusing any image of the same key, and print each one's + status; exits 1 when one failed.""" + try: + env = Env.get(env_id, version) + except NotFoundError as e: + raise click.ClickException(str(e)) from None + if not isinstance(env, ComputerEnv): + raise click.ClickException(f"{env_id} is a {env.type} env; only a computer env is built") + if (pairs := composer.pending(env)) or not composer.current(env): + composer.compose(env, pairs) + failed = False + for form, by_arch in env.metadata.get("composed", {}).items(): + for arch, result in by_arch.items(): + failed |= result["status"] == "failed" + detail = result.get("reason") or result.get("image", "") + click.echo(f"{form}/{arch}: {result['status']}" + (f" ({'reused, ' if result.get('reused') else ''}" + f"{detail})" if detail else "")) + if failed: + raise SystemExit(1) diff --git a/src/agent_env/computers/README.md b/src/agent_env/computers/README.md index c5d9275c8..fd03b9515 100644 --- a/src/agent_env/computers/README.md +++ b/src/agent_env/computers/README.md @@ -19,5 +19,9 @@ its services as supervisord programs in `/etc/supervisor/conf.d/`; they inherit 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. +A computer env composes a driver onto it at put: an `envs//env.toml` of `type = "computer"` names +`computer = "ubuntu-24.04"` and `driver = "reference"`, and its put builds an image of the two for each arch this +machine's Docker can build. + 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/drivers/README.md b/src/agent_env/drivers/README.md index 256ee7e45..6193e30f9 100644 --- a/src/agent_env/drivers/README.md +++ b/src/agent_env/drivers/README.md @@ -7,8 +7,10 @@ pointer and keyboard groups, and `computer-ready`, `computer-display`, `computer `x11grab` through PyAV at up to 30 fps. Name it from another bundle as `reference`, the alias `aliases.toml` gives it, or by its id, `@local/agentenv-framework/drivers/reference`. -A run that names it writes it: its files, and what its class declares for each platform it supports, read without -`mss`, `python-xlib` or PyAV installed. It deploys inside a computer, which agent-env can't create yet, so a deploy of it is +A run that names it writes it: its files, what its class declares for each platform it supports, read without +`mss`, `python-xlib` or PyAV installed, and how a computer installs it. Its `env.toml`'s `[install.linux]` has a +computer env's composer put the folder in `/opt/agentenv/drivers/reference/`, install `requirements.txt` into a venv +there, and run `agentenv_protocol.computer.serve driver.py:ReferenceDriver` as a session service on `port`, 18765. It deploys inside a computer, which agent-env can't create yet, so a deploy of it is refused until then. `python -m agentenv_protocol.computer.conformance ` checks it, or any driver, where it runs. This bundle has no tasks of its own: other bundles pull its drivers by alias, and `agent-env run drivers` has nothing diff --git a/src/agent_env/drivers/envs/reference/env.toml b/src/agent_env/drivers/envs/reference/env.toml index 6ec1dff44..cbd46fcb0 100644 --- a/src/agent_env/drivers/envs/reference/env.toml +++ b/src/agent_env/drivers/envs/reference/env.toml @@ -1,2 +1,7 @@ type = "computer_driver" module = "driver.py:ReferenceDriver" +port = 18765 + +[install.linux] +python = "requirements.txt" +services = [{ name = "driver", command = "venv/bin/python -m agentenv_protocol.computer.serve driver.py:ReferenceDriver" }] diff --git a/src/agent_env/drivers/envs/reference/requirements.txt b/src/agent_env/drivers/envs/reference/requirements.txt new file mode 100644 index 000000000..c9bafe585 --- /dev/null +++ b/src/agent_env/drivers/envs/reference/requirements.txt @@ -0,0 +1,5 @@ +# What a composed computer installs into the driver's venv. A release pins agentenv-framework-protocol to the version +# it ships, which serves the driver with agentenv_protocol.computer.serve. +agentenv-framework-protocol[computer]>=0.1.381 +mss>=9,<11 +python-xlib>=0.33,<0.34 diff --git a/src/agent_env/env/envs/computer/__init__.py b/src/agent_env/env/envs/computer/__init__.py new file mode 100644 index 000000000..350712890 --- /dev/null +++ b/src/agent_env/env/envs/computer/__init__.py @@ -0,0 +1 @@ +"""The computer env type: a base computer and a driver, composed at put into an image for each form and arch.""" diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py new file mode 100644 index 000000000..52e0ba33e --- /dev/null +++ b/src/agent_env/env/envs/computer/composer.py @@ -0,0 +1,427 @@ +"""The composer: a computer env's base and driver built into one image for each form and arch with this machine's Docker, +tagged in the env's image store by a key of everything that goes into it, so an image of that key, built for any env, is +reused. +Each result is recorded in the env's metadata as it finishes (``composed``), and the arches that built are written as +the env's computer, a ``computer`` artifact over an index of their images.""" + +from __future__ import annotations + +import copy +import hashlib +import importlib.metadata +import io +import json +import logging +import re +import shutil +import subprocess +import tarfile +import tempfile +import threading +import urllib.parse +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, docker_login, push_local_image +from agent_env.artifact.artifacts.file import FileArtifact +from agent_env.config import get_config +from agent_env.entity.export import Unwritable, checked_path +from agent_env.env.env import Env +from agent_env.env.envs.computer import gate +from agent_env.env.envs.computer.install import Install +from agent_env.providers.sandbox_providers.host_images import build_for, host_platform, platform_tag +from agent_env.store.base import ConcurrentModificationError +from agent_env.store.ids import derive_id +from agent_env.store.image_store.image_contents import labels +from agent_env.store.image_store.oci_registry_credentials import names_registry +from agent_env.store.image_store.registry_api import NoSuchImageError, pin_digest +from agent_env.utils.docker.build import DockerBuildError, build_image + +if TYPE_CHECKING: + from agent_env.env.envs.computer.env import ComputerEnv + from agent_env.env.envs.computer_driver import ComputerDriverEnv + +logger = logging.getLogger(__name__) + +#: The image store repository composed computers are pushed to, tagged by their keys. +REPOSITORY = "agentenv-computers" +#: Where a driver's files go in a composed computer, in a folder named for the driver. +DRIVERS = "/opt/agentenv/drivers" +KEY_LABEL = "dev.agentenv.computer-key" +# What a build installed and whether it was emulated, kept on its image for an env that reuses it. +INSTALLED_LABEL, EMULATED_LABEL = "dev.agentenv.installed", "dev.agentenv.emulated" +# What a file starts with when it's only ever run: a script or a Linux binary, made executable in the image, since the +# store keeps no file modes. +_RUN_ONLY = (b"#!", b"\x7fELF") +_EXECUTABLE = 0o755 +# A pair's statuses once its image is in the store. +_BUILT = ("unverified", "ready") +_WRITE_TRIES = 4 +_PROTOCOL = "agentenv-framework-protocol" +# What building the protocol from its source reads, beside src/. +_PROTOCOL_FILES = ("pyproject.toml", "README.md", "LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md") + + +def compose(env: ComputerEnv, pairs: list[tuple[str, str]] | None = None, + on_build: Callable[[], None] | None = None) -> None: + """Build each of ``pairs``, by default each form the base has in each arch the env composes, in parallel, + recording each as it starts and finishes, then write the env's computer over every pair built so far. ``on_build`` + is called once, just before the first image is built here; reusing them all calls it never.""" + base, driver = env.computer, env.driver + building = _once(on_build) + pairs = pairs if pairs is not None else [(form, arch) for form in gate.forms(base) for arch in gate.arches(base, driver)] + record = _Record(env) + record.started(pairs) + with ThreadPoolExecutor(max_workers=max(len(pairs), 1)) as pool: + futures = {pool.submit(_built, env, base, driver, form, arch, building): (form, arch) for form, arch in pairs} + for future in as_completed(futures): + form, arch = futures[future] + result = future.result() + if result["status"] == "failed": + logger.warning(f"{env.stored_id}: {form}/{arch} failed at {result['stage']}: {result['reason']}") + else: + logger.info(f"{env.stored_id}: {form}/{arch} {'reused' if result['reused'] else 'built'}: {result['image']}") + record.finished(form, arch, result) + record.computer(_computer(env, base, record.built())) + + +def current(env: ComputerEnv) -> bool: + """Whether the env's computer was written over every pair built, as a put or env build that failed writing it + leaves it not.""" + return "computer" in env.metadata and env.metadata["computer"].get("images") == _Record(env).built() + + +def pending(env: ComputerEnv) -> list[tuple[str, str]]: + """The forms and arches of ``env`` not yet built: never started, failed, or left building by a put that stopped.""" + composed = env.metadata.get("composed", {}) + return [(form, arch) for form in gate.forms(env.computer) for arch in gate.arches(env.computer, env.driver) + if composed.get(form, {}).get(arch, {}).get("status") not in _BUILT] + + +def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, form: str, arch: str, + building: Callable[[], None]) -> dict: + """The status of ``form`` and ``arch`` once built and pushed, or reused, or the stage that failed and why; + ``building`` is called before anything builds.""" + stage, key = "build", None + try: + with tempfile.TemporaryDirectory(prefix="agentenv-compose-") as work: + context = Path(work) + source, base_identity = _base(base, arch) + layer = _stage_driver(context, driver, base) + dockerfile = _dockerfile(source, layer, driver.install[base.os.type], base, _build_settings(env, base), + _stage_protocol(context)) + key = "sha256:" + hashlib.sha256(json.dumps({ + "base": base_identity, "dockerfile": dockerfile, "context": _digest(context), "form": form, + "arch": arch, "composer": _composer_digest(), + }, sort_keys=True).encode()).hexdigest() + store = get_config().get_image_store_for(env.stored_id) + store.ensure_repository(REPOSITORY) + ref = store.image_ref(REPOSITORY, key.removeprefix("sha256:")) + if (pinned := _served(ref, store)) is not None: + kept = labels(pinned, arch, store.auth(pinned)) + return {"status": "unverified", "key": key, "image": pinned, "reused": True, + "emulated": kept.get(EMULATED_LABEL) == "true", + "installed": json.loads(kept.get(INSTALLED_LABEL, "{}")), "built_at": _now()} + platform = f"linux/{arch}" + emulated = platform != host_platform() + if emulated and platform not in _emulated_platforms(): + return _failed(env, form, arch, "build", key, f"this machine can't build {platform}: its Docker has " + "no emulation for it") + building() + _ready_base(base, arch) + (context / "Dockerfile").write_text(f"{dockerfile}LABEL {KEY_LABEL}={key}\n") + local = f"agentenv-computer:{key[7:23]}-{arch}" + build_image(context / "Dockerfile", context, local, platform=platform, labels={KEY_LABEL: key}) + installed = _installed(local, layer) + _label(local, platform, {INSTALLED_LABEL: json.dumps(installed, sort_keys=True), + EMULATED_LABEL: "true" if emulated else "false"}) + stage = "push" + push_local_image(local, ref, store) + return {"status": "unverified", "key": key, "image": pin_digest(ref, store.auth(ref)), "reused": False, + "emulated": emulated, "installed": installed, "built_at": _now()} + except DockerBuildError as e: + # Docker can list a platform it has lost the emulation for, until it restarts; the build says so. + unemulated = "exec format error" in str(e) + return _failed(env, form, arch, stage, key, f"this machine can't run linux/{arch} programs: its Docker has no " + "emulation for it" if unemulated else "the build failed", log=str(e)) + except subprocess.CalledProcessError as e: + return _failed(env, form, arch, stage, key, f"{' '.join(e.cmd[:2])} failed: {(e.stderr or '').strip()}") + except (RuntimeError, ValueError, OSError) as e: + return _failed(env, form, arch, stage, key, f"{type(e).__name__}: {e}") + + +def _once(fn: Callable[[], None] | None) -> Callable[[], None]: + """``fn``, called at most once however many builds call it at once; nothing when it's None.""" + lock, called = threading.Lock(), [] + + def once() -> None: + with lock: + if fn is not None and not called: + called.append(True) + fn() + return once + + +def _base(base: ComputerArtifact, arch: str) -> tuple[str, str]: + """What the composed image is built ``FROM`` for ``arch``, and the base's part of the key, found without building + anything: a registry image pinned to its digest, or the tag a build of the base's stored build context for that + arch gets, the same on every machine.""" + image = base.image() + if names_registry(image.image_name): + store = get_config().get_image_store() + pinned = image.pinned_name if "@" in image.pinned_name else pin_digest(image.pinned_name, store.auth(image.pinned_name)) + return pinned, pinned + return platform_tag(image, f"linux/{arch}"), f"sources:{image.source_digest}" + + +def _ready_base(base: ComputerArtifact, arch: str) -> None: + """Have this machine's Docker able to build ``FROM`` the base for ``arch``: signed in to its registry with the image + store's credentials for it, or holding it built from its stored build context.""" + image = base.image() + if names_registry(image.image_name): + docker_login(get_config().get_image_store().auth(image.pinned_name)) + else: + build_for(image, f"linux/{arch}") + + +def _stage_driver(context: Path, driver: ComputerDriverEnv, base: ComputerArtifact) -> str: + """Write the driver's install files under ``context/driver/`` and its supervisord programs beside them; the name + of its folder under ``DRIVERS``, its id's last part.""" + install = driver.install[base.os.type] + layer = re.sub(r"[^a-z0-9-]+", "-", driver.id.rsplit("/", 1)[-1].lower()).strip("-") or "driver" + folder = PurePosixPath(install.files) + for name, artifact in driver.files.get_file_artifacts().items(): + try: + path = PurePosixPath(checked_path(name)) + except Unwritable as e: + raise ValueError(f"driver {driver.id}: {e}") from None + if folder == PurePosixPath(".") or path.is_relative_to(folder): + target = context / "driver" / path.relative_to(folder) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data := artifact.load()) + if data.startswith(_RUN_ONLY): + target.chmod(_EXECUTABLE) + (context / "driver").mkdir(exist_ok=True) + (context / f"agentenv-{layer}.conf").write_text(_programs(layer, install, driver.port)) + return layer + + +def _programs(layer: str, install: Install, port: int) -> str: + prefix = f"{DRIVERS}/{layer}" + programs = [] + for service in install.services: + program, _, rest = service.command.partition(" ") + if "/" in program and not program.startswith("/"): + program = f"{prefix}/{program}" + programs.append(f"[program:{layer}-{service.name}]\ncommand={program} {rest}".rstrip() + "\n" + f"directory={prefix}\nenvironment=MCP_PORT=\"{port}\"\nautorestart=true\nredirect_stderr=true\n" + f"stdout_logfile=/var/log/agentenv/{layer}-{service.name}.log\n") + return "\n".join(programs) + + +def _stage_protocol(context: Path) -> bool: + """Copy the protocol's source to ``context/protocol/`` when this install runs it from one, as a checkout does; + False when it's installed from an index, as a release is, and the driver's requirements name it.""" + if (source := _protocol_source()) is None: + return False + shutil.copytree(source / "src", context / "protocol" / "src", ignore=shutil.ignore_patterns("__pycache__")) + for name in _PROTOCOL_FILES: + if (source / name).is_file(): + shutil.copy(source / name, context / "protocol" / name) + return True + + +def _protocol_source() -> Path | None: + direct = importlib.metadata.distribution(_PROTOCOL).read_text("direct_url.json") + url = json.loads(direct).get("url", "") if direct else "" + if not url.startswith("file://"): + return None + folder = Path(urllib.parse.unquote(urllib.parse.urlparse(url).path)) + return folder if (folder / "pyproject.toml").is_file() else None + + +def _build_settings(env: ComputerEnv, base: ComputerArtifact) -> dict[str, Any]: + """The env's values of the base's build-phase settings, which the composed image fixes: its display is the + resolution's, for a base that reads that at build.""" + values = {**env.computer_settings, **({"resolution": _resolution(env.display)} if env.display else {})} + return {name: values[name] for name, setting in base.config.items() if setting.phase == "build" and name in values} + + +def _resolution(display: dict) -> str: + return f"{display['width']}x{display['height']}" + + +def _dockerfile(source: str, layer: str, install: Install, base: ComputerArtifact, build_settings: dict, + protocol: bool) -> str: + """The composed image's Dockerfile but its key's label, which the key is a digest of.""" + prefix = f"{DRIVERS}/{layer}" + lines = [f"FROM {source}", "USER root", f"COPY driver/ {prefix}/"] + if install.python is not None: + packages = f"/tmp/agentenv-protocol -r {prefix}/{install.python}" if protocol else f"-r {prefix}/{install.python}" + if protocol: + lines.append("COPY protocol /tmp/agentenv-protocol") + lines.append(f"RUN python3 -m venv {prefix}/venv && {prefix}/venv/bin/pip install --no-cache-dir {packages} " + f"&& {prefix}/venv/bin/pip list --format=json > {prefix}/installed.json" + + (" && rm -rf /tmp/agentenv-protocol" if protocol else "")) + lines.append(f"COPY agentenv-{layer}.conf /etc/supervisor/conf.d/agentenv-{layer}.conf") + checks = [f"test -d {prefix}", f"test -f /etc/supervisor/conf.d/agentenv-{layer}.conf"] + if install.python is not None: + checks.append(f"test -x {prefix}/venv/bin/python") + lines.append(f"RUN {' && '.join(checks)}") + lines += [f"ENV AGENTENV_SETTING_{name.upper()}={json.dumps(str(value))}" for name, value in build_settings.items()] + lines.append(f"USER {base.session.user}") + return "\n".join(lines) + "\n" + + +def _installed(local: str, layer: str) -> dict[str, str]: + """The packages ``local``'s build installed into the driver's venv, by name, read from the image without running it, + so on any arch; none for a driver with no venv.""" + created = subprocess.run(["docker", "create", local], capture_output=True, text=True, check=True).stdout.strip() + try: + copied = subprocess.run(["docker", "cp", f"{created}:{DRIVERS}/{layer}/installed.json", "-"], + capture_output=True) + finally: + subprocess.run(["docker", "rm", created], capture_output=True) + if copied.returncode != 0: + return {} + with tarfile.open(fileobj=io.BytesIO(copied.stdout)) as archive: + listed = json.loads(archive.extractfile(archive.getmembers()[0]).read()) + return {package["name"]: package["version"] for package in listed} + + +def _label(local: str, platform: str, extra: dict[str, str]) -> None: + """Add ``extra``'s labels to this machine's image ``local``, which adds no layer.""" + with tempfile.TemporaryDirectory(prefix="agentenv-label-") as work: + dockerfile = Path(work) / "Dockerfile" + dockerfile.write_text(f"FROM {local}\n") + build_image(dockerfile, Path(work), local, platform=platform, labels=extra) + + +def _served(ref: str, store) -> str | None: + """``ref`` pinned to the digest the image store serves for it, or None when it serves none.""" + try: + return pin_digest(ref, store.auth(ref)) + except NoSuchImageError: + return None + + +def _emulated_platforms() -> set[str]: + """The platforms this machine's Docker builds, its own and those it emulates.""" + inspected = subprocess.run(["docker", "buildx", "inspect", "--bootstrap"], capture_output=True, text=True) + platforms = set() + for line in inspected.stdout.splitlines(): + if line.strip().startswith("Platforms:"): + platforms |= {item.strip().rstrip("*") for item in line.split(":", 1)[1].split(",")} + return platforms + + +def _failed(env: ComputerEnv, form: str, arch: str, stage: str, key: str | None, reason: str, + log: str | None = None) -> dict: + result = {"status": "failed", "stage": stage, "reason": reason, "failed_at": _now()} + if key is not None: + result["key"] = key + if log is not None: + stored = FileArtifact.put_bytes(derive_id(env.stored_id, f"build_log_{form}_{arch}"), description=( + f"the {form}/{arch} build of {env.stored_id} v{env.version}, which failed"), filename="build.log", + content=log.encode(), content_type="text/plain") + result["log"] = {"id": stored.id, "version": stored.version} + result["reason"] = f"{reason}: {log.strip().splitlines()[-1] if log.strip() else 'no output'}" + return result + + +def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str]) -> dict | None: + """Write the env's computer over the images ``built``, by arch, joined into one index in the env's image store, signed + in to it: its arches those, its OS and session the base's, its settings the base's with the env's values as their + defaults. None when nothing built.""" + if not built: + return None + arches = [arch for arch in base.arch if arch in built] + store = get_config().get_image_store_for(env.stored_id) + index = store.image_ref(REPOSITORY, "index-" + hashlib.sha256( + ",".join(built[arch] for arch in arches).encode()).hexdigest()) + docker_login(store.auth(index)) + _joined(index, [built[arch] for arch in arches]) + image = DockerImageArtifact.put_ref(derive_id(env.stored_id, "computer_image"), description=( + f"{env.stored_id}'s composed computer"), image_name=index) + config = {name: setting.model_dump() for name, setting in base.config.items()} + for name, value in env.computer_settings.items(): + config[name]["default"] = value + if env.display is not None: + config["resolution"]["default"] = _resolution(env.display) + computer = ComputerArtifact.put( + id=derive_id(env.stored_id, "computer"), description=f"{base.id} composed with {env.driver.id} for {env.stored_id}", + arch=arches, os=base.os.model_dump(), session=base.session.model_dump(), config=config, + forms={"container": {"image": {"id": image.id, "version": image.version}, "init": base.forms.container.init, + "input_channel": base.forms.container.input_channel}}) + return {"id": computer.id, "version": computer.version} + + +def _joined(index: str, images: list[str]) -> None: + """Push one index of ``images`` to ``index``, as the release joins its platforms' images.""" + joined = subprocess.run(["docker", "buildx", "imagetools", "create", "-t", index, *images], + capture_output=True, text=True) + if joined.returncode != 0: + raise RuntimeError(f"joining {', '.join(images)} into {index} failed: {joined.stderr.strip()}") + + +def _digest(folder: Path) -> str: + """A digest of the files under ``folder``, by their paths and bytes.""" + hashed = hashlib.sha256() + for path in sorted(p for p in folder.rglob("*") if p.is_file()): + hashed.update(path.relative_to(folder).as_posix().encode() + b"\0" + hashlib.sha256(path.read_bytes()).digest()) + return "sha256:" + hashed.hexdigest() + + +def _composer_digest() -> str: + return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class _Record: + """The env's ``composed`` metadata, by form and arch. Each result is merged into the stored table pair by pair, the + table read again when another build wrote it first, so builds of one env at once keep each other's pairs, and a pair + built stays built whatever another build of it records.""" + + def __init__(self, env: ComputerEnv): + self.env, self.lock = env, threading.Lock() + + def started(self, pairs: list[tuple[str, str]]) -> None: + now = _now() + self._merge({pair: {"status": "building", "started_at": now} for pair in pairs}) + + def finished(self, form: str, arch: str, result: dict) -> None: + self._merge({(form, arch): result}) + + def built(self) -> dict[str, str]: + """Each arch's image of the container form, of the pairs built or reused.""" + return {arch: result["image"] for arch, result in self.env.metadata.get("composed", {}).get("container", {}).items() + if result.get("status") in _BUILT} + + def computer(self, ref: dict | None) -> None: + """Record the env's computer, with the images it was written over.""" + if ref is not None: + with self.lock: + self.env.merge_metadata({"computer": {**ref, "images": self.built()}}) + + def _merge(self, results: dict[tuple[str, str], dict]) -> None: + with self.lock: + for attempt in range(_WRITE_TRIES): + composed = copy.deepcopy(self.env.metadata.get("composed", {})) + for (form, arch), result in results.items(): + stored = composed.setdefault(form, {}).get(arch, {}) + if stored.get("status") not in _BUILT or result["status"] in _BUILT: + composed[form][arch] = {"started_at": stored.get("started_at"), **result} + try: + self.env.update_metadata({**self.env.metadata, "composed": composed}) + return + except ConcurrentModificationError: + if attempt == _WRITE_TRIES - 1: + raise + self.env.metadata = Env.get(self.env.stored_id, self.env.version).metadata diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py new file mode 100644 index 000000000..a38ae5a53 --- /dev/null +++ b/src/agent_env/env/envs/computer/env.py @@ -0,0 +1,95 @@ +"""The computer env: a base computer and a driver, named by the env with what makes it itself, checked against each +other at put (gate 1) and composed into an image for each form the base has and each arch, each recorded in its +metadata as it builds (``composed``), and the arches built written as its computer (``computer``).""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self + +from agent_env.artifact.artifacts.computer import ComputerArtifact, SettingValue +from agent_env.entity.refs import EntityRef +from agent_env.env.env import Env +from agent_env.env.envs.computer import composer, gate +from agent_env.env.envs.computer_driver import ComputerDriverEnv +from agent_env.env.store import get_env_store + +if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext + from agent_env.env.env import DeployedEnv + + +class ComputerEnv(Env): + type: ClassVar[str] = "computer" + description = "A computer: a base computer and a driver, composed at put into an image for each form and arch" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("computer", artifact_type="computer"), + EntityRef.env("driver", env_type=ComputerDriverEnv.type)) + + def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, + driver: ComputerDriverEnv, display: Optional[dict] = None, network: Optional[dict] = None, + computer_settings: Optional[dict[str, SettingValue]] = None, metadata: Optional[dict[str, Any]] = None): + super().__init__(id, version, metadata=metadata) + self.environment_name = environment_name + self.computer, self.driver = computer, driver + self.display, self.network = display, network + self.computer_settings = computer_settings or {} + + def to_dict(self) -> dict: + return {**super().to_dict(), "service_name": self.environment_name, + "environment_name": self.environment_name, # dual-write with service_name during the rename + "computer": {"id": self.computer.id, "version": self.computer.version}, + "driver": {"id": self.driver.id, "version": self.driver.version}, "display": self.display, + "network": self.network, "computer_settings": self.computer_settings} + + @classmethod + def from_dict(cls, data: dict) -> ComputerEnv: + driver = Env.get(data["driver"]["id"], data["driver"]["version"]) + if not isinstance(driver, ComputerDriverEnv): + raise TypeError(f"{data['driver']['id']} is a {driver.type} env, not a computer_driver env") + return cls(id=data["id"], version=data.get("version"), + environment_name=data["environment_name"] if "environment_name" in data else data["service_name"], + computer=ComputerArtifact.get(data["computer"]["id"], data["computer"]["version"]), driver=driver, + display=data.get("display"), network=data.get("network"), + computer_settings=data.get("computer_settings"), metadata=data.get("metadata") or {}) + + @classmethod + def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, environment_name: str, + apps: Any = (), display: Any = None, network: Any = None, computer_settings: Any = None, + compose: bool = True, on_build: Optional[Callable[[], None]] = None) -> Self: + """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is + False, calling ``on_build`` just before it builds an image. Refused, naming every mismatch, before anything is + stored.""" + values, problems = gate.check(computer, driver, apps=list(apps), display=display, network=network, + computer_settings=computer_settings or {}) + if problems: + raise ValueError(f"computer env {id!r}: " + "; ".join(problems)) + env = get_env_store().put_document(cls( + id=id, version=None, environment_name=environment_name, + computer=computer, driver=driver, display=display, network=network, computer_settings=values)) + if compose: + composer.compose(env, on_build=on_build) + return env + + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerEnv: + """Write the computer env an env.toml declares, its base and driver as the bundle wrote them or the ones they + name, its environment_name the folder's when it sets none.""" + fields, refused = ctx.accepted(data, environment_name=str, computer=object, driver=object, apps=list, + display=dict, network=dict, computer_settings=dict) + missing = [ctx.config_problem(f"{key} is required: the {what}, such as {example}") for key, what, example in ( + ("computer", "base computer", '"ubuntu-24.04"'), ("driver", "driver", '"reference"')) if key not in data] + if refused or missing: + ctx.refuse([*refused, *missing]) + base, driver = ctx.artifact(fields.pop("computer"), ComputerArtifact), ctx.env(fields.pop("driver"), + ComputerDriverEnv) + try: + return cls.put(id=ctx.id, computer=base, driver=driver, + environment_name=fields.pop("environment_name", ctx.name), on_build=ctx.on_build, **fields) + except ValueError as e: + ctx.refuse([str(e)]) + + async def deploy(self, **kwargs) -> DeployedEnv: + statuses = {f"{form}/{arch}": result.get("status") for form, by_arch in self.metadata.get("composed", {}).items() + for arch, result in by_arch.items()} + raise NotImplementedError(f"{self.id} is a computer env, which agent-env can't boot yet; its builds are " + f"{statuses or 'none yet'}") diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py new file mode 100644 index 000000000..a1334e837 --- /dev/null +++ b/src/agent_env/env/envs/computer/gate.py @@ -0,0 +1,126 @@ +"""Gate 1, run at a computer env's put before anything is stored or built: its base and driver are matched against each +other and against what the env declares, and every mismatch is named.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentenv_protocol.computer.platform import Platform, supported + +from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact, SettingValue +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.store.image_store.oci_registry_credentials import names_registry + +if TYPE_CHECKING: + from agent_env.env.envs.computer_driver import ComputerDriverEnv + +#: The largest screen side X can address: its coordinates are signed 16-bit. +MAX_SIDE = 32767 + + +def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, display: Any, network: Any, + computer_settings: Any) -> tuple[dict[str, SettingValue], list[str]]: + """The env's values of the base's settings, and every way ``base``, ``driver`` and what the env declares don't + compose.""" + problems = [] + if apps: + problems.append("apps: computer app envs aren't supported yet") + problems += _platform(base, driver) + problems += _image(base) + problems += _display(base, display, computer_settings) + problems += _network(network) + values, invalid = _computer_settings(base, computer_settings) + return values, problems + invalid + + +def forms(base: ComputerArtifact) -> list[str]: + """The forms a composition of ``base`` builds: every one the base has.""" + return [form for form in FORMS if getattr(base.forms, form, None)] + + +def arches(base: ComputerArtifact, driver: ComputerDriverEnv) -> list[str]: + """The architectures a composition of ``base`` and ``driver`` runs on: the base's that a platform of the driver's + matching the base's OS names, in the base's order.""" + matching = {platform.arch for platform in _matching(base, driver)} + return [arch for arch in base.arch if arch in matching] + + +def _computer_settings(base: ComputerArtifact, values: Any) -> tuple[dict[str, SettingValue], list[str]]: + """``values``, of the base's settings by name, and the problems with them: a setting the base doesn't declare, or a + value of another type than its default's.""" + if not isinstance(values, dict): + return {}, [f"computer_settings must be a table of the computer's setting values, not {values!r}"] + checked, problems = {}, [] + for name, value in values.items(): + if (setting := base.config.get(name)) is None: + problems.append(f"computer_settings.{name}: {base.id} declares no setting {name!r}") + elif type(value) is not type(setting.default): + problems.append(f"computer_settings.{name} must be a {type(setting.default).__name__}, like its default " + f"{setting.default!r}, not {value!r}") + else: + checked[name] = value + return checked, problems + + +def _platform(base: ComputerArtifact, driver: ComputerDriverEnv) -> list[str]: + os = base.os + if not _matching(base, driver): + named = ", ".join(_shown(platform) for entry in driver.declaration["supports"] for platform in supported(entry)) + return [f"driver: {driver.id} supports no platform {base.id} is ({os.type} {os.distribution} {os.version} " + f"{os.display}); it supports {named}"] + problems = [] + if not arches(base, driver): + problems.append(f"driver: no architecture both run: {base.id} runs {', '.join(base.arch)}, and {driver.id} " + f"{', '.join(sorted({platform.arch for platform in _matching(base, driver)}))}") + if (install := driver.install.get(os.type)) is None: + problems.append(f"driver: {driver.id} declares no install for {os.type}") + else: + problems += [f"driver: its service {service.name!r} runs as root, and {base.id}'s " + f"{base.forms.container.init} runs every program as {base.session.user}" + for service in install.services if service.scope == "system"] + return problems + + +def _matching(base: ComputerArtifact, driver: ComputerDriverEnv) -> list[Platform]: + os = base.os + return [platform for entry in driver.declaration["supports"] for platform in supported(entry) + if platform.os == os.type and platform.display == os.display + and platform.distribution in (None, os.distribution) and platform.version in (None, os.version)] + + +def _shown(platform: Platform) -> str: + return " ".join(part for part in (platform.os, platform.distribution, platform.version, platform.display, + platform.arch) if part) + + +def _image(base: ComputerArtifact) -> list[str]: + image = base.image() + if names_registry(image.image_name) or image.buildable: + return [] + return [f"computer: {base.id}'s container image {image.image_name!r} is in no registry and has no build context " + "to build it from"] + + +def _display(base: ComputerArtifact, display: Any, computer_settings: Any) -> list[str]: + if display is None: + return [] + if not isinstance(display, dict) or display.keys() != {"width", "height"} or not all( + isinstance(side, int) and not isinstance(side, bool) and 1 <= side <= MAX_SIDE for side in display.values()): + return [f"display is {{ width = , height = }}, each 1 to {MAX_SIDE} pixels, not {display!r}"] + if "resolution" not in base.config: + return [f"display: {base.id} declares no resolution setting for it to set"] + if isinstance(computer_settings, dict) and "resolution" in computer_settings: + return ["display and the base's resolution setting both set the screen's size; set display alone"] + return [] + + +def _network(network: Any) -> list[str]: + if network is None: + return [] + if not isinstance(network, dict) or not network.keys() <= {"mode", "allow_hosts", "allow_cidrs"}: + return [f"network is {{ mode, allow_hosts, allow_cidrs }}, not {network!r}"] + try: + NetworkPolicy(**network) + except (TypeError, ValueError) as e: + return [f"network: {e}"] + return [] diff --git a/src/agent_env/env/envs/computer/install.py b/src/agent_env/env/envs/computer/install.py new file mode 100644 index 000000000..b37bc5daf --- /dev/null +++ b/src/agent_env/env/envs/computer/install.py @@ -0,0 +1,64 @@ +"""What a computer's layer installs on one OS, as a driver's env.toml declares it under ``[install.]``: ``files``, a +folder of its own copied under the layer's prefix; ``python``, a requirements file in that folder installed into a +venv beside them; and ``services``, the programs the base's init runs from the prefix.""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from agent_env.artifact.artifacts.computer import OS_TYPES + + +class Service(BaseModel): + """A program the base's init runs: as the session user, or for ``system``, as root. A ``command`` whose program is a + relative path names one under the layer's prefix.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(pattern=r"^[a-z][a-z0-9-]{0,31}$") + command: str = Field(min_length=1) + scope: Literal["session", "system"] = "session" + + +class Install(BaseModel): + model_config = ConfigDict(extra="forbid") + + files: str = "." + python: str | None = None + services: list[Service] = Field(min_length=1) + + @field_validator("files", "python") + @classmethod + def _relative(cls, value: str | None) -> str | None: + if value is not None and (PurePosixPath(value).is_absolute() or ".." in PurePosixPath(value).parts): + raise ValueError(f"{value!r} must be a path inside the layer's folder") + return value + + @field_validator("services") + @classmethod + def _named_once(cls, value: list[Service]) -> list[Service]: + names = [service.name for service in value] + if len(set(names)) != len(names): + raise ValueError(f"names a service twice: {names}") + return value + + +def installs(data: Any) -> tuple[dict[str, Install], list[str]]: + """The installs ``data``, an env.toml's ``install`` table, declares by OS type, and the problems with it, each + named by its key.""" + if not isinstance(data, dict): + return {}, [f"install must be a table of OS types ({', '.join(OS_TYPES)}), not {data!r}"] + found, problems = {}, [] + for os_type, table in data.items(): + if os_type not in OS_TYPES: + problems.append(f"install.{os_type}: an install is for an OS type agent-env composes: {', '.join(OS_TYPES)}") + continue + try: + found[os_type] = Install.model_validate(table) + except ValidationError as e: + problems.extend(f"install.{'.'.join(map(str, (os_type, *error['loc'])))}: {error['msg']}" + for error in e.errors()) + return found, problems diff --git a/src/agent_env/env/envs/computer_driver.py b/src/agent_env/env/envs/computer_driver.py index 034309728..b8b39f36a 100644 --- a/src/agent_env/env/envs/computer_driver.py +++ b/src/agent_env/env/envs/computer_driver.py @@ -1,16 +1,19 @@ """Computer driver env: the code that serves a computer's tools, a ``ComputerDriverEnvironment`` subclass, with its -files and what it declares for each platform it supports, read from the class at put.""" +files, what it declares for each platform it supports, read from the class at put, and how a computer installs it and +reaches it.""" from __future__ import annotations import json import subprocess import sys +from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any, ClassVar, NoReturn, Optional from agent_env.artifact import Artifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse from agent_env.env.env import Env +from agent_env.env.envs.computer.install import Install, installs from agent_env.store.ids import derive_id if TYPE_CHECKING: @@ -19,6 +22,9 @@ #: How long reading a driver's class at put may take. DECLARE_TIMEOUT_S = 120 +#: The port the SDK serves a driver on when MCP_PORT doesn't name one. +DEFAULT_PORT = 18765 +MAX_PORT = 65535 class ComputerDriverEnv(Env): @@ -26,6 +32,7 @@ class ComputerDriverEnv(Env): description = "A computer driver: the code that serves a computer's tools, and what it declares" def __init__(self, id: str, version: Optional[int], module: str, files: FileArtifactUniverse, declaration: dict, + install: Optional[dict[str, Install]] = None, port: int = DEFAULT_PORT, metadata: Optional[dict[str, Any]] = None): super().__init__(id, version, metadata=metadata) self.module = module @@ -33,41 +40,54 @@ def __init__(self, id: str, version: Optional[int], module: str, files: FileArti #: The card's ``name``, the class's ``supports``, and per platform those name the extensions it ``provides``, #: its ``tools`` and its computer/v1 ``features``. self.declaration = declaration + self.install = install or {} + self.port = port def to_dict(self) -> dict: return {**super().to_dict(), "module": self.module, "declaration": self.declaration, - "files": {"id": self.files.id, "version": self.files.version, "type": self.files.type}} + "files": {"id": self.files.id, "version": self.files.version, "type": self.files.type}, + "install": {os_type: install.model_dump(exclude_none=True) for os_type, install in self.install.items()}, + "port": self.port} @classmethod def from_dict(cls, data: dict) -> ComputerDriverEnv: files = Artifact.get(data["files"]["id"], version=data["files"]["version"]) + install = {os_type: Install.model_validate(table) for os_type, table in (data.get("install") or {}).items()} return cls(id=data["id"], version=data.get("version"), module=data["module"], files=files, - declaration=data["declaration"], metadata=data.get("metadata") or {}) + declaration=data["declaration"], install=install, port=data.get("port", DEFAULT_PORT), + metadata=data.get("metadata") or {}) @classmethod def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerDriverEnv: """The driver's class is read in a process of its own (``agentenv_protocol.computer._declare``), so the driver's own dependencies need not be installed here.""" - module = cls.accept_toml(data, ctx)["module"] - file, _, name = module.partition(":") + fields = cls.accept_toml(data, ctx) + file, _, name = fields["module"].partition(":") declaration = _declare(ctx, file, name) universe = FileArtifactUniverse.put_bundled(derive_id(ctx.id, "files"), files=ctx.files()) - return cls.put(id=ctx.id, module=module, files=universe, declaration=declaration) + return cls.put(id=ctx.id, module=fields["module"], files=universe, declaration=declaration, + install=fields["install"], port=fields.get("port", DEFAULT_PORT)) @classmethod def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, a computer driver env takes: ``module = ":"``, naming a file - of its folder. Raises BundleError listing every problem.""" - fields = ctx.accept(data, module=str) - module = fields.get("module") - if module is None: - ctx.refuse([ctx.config_problem('module is required: the driver\'s file and class, such as ' - '"driver.py:ReferenceDriver"')]) - file, _, name = module.partition(":") + of its folder; ``port``, where it serves; and ``install``, by OS type, whose files and requirements are in its + folder. Raises BundleError listing every problem.""" + fields, refused = ctx.accepted(data, module=str, port=int, install=dict) files = ctx.files() - if not name or file not in files: - ctx.refuse([ctx.config_problem(f'module {module!r} names no ":" in this folder; it has ' - f"{', '.join(sorted(files))}")]) + module, problems = fields.get("module"), [] + if module is None and "module" not in data: + problems.append('module is required: the driver\'s file and class, such as "driver.py:ReferenceDriver"') + elif module is not None and (not module.partition(":")[2] or module.partition(":")[0] not in files): + problems.append(f'module {module!r} names no ":" in this folder; it has ' + f"{', '.join(sorted(files))}") + if not 1 <= fields.get("port", DEFAULT_PORT) <= MAX_PORT: + problems.append(f"port must be 1 to {MAX_PORT}, not {fields['port']}") + fields["install"], invalid = installs(fields.get("install", {})) + problems += invalid + [problem for os_type, install in fields["install"].items() + for problem in _missing(os_type, install, files)] + if refused or problems: + ctx.refuse([*refused, *map(ctx.config_problem, problems)]) return fields async def deploy(self, **kwargs) -> DeployedEnv: @@ -76,6 +96,17 @@ async def deploy(self, **kwargs) -> DeployedEnv: "as an mcp_server env meanwhile") +def _missing(os_type: str, install: Install, files: dict) -> list[str]: + """What ``install`` names that the driver's folder, ``files`` by path, doesn't hold.""" + folder = PurePosixPath(install.files) + problems = [] + if folder != PurePosixPath(".") and not any(PurePosixPath(path).is_relative_to(folder) for path in files): + problems.append(f"install.{os_type}.files: this folder has no {install.files!r} folder") + if install.python is not None and (folder / install.python).as_posix() not in files: + problems.append(f"install.{os_type}.python: {(folder / install.python).as_posix()!r} isn't in this folder") + return problems + + def _declare(ctx: AuthoringContext, file: str, name: str) -> dict: try: done = subprocess.run([sys.executable, "-m", "agentenv_protocol.computer._declare", str(ctx.dir), file, name], diff --git a/src/agent_env/env/registry.py b/src/agent_env/env/registry.py index da666648a..50b85bfa1 100644 --- a/src/agent_env/env/registry.py +++ b/src/agent_env/env/registry.py @@ -21,6 +21,7 @@ def _get_type(cls: type["Env"]) -> str: def _builtin_registry() -> dict[str, type["Env"]]: """The built-in env types, by type.""" + from agent_env.env.envs.computer.env import ComputerEnv from agent_env.env.envs.computer_driver import ComputerDriverEnv from agent_env.env.envs.gateway_server import GatewayEnv from agent_env.env.envs.mcp_server import MCPServerEnv @@ -29,6 +30,7 @@ def _builtin_registry() -> dict[str, type["Env"]]: from agent_env.env.envs.website import WebsiteEnv return { + _get_type(ComputerEnv): ComputerEnv, _get_type(ComputerDriverEnv): ComputerDriverEnv, _get_type(GatewayEnv): GatewayEnv, _get_type(MCPServerEnv): MCPServerEnv, diff --git a/src/agent_env/providers/sandbox_providers/host_images.py b/src/agent_env/providers/sandbox_providers/host_images.py index 76b8cf22f..e1953d384 100644 --- a/src/agent_env/providers/sandbox_providers/host_images.py +++ b/src/agent_env/providers/sandbox_providers/host_images.py @@ -84,13 +84,32 @@ def build(image: DockerImageArtifact, before: Callable[[], None] | None = None) before() with tempfile.TemporaryDirectory(prefix="agent-env-build-") as work: context = Path(work) - data = get_config().get_object_store_at(image.build_context_object_url).get(image.build_context_object_url) - with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: - archive.extractall(context, filter="data") + _extract_context(image, context) logger.info(f"Building {image.image_name} from its build context on this machine...") _build(context, image.dockerfile_path, image.image_name, sources(image)) +def platform_tag(image: DockerImageArtifact, platform: str) -> str: + """The tag ``build_for`` holds ``image`` built for ``platform`` under: its name with the platform's arch after it.""" + return f"{image.image_name}-{normalize_platform(platform).rsplit('/', 1)[-1]}" + + +def build_for(image: DockerImageArtifact, platform: str) -> str: + """Have this machine's Docker hold ``image`` built for ``platform`` under ``platform_tag``, from its stored build + context unless one built from the same sources is held, natively or through Docker's emulation; the tag. Raises + DockerBuildError.""" + tag, built_from = platform_tag(image, platform), f"{sources(image)}@{normalize_platform(platform)}" + with holding_locks([f"image-build:{built_from}"]): + if not holds(tag, built_from): + with tempfile.TemporaryDirectory(prefix="agent-env-build-") as work: + context = Path(work) + _extract_context(image, context) + logger.info(f"Building {tag} for {platform} from its build context on this machine...") + build_image(context / image.dockerfile_path, context, tag, platform=platform, + labels={_SOURCES_LABEL: built_from}) + return tag + + def build_staged(context: Path, dockerfile: str, image_name: str, image_sources: str, before: Callable[[], None] | None = None) -> None: """``build`` from the build context at ``context``, before it's stored, as ``image_name`` from ``image_sources``, @@ -103,6 +122,12 @@ def build_staged(context: Path, dockerfile: str, image_name: str, image_sources: _build(context, dockerfile, image_name, image_sources) +def _extract_context(image: DockerImageArtifact, context: Path) -> None: + data = get_config().get_object_store_at(image.build_context_object_url).get(image.build_context_object_url) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + archive.extractall(context, filter="data") + + def _build(context: Path, dockerfile: str, image_name: str, image_sources: str) -> None: build_image(context / dockerfile, context, image_name, platform=None, labels={_SOURCES_LABEL: image_sources}) diff --git a/src/agent_env/store/image_store/image_contents.py b/src/agent_env/store/image_store/image_contents.py index c15e6e594..12f325a9f 100644 --- a/src/agent_env/store/image_store/image_contents.py +++ b/src/agent_env/store/image_store/image_contents.py @@ -40,6 +40,23 @@ def platform_manifests(ref: str, auth: RegistryAuth | None) -> tuple[str, dict[s return pinned, {config["architecture"]: pinned.rpartition("@")[2]} if config.get("os") == "linux" else {} +def labels(ref: str, arch: str, auth: RegistryAuth | None) -> dict[str, str]: + """The labels of ``ref``'s image for ``arch``, read from its config, an index's or a single image's. Raises + ValueError when it has none for ``arch``, or as ``read_manifest`` does.""" + pinned, manifests = platform_manifests(ref, auth) + if arch not in manifests: + raise ValueError(f"{ref} has no linux/{arch} image") + manifest = read_manifest(f"{pinned.partition('@')[0]}@{manifests[arch]}", auth) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config" + try: + read_blob(ref, manifest["config"]["digest"], auth, path) + config = json.loads(path.read_text()) + except OSError as e: + raise ValueError(f"its config {manifest['config']['digest']} couldn't be read: {e}") from e + return (config.get("config") or {}).get("Labels") or {} + + 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 diff --git a/tst/integration/computer/computer_env_test.py b/tst/integration/computer/computer_env_test.py new file mode 100644 index 000000000..473a80020 --- /dev/null +++ b/tst/integration/computer/computer_env_test.py @@ -0,0 +1,124 @@ +"""A computer env composed on this machine: a bundle's env naming the shipped ubuntu-24.04 and reference by alias builds +an image for each arch this machine's Docker can build, pushed to the local registry and written as the env's computer; +the image of this machine's arch boots with everything running as the session user and passes the conformance probe +at full three times in a row; and another env that changes only what doesn't change the image reuses it. Needs Docker +and the local registry on localhost:5000.""" + +import asyncio +import json +import platform +import subprocess +import time +import uuid + +import pytest + +from agentenv_protocol.computer import COMPUTER_READY +from agentenv_protocol.computer.conformance import run_probe +from agentenv_protocol.computer.conformance.client import Driver + +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.store import reset_artifact_store +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 plan_bundle +from agent_env.bundle.resolve import BuiltImage, resolve_bundle +from agent_env.config import configure, reset_config +from agent_env.env import Env +from agent_env.env.envs.computer.env import ComputerEnv +from agent_env.store.routing import namespace_routing +from tst.integration.computer.conftest import docker + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine().lower()] +PORT = 18765 +DESKTOP = 'type = "computer"\ncomputer = "ubuntu-24.04"\ndriver = "reference"\n' + + +@pytest.fixture(scope="module") +def composed(tmp_path_factory): + """The bundle's computer env, composed once for the module's tests, in stores of the module's own.""" + root = tmp_path_factory.mktemp("composed") + patch = pytest.MonkeyPatch() + patch.chdir(root) + patch.setenv("XDG_STATE_HOME", str(root / "state")) + patch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + patch.setenv("AGENT_ENV_OBJECT_STORE", "local") + configure() + reset_artifact_store() + bundle = root / "desk" + for path, text in {"envs/desktop/env.toml": DESKTOP, "artifacts/notes/notes.txt": "a note\n", "tasks/t.json": json.dumps( + [{"id": "load", "type": "load_artifact", "env_id": "desktop", "artifact_id": "notes"}])}.items(): + (bundle / path).parent.mkdir(parents=True, exist_ok=True) + (bundle / path).write_text(text) + with namespace_routing(): + plan = plan_bundle(resolve_bundle(parse_bundle(bundle)), installed=installed_bundles) + done = materialize(plan, build_here=frozenset(w.id for w in plan.writes if isinstance(w.source, BuiltImage))) + env = next(Env.get(item.write.id) for item in done.writes if item.write.id.endswith("/desktop")) + yield env + reset_artifact_store() + reset_config() + patch.undo() + + +def test_each_arch_this_machine_builds_is_composed_pushed_and_written_as_the_envs_computer(composed): + results = composed.metadata["composed"]["container"] + with namespace_routing(): + computer = ComputerArtifact.get(composed.metadata["computer"]["id"], composed.metadata["computer"]["version"]) + + assert results[ARCH]["status"] == "unverified" and results[ARCH]["emulated"] is False + assert {"mss", "python-xlib", "av", "agentenv-framework-protocol"} <= set(results[ARCH]["installed"]) + for arch, result in results.items(): + assert result["status"] == "unverified" or "its Docker has no emulation for it" in result["reason"], result + built = sorted(arch for arch, result in results.items() if result["status"] == "unverified") + assert sorted(computer.arch) == built + assert {arch: result["status"] for arch, result in computer.metadata["validated_images"]["container"].items()} == { + arch: "passed" for arch in built} + + +@pytest.mark.asyncio +async def test_the_image_of_this_machines_arch_boots_as_the_session_user_and_passes_the_probe_at_full(composed): + image = composed.metadata["composed"]["container"][ARCH]["image"] + name = f"agentenv-test-computer-{uuid.uuid4().hex[:8]}" + docker("run", "-d", "--name", name, "-p", f"127.0.0.1::{PORT}", + "-e", 'AGENTENV_COMPUTER_SETTINGS={"data_paths": ["fixture"]}', image) + try: + url = await _ready(name) + reports = [await run_probe(url, "full") for _ in range(3)] + users = set(subprocess.run(["docker", "exec", name, "ps", "-eo", "user="], capture_output=True, text=True, + check=True).stdout.split()) + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + for report in reports: + assert report.passed, [f"{c.check} {c.name}: {c.detail}" for c in report.checks if c.status == "failed"] + assert users == {"user"} + + +def test_an_env_that_changes_only_its_display_and_name_reuses_the_image(composed): + with namespace_routing(): + started = time.monotonic() + other = ComputerEnv.put(id=f"{composed.id}-other", computer=composed.computer, driver=composed.driver, + environment_name="other", display={"width": 1024, "height": 768}) + took = time.monotonic() - started + results = Env.get(other.id).metadata["composed"]["container"] + first = composed.metadata["composed"]["container"] + + assert results[ARCH]["reused"] is True and results[ARCH]["image"] == first[ARCH]["image"] + assert results[ARCH]["installed"] == first[ARCH]["installed"] and results[ARCH]["emulated"] is False + assert took < 60, took + + +async def _ready(name: str) -> str: + for _ in range(240): + port = subprocess.run(["docker", "port", name, str(PORT)], capture_output=True, text=True).stdout + if port: + url = f"http://{port.splitlines()[0]}" + try: + if (await (await Driver.connect(url, 10)).invoke(COMPUTER_READY, "status"))["ready"]: + return url + except Exception: # not serving yet + pass + await asyncio.sleep(0.25) + raise TimeoutError(f"{name}'s computer never became ready") diff --git a/tst/integration/slow_durations.json b/tst/integration/slow_durations.json index ef5f65932..1dc850079 100644 --- a/tst/integration/slow_durations.json +++ b/tst/integration/slow_durations.json @@ -35,6 +35,7 @@ "tst/integration/cli/run_bundle_skills_test.py": 23.2, "tst/integration/cli/run_role_models_test.py": 36.8, "tst/integration/computer/base_computer_test.py": 75.1, + "tst/integration/computer/computer_env_test.py": 90.0, "tst/integration/computer/recording_chaos_test.py": 167.6, "tst/integration/computer/scaffold_test.py": 109.6, "tst/integration/env/env_test.py": 11.3, diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index 0dfc0c07d..cb3301c38 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -122,7 +122,7 @@ def wait(self, timeout=None): def test_put_keeps_the_whole_context_and_records_how_to_build_it(local_stores, context, monkeypatch): monkeypatch.setattr(LocalRegistryImageStore, "ensure_repository", lambda self, repository: None) - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: None) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: None) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: None) monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) @@ -137,7 +137,7 @@ def test_put_keeps_the_whole_context_and_records_how_to_build_it(local_stores, c def test_put_refuses_a_context_it_cant_take_before_pushing_anything(local_stores, context, monkeypatch): pushed = [] - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: pushed.append(ref)) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: pushed.append(ref)) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: None) os.mkfifo(context / "pipe") @@ -158,7 +158,7 @@ def test_the_platform_of_an_image_is_named_as_docker_build_takes_it(monkeypatch, def test_put_records_the_platform_it_built_for_when_none_is_given(local_stores, context, monkeypatch): monkeypatch.setattr(LocalRegistryImageStore, "ensure_repository", lambda self, repository: None) - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: None) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: None) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: "linux/arm64") monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) diff --git a/tst/unit/bundle/computer_driver_env_test.py b/tst/unit/bundle/computer_driver_env_test.py index 19a8f5071..ef200e90a 100644 --- a/tst/unit/bundle/computer_driver_env_test.py +++ b/tst/unit/bundle/computer_driver_env_test.py @@ -1,6 +1,7 @@ """Computer driver envs written from a bundle's env folders: the driver's files stored with each version, its class read -where its own dependencies aren't installed, what it declares for each platform it supports, a new version when a file -changes, refusals before any write, and a deploy that waits for a computer.""" +where its own dependencies aren't installed, what it declares for each platform it supports, how a computer installs it +and reaches it, a new version when a file changes, refusals before any write, and a deploy that waits for a +computer.""" import json @@ -56,6 +57,8 @@ def press_keys(self, keys): pass ''' LINUX = [{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}] TOML = 'type = "computer_driver"\nmodule = "driver.py:Desk"\n' +SERVE = 'services = [{ name = "driver", command = "venv/bin/python -m agentenv_protocol.computer.serve driver.py:Desk" }]' +INSTALLED = f'{TOML}port = 19000\n\n[install.linux]\nfiles = "linux"\npython = "requirements.txt"\n{SERVE}\n' @pytest.fixture @@ -99,6 +102,19 @@ def test_a_driver_folder_is_written_with_its_files_and_what_its_class_declares(b assert ComputerDriverEnv.from_dict(env.to_dict()).to_dict() == env.to_dict() +def test_a_driver_is_written_with_how_a_computer_installs_it_and_the_port_it_serves_on(bundle_dir): + _bundle(bundle_dir, toml=INSTALLED, **{"envs/desk/linux/requirements.txt": "mss>=9,<11\n"}) + + _run(bundle_dir) + env = Env.get(f"{ROOT}/desk") + + install = env.install["linux"] + assert (env.port, install.files, install.python) == (19000, "linux", "requirements.txt") + assert [(service.name, service.scope) for service in install.services] == [("driver", "session")] + assert sorted(env.files.file_artifact_ids) == ["driver.py", "linux/requirements.txt"] + assert ComputerDriverEnv.from_dict(env.to_dict()).to_dict() == env.to_dict() + + def test_a_driver_is_written_again_only_when_one_of_its_files_changes(bundle_dir): _bundle(bundle_dir) _run(bundle_dir) @@ -120,7 +136,22 @@ def test_a_driver_is_written_again_only_when_one_of_its_files_changes(bundle_dir (TOML, LINUX, ("thumb",), "buttons include left"), (TOML, [], ("left",), "Desk declares no platform: name them with @computer_driver(supports=[...])"), ('type = "computer_driver"\nmodule = "driver.py:Desk"\nimage = "x"\n', LINUX, ("left",), "unknown key 'image'"), -]) + (f"{TOML}port = 0\n", LINUX, ("left",), "port must be 1 to 65535, not 0"), + (f'{TOML}[install.windows]\n{SERVE}\n', LINUX, ("left",), + "install.windows: an install is for an OS type agent-env composes: linux"), + (f'{TOML}[install.linux]\npython = "requirements.txt"\n{SERVE}\n', LINUX, ("left",), + "install.linux.python: 'requirements.txt' isn't in this folder"), + (f'{TOML}[install.linux]\nfiles = "linux"\n{SERVE}\n', LINUX, ("left",), + "install.linux.files: this folder has no 'linux' folder"), + (f'{TOML}[install.linux]\nfiles = "../elsewhere"\n{SERVE}\n', LINUX, ("left",), + "install.linux.files: Value error, '../elsewhere' must be a path inside the layer's folder"), + (f'{TOML}[install.linux]\nservices = []\n', LINUX, ("left",), "install.linux.services: List should have at least 1"), + (f'{TOML}[install.linux]\nservices = [{{ name = "d", command = "x" }}, {{ name = "d", command = "y" }}]\n', LINUX, + ("left",), "install.linux.services: Value error, names a service twice: ['d', 'd']"), + (f'{TOML}[install.linux]\nservices = [{{ name = "d", command = "x", scope = "root" }}]\n', LINUX, ("left",), + "install.linux.services.0.scope: Input should be 'session' or 'system'"), +], ids=["no-module", "module-file", "module-class", "button", "no-platform", "unknown-key", "port", "install-os", + "install-python", "install-files", "install-escape", "no-service", "service-twice", "service-scope"]) def test_a_driver_its_class_cant_be_read_from_is_refused_before_any_write(bundle_dir, toml, supports, buttons, problem): _bundle(bundle_dir, toml=toml, supports=supports, buttons=buttons) @@ -168,3 +199,5 @@ def test_a_run_naming_the_reference_driver_writes_the_shipped_one(tmp_path, monk assert env.declaration["name"] == "reference" and len(env.declaration["platforms"]) == 2 assert {"urn:agentenv:computer-pointer/v1", "urn:agentenv:computer-keyboard/v1", "urn:agentenv:computer-exec/v1"} <= set(env.declaration["platforms"][0]["provides"]) + assert (env.port, env.install["linux"].python) == (18765, "requirements.txt") + assert env.install["linux"].services[0].command.endswith("agentenv_protocol.computer.serve driver.py:ReferenceDriver") diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py new file mode 100644 index 000000000..4f3c38cc3 --- /dev/null +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -0,0 +1,114 @@ +"""Computer env folders: an ``envs//env.toml`` of type computer names its base and driver as another bundle's +aliases, ``ubuntu-24.04`` and ``reference``, which the run pulls in and writes first; the env is written over their ids, +its computer_settings the base's by name, and the run says it builds with docker. The composer is replaced by a recorder.""" + +import json + +import pytest + +from agent_env.bundle import BundleError, parse_bundle +from agent_env.bundle.installed import installed_bundles +from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle +from agent_env.bundle.materialize import materialize +from agent_env.bundle.plan import plan_bundle, plan_entities +from agent_env.bundle.resolve import resolve_bundle +from agent_env.env import Env +from agent_env.env.envs.computer import composer +from agent_env.env.envs.computer.env import ComputerEnv +from tst.unit.bundle._support import layout, local_builds + +UBUNTU = "@local/agentenv-framework/computers/ubuntu-24.04" +REFERENCE = "@local/agentenv-framework/drivers/reference" +DESKTOP = '''type = "computer" +computer = "ubuntu-24.04" +driver = "reference" +display = { width = 1600, height = 900 } + +[computer_settings] +timezone = "Europe/Paris" +''' + + +@pytest.fixture +def composed(tmp_path, monkeypatch, local_stores, cli_routing, builds): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("agent_env.env.published_images._registry_digest", lambda ref: None) + calls = [] + + def compose(env, on_build=None): + on_build() + calls.append((env.id, env.computer.id, env.driver.id)) + + monkeypatch.setattr(composer, "compose", compose) + return calls + + +def _bundle(root, toml=DESKTOP): + return layout(root, {"envs/desktop/env.toml": toml, "tasks/t.json": json.dumps([ + {"id": "desktop", "type": "load_artifact", "env_id": "desktop", "artifact_id": "notes"}]), + "artifacts/notes/notes.txt": "a note\n"}) + + +def _installed(tmp_path, package, name): + """An installed bundle of ``package`` whose aliases name an agent ``helper``.""" + root = tmp_path / package / name + root.mkdir(parents=True) + (root / ALIASES_FILE).write_text('[agents]\nagent = ["helper"]\n') + return InstalledBundle(name, package, "1.0", package.replace("-", "_"), root, None, None) + + +def _plan(root): + return plan_bundle(resolve_bundle(parse_bundle(root)), installed=installed_bundles) + + +def _plan_env(root): + """The env alone, as ``write_installed`` writes an installed bundle's env another bundle names.""" + resolved = resolve_bundle(parse_bundle(root)) + return plan_entities(resolved, [f"{resolved.bundle.id_root}/desktop"], installed=installed_bundles) + + +@pytest.mark.parametrize("planned", [_plan, _plan_env], ids=["a-run", "the-env-alone"]) +def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed(tmp_path, composed, planned): + plan = planned(_bundle(tmp_path / "desk")) + building = [] + + done = materialize(plan, build_here=local_builds(plan), on_build=lambda write: building.append(write.id)) + + env = Env.get(f"{plan.bundle.bundle.id_root}/desktop") + written = {item.write.id for item in done.writes} + assert isinstance(env, ComputerEnv) and {UBUNTU, REFERENCE} <= written + assert (env.computer.id, env.driver.id, env.environment_name) == (UBUNTU, REFERENCE, "desktop") + assert env.computer_settings == {"timezone": "Europe/Paris"} and env.display == {"width": 1600, "height": 900} + assert composed == [(env.id, UBUNTU, REFERENCE)] and env.id in building + + +def test_the_env_alone_is_planned_whatever_the_bundles_tasks_would_deploy(tmp_path, composed, monkeypatch): + """A default agent two installed bundles both alias names neither, which blocks a run of a task that deploys it, + but not writing an env that no task of the bundle runs for.""" + claims = [_installed(tmp_path, package, name) for package, name in (("acme-kit", "kit"), ("zeta-tools", "tools"))] + monkeypatch.setattr("agent_env.config.runtime.Config.get_default_a2a_agent_id", lambda self: "helper") + root = _bundle(tmp_path / "desk") + (root / "tasks" / "agent.json").write_text(json.dumps([{"id": "agent", "type": "deploy_agent", "env_ids": []}])) + resolved = resolve_bundle(parse_bundle(root)) + + plan = plan_entities(resolved, [f"{resolved.bundle.id_root}/desktop"], + installed=lambda: (*installed_bundles(), *claims)) + + assert f"{resolved.bundle.id_root}/desktop" in {write.id for write in plan.writes} + + +@pytest.mark.parametrize("toml, problem", [ + ('type = "computer"\ndriver = "reference"\n', 'computer is required: the base computer, such as "ubuntu-24.04"'), + ('type = "computer"\ncomputer = "ubuntu-24.04"\n', 'driver is required: the driver, such as "reference"'), + (DESKTOP.replace("display =", "ports = [80]\ndisplay ="), "unknown key 'ports'"), + (DESKTOP.replace("1600", "0"), "display is { width = , height = }, each 1 to 32767 pixels"), +], ids=["no-computer", "no-driver", "unknown-key", "display"]) +def test_a_computer_env_folder_that_declares_what_cant_compose_is_refused_naming_the_folder(tmp_path, composed, toml, + problem): + plan = _plan(_bundle(tmp_path / "desk", toml)) + + with pytest.raises(BundleError) as refused: + materialize(plan, build_here=local_builds(plan)) + + assert any(line.startswith("envs/desktop") and problem in line for line in refused.value.problems), refused.value + assert composed == [] diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index 8fc13d690..2e20e810f 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -442,15 +442,15 @@ def test_the_stores_env_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): -def test_an_env_definition_naming_an_env_alias_is_refused_saying_to_name_the_env_by_its_id(bundle_dir, tmp_path): - """An env alias names an env in a task's steps; a MultiEnv's servers name theirs by id.""" +def test_an_env_definition_naming_an_env_alias_is_pointed_at_the_env_it_names_pulled_in(bundle_dir, tmp_path): kit = _kit(tmp_path, ENV_ALIASED) - problems = _problems(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "suite"}], - {"envs/suite/env.toml": 'type = "multi"\nmcp_server_envs = ["crm-x"]\n'}), kit) + plan = _plan(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "suite"}], + {"envs/suite/env.toml": 'type = "multi"\nmcp_server_envs = ["crm-x"]\n'}), kit) - 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 + suite = next(write for write in plan.writes if write.id.endswith("/suite")) + assert (BundleKind.ENV, CRM) in _writes(plan) and suite.source.config["mcp_server_envs"] == [CRM] + assert plan.alias_uses[EntityKind.ENV]["crm-x"].target_id == CRM and plan.store_refs == () 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'} @@ -470,15 +470,29 @@ def test_an_artifact_named_by_an_installed_alias_is_pulled_in_and_the_task_names 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, +def test_an_env_definition_naming_an_artifact_alias_is_pointed_at_the_artifact_it_names_pulled_in(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) + plan = _plan(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}], + {"envs/crm/env.toml": 'environment_name = "crm"\nimage = { artifact = "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 + crm = next(write for write in plan.writes if write.kind is BundleKind.ENV) + assert (BundleKind.ARTIFACT, f"{KIT}/img") in _writes(plan) and crm.source.config["image"] == {"artifact": f"{KIT}/img"} + assert plan.alias_uses[EntityKind.ARTIFACT]["img-x"].target_id == f"{KIT}/img" + + +def test_an_installed_bundles_env_naming_another_bundles_alias_is_pointed_at_it_when_pulled_in(bundle_dir, tmp_path): + images = _installed(tmp_path / "site" / "images", {"artifacts/img/Dockerfile": "FROM scratch\n", + ALIASES_FILE: '[artifacts]\nimg = ["img-x"]\n'}, + dist="demo-images", name="images") + kit = _kit(tmp_path, {"envs/crm/env.toml": 'environment_name = "crm"\nimage = "img-x"\n'}) + + plan = _plan(_task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": f"{KIT}/crm"}]), kit, images) + + crm = next(write for write in plan.writes if write.kind is BundleKind.ENV) + assert (crm.id, crm.source.config["image"]) == (f"{KIT}/crm", "@local/demo-images/images/img") + assert (BundleKind.ARTIFACT, "@local/demo-images/images/img") in _writes(plan) def test_the_stores_agent_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): diff --git a/tst/unit/bundle/register_test.py b/tst/unit/bundle/register_test.py index 702f7b806..dcf233716 100644 --- a/tst/unit/bundle/register_test.py +++ b/tst/unit/bundle/register_test.py @@ -405,7 +405,7 @@ def pin(ref, auth): monkeypatch.setattr(docker_image_module, "pin_digest", pin) monkeypatch.setattr(docker_image_module, "build_image", lambda dockerfile, context, tag, platform: built.append((tag, platform))) - monkeypatch.setattr(docker_image_module, "_push_local_image", + monkeypatch.setattr(docker_image_module, "push_local_image", lambda local, ref, image_store: (pushed.append(ref), served.add(ref))) context = layout(home / "context", {"Dockerfile": "FROM python:3.12-slim\n"}) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py new file mode 100644 index 000000000..f3716c3e9 --- /dev/null +++ b/tst/unit/env/envs/test_computer_env.py @@ -0,0 +1,525 @@ +"""Computer envs: gate 1 refuses a base, a driver or a declaration that can't compose, naming every problem, before +anything is stored; the composer builds each form and arch on this machine's Docker, reuses an image of the same key +whichever env built it, records each result as it finishes, and writes the arches built as the env's computer. Docker +and the registry are faked.""" + +import io +import json +import logging +import subprocess +import tarfile +from types import SimpleNamespace + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.artifacts import docker_image +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.artifacts.file import FileArtifact +from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse +from agent_env.artifact.store import get_artifact_store +from agent_env.cli import cli +from agent_env.env import Env +from agent_env.env import env as env_module +from agent_env.env.envs.computer import composer +from agent_env.env.envs.computer.env import ComputerEnv +from agent_env.env.envs.computer.install import Install, Service +from agent_env.env.envs.computer_driver import ComputerDriverEnv +from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle, installed_aliases +from agent_env.store.base import NotFoundError +from agent_env.store.image_store.image_store import OciRegistryImageStore +from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore +from agent_env.store.image_store.oci_registry_credentials import RegistryAuth +from agent_env.utils.docker.build import DockerBuildError +from tst.util.oci_registry import UBUNTU_LAYER, Registry + +OS = {"type": "linux", "distribution": "ubuntu", "version": "24.04", "display": "x11"} +CONFIG = {"resolution": {"default": "1280x800", "phase": "deploy"}, "timezone": {"default": "UTC", "phase": "deploy"}, + "theme": {"default": "light", "phase": "build"}} +SERVE = "venv/bin/python -m agentenv_protocol.computer.serve driver.py:Desk" +TOOLS = ["computer_screenshot", "computer_click", "computer_type", "computer_pointer_list"] + + +class FakeDocker: + """This machine's Docker for the composer, on arm64 emulating amd64: each build recorded with its Dockerfile, its + context's files and its programs, each push landing in the fake registry as an image of its arch.""" + + def __init__(self, registry: Registry): + self.registry, self.builds, self.fail, self.emulates, self.labels = registry, [], {}, {"linux/amd64"}, {} + + def build_image(self, dockerfile, context, tag, *, platform, labels=None, build_args=None): + if platform in self.fail: + raise DockerBuildError(self.fail[platform]) + self.builds.append({"platform": platform, "tag": tag, "dockerfile": dockerfile.read_text(), + "files": sorted(p.relative_to(context).as_posix() for p in context.rglob("*") if p.is_file()), + "conf": next(context.glob("agentenv-*.conf")).read_text()}) + + def label(self, local, platform, extra): + self.labels[local] = extra + + def push(self, local, ref, store): + self.registry.tags[ref] = self.registry.image(UBUNTU_LAYER, arch=local.rsplit("-", 1)[1], + labels=self.labels.get(local)) + + def joined(self, index, images): + manifests = {self.registry.manifests[image.rpartition("@")[2]]["config"]["digest"]: image.rpartition("@")[2] + for image in images} + arches = {self._arch(config): digest for config, digest in manifests.items()} + self.registry.tags[index] = self.registry.index(**arches) + + def _arch(self, config_digest): + return json.loads(self.registry.blobs[config_digest])["architecture"] + + +@pytest.fixture +def docker(local_stores, cli_routing, monkeypatch): + registry = Registry().serve(monkeypatch) + fake = FakeDocker(registry) + monkeypatch.setattr(composer, "build_image", fake.build_image) + monkeypatch.setattr(composer, "push_local_image", fake.push) + monkeypatch.setattr(composer, "_label", fake.label) + monkeypatch.setattr(composer, "docker_login", lambda auth: None) + monkeypatch.setattr(composer, "_joined", fake.joined) + monkeypatch.setattr(composer, "pin_digest", registry.pin_digest) + monkeypatch.setattr(docker_image, "pin_digest", registry.pin_digest) + monkeypatch.setattr(composer, "host_platform", lambda: "linux/arm64") + monkeypatch.setattr(composer, "_emulated_platforms", lambda: {"linux/arm64", *fake.emulates}) + monkeypatch.setattr(composer, "_installed", lambda local, layer: {"mss": "10.1.0"}) + monkeypatch.setattr(composer, "_protocol_source", lambda: None) + monkeypatch.setattr(LocalRegistryImageStore, "ensure_repository", lambda self, repository: None) + return fake + + +@pytest.fixture +def base(docker): + index = docker.registry.index(amd64=docker.registry.image(UBUNTU_LAYER), + arm64=docker.registry.image(UBUNTU_LAYER, arch="arm64")) + image = get_artifact_store().put_document(DockerImageArtifact(id="base-image", description="", + image_name=f"ghcr.io/acme/base:1@{index}")) + return _base(image) + + +@pytest.fixture +def driver(docker, tmp_path): + return _driver(tmp_path / "driver") + + +def _base(image, **changes): + return ComputerArtifact.put(inspect=False, **{ + "id": "@local/~/kit/ubuntu-24.04", "arch": ["amd64", "arm64"], "os": OS, "session": {"user": "user"}, + "config": CONFIG, "forms": {"container": {"image": {"id": image.id, "version": image.version}, + "init": "supervisord", "input_channel": "env"}}, **changes}) + + +def _driver(folder, requirements="mss>=9,<11\n", supports=None, services=None, install=True, extra=None): + folder.mkdir(parents=True, exist_ok=True) + (folder / "driver.py").write_text("class Desk: ...\n") + (folder / "requirements.txt").write_text(requirements) + files = FileArtifactUniverse.put_bundled("@local/~/kit/desk__files", files={ + name: folder / name for name in ("driver.py", "requirements.txt")}) + if extra: + files = FileArtifactUniverse.put(files.id, file_artifacts={**files.get_file_artifacts(), **extra}) + supports = supports or [{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64", "arm64"]}] + platforms = [{"platform": {"os": "linux", "arch": arch, "display": "x11"}, "provides": [], "tools": TOOLS, + "features": {}} for arch in ("amd64", "arm64")] + services = services or [Service(name="driver", command=SERVE)] + return ComputerDriverEnv.put( + id="@local/~/kit/desk", module="driver.py:Desk", files=files, + declaration={"name": "desk", "supports": supports, "platforms": platforms}, + install={"linux": Install(python="requirements.txt", services=services)} if install else {}, port=18765) + + +def _put(base, driver, compose=True, **changes): + return ComputerEnv.put(**{"id": "@local/~/kit/desktop", "computer": base, "driver": driver, + "environment_name": "desktop", "compose": compose, **changes}) + + +def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_its_images_make(base, driver, docker, + local_stores): + local_stores.set_image_store(OciRegistryImageStore("registry.example")) + env = _put(base, driver, computer_settings={"timezone": "Europe/Paris", "theme": "dark"}, + display={"width": 1600, "height": 900}) + + stored = Env.get(env.id) + composed = stored.metadata["composed"]["container"] + assert {arch: (r["status"], r["reused"], r["emulated"]) for arch, r in composed.items()} == { + "amd64": ("unverified", False, True), "arm64": ("unverified", False, False)} + assert composed["arm64"]["installed"] == {"mss": "10.1.0"} and composed["arm64"]["key"] != composed["amd64"]["key"] + assert composed["arm64"]["image"].startswith("localhost:5000/agentenv-computers:") + computer = ComputerArtifact.get(stored.metadata["computer"]["id"], stored.metadata["computer"]["version"]) + assert computer.id == "@local/~/kit/desktop__computer" and computer.arch == ["amd64", "arm64"] + assert computer.image().image_name.startswith("localhost:5000/agentenv-computers:index-") + assert {arch: r["status"] for arch, r in computer.metadata["validated_images"]["container"].items()} == { + "amd64": "passed", "arm64": "passed"} + assert (computer.config["resolution"].default, computer.config["timezone"].default) == ("1600x900", "Europe/Paris") + assert stored.computer_settings == {"timezone": "Europe/Paris", "theme": "dark"} + arm64 = next(build for build in docker.builds if build["platform"] == "linux/arm64") + assert arm64["dockerfile"].splitlines() == [ + f"FROM {base.image().image_name}", "USER root", "COPY driver/ /opt/agentenv/drivers/desk/", + "RUN python3 -m venv /opt/agentenv/drivers/desk/venv && /opt/agentenv/drivers/desk/venv/bin/pip install " + "--no-cache-dir -r /opt/agentenv/drivers/desk/requirements.txt && /opt/agentenv/drivers/desk/venv/bin/pip list " + "--format=json > /opt/agentenv/drivers/desk/installed.json", + "COPY agentenv-desk.conf /etc/supervisor/conf.d/agentenv-desk.conf", + "RUN test -d /opt/agentenv/drivers/desk && test -f /etc/supervisor/conf.d/agentenv-desk.conf && test -x " + "/opt/agentenv/drivers/desk/venv/bin/python", + 'ENV AGENTENV_SETTING_THEME="dark"', "USER user", f"LABEL dev.agentenv.computer-key={composed['arm64']['key']}"] + assert arm64["files"] == ["Dockerfile", "agentenv-desk.conf", "driver/driver.py", "driver/requirements.txt"] + assert arm64["conf"] == ( + "[program:desk-driver]\ncommand=/opt/agentenv/drivers/desk/venv/bin/python -m agentenv_protocol.computer.serve " + 'driver.py:Desk\ndirectory=/opt/agentenv/drivers/desk\nenvironment=MCP_PORT="18765"\nautorestart=true\n' + "redirect_stderr=true\nstdout_logfile=/var/log/agentenv/desk-driver.log\n") + assert ComputerEnv.from_dict(stored.to_dict()).to_dict() == stored.to_dict() + + +def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one_another_env_built(base, driver, + docker, monkeypatch): + auth, docker_calls = RegistryAuth("localhost:5000", "user", "token"), [] + monkeypatch.setattr(LocalRegistryImageStore, "auth", lambda self, ref: auth) + monkeypatch.setattr(composer, "docker_login", lambda signed_in: docker_calls.append(("login", signed_in))) + monkeypatch.setattr(composer, "_joined", lambda index, images: (docker_calls.append(("join", index)), + docker.joined(index, images))) + announced = [] + first = _put(base, driver, on_build=lambda: announced.append("first")) + docker_calls.clear() + second = _put(base, driver, id="@local/~/kit/other", environment_name="other", display={"width": 640, "height": 480}, + network={"mode": "allowlist"}, on_build=lambda: announced.append("second")) + + built, reused = (Env.get(env.id).metadata["composed"]["container"] for env in (first, second)) + assert len(docker.builds) == 2 and all(reused[arch]["reused"] for arch in reused) and announced == ["first"] + assert {arch: reused[arch]["image"] for arch in reused} == {arch: built[arch]["image"] for arch in built} + assert {arch: (r["installed"], r["emulated"]) for arch, r in reused.items()} == { + "amd64": ({"mss": "10.1.0"}, True), "arm64": ({"mss": "10.1.0"}, False)} + index = ComputerArtifact.get(f"{second.id}__computer").image().image_name.partition("@")[0] + assert docker_calls == [("login", auth), ("join", index)] + + +@pytest.mark.parametrize("change", ["requirements", "build-setting", "service", "display-read-at-build"]) +def test_what_changes_the_image_changes_its_key_and_builds_it_again(base, driver, docker, tmp_path, change): + if change == "display-read-at-build": + base = _base(base.image(), config={**CONFIG, "resolution": {"default": "1280x800", "phase": "build"}}) + first = Env.get(_put(base, driver).id).metadata["composed"]["container"]["arm64"]["key"] + if change == "requirements": + driver = _driver(tmp_path / "changed", requirements="mss>=10,<11\n") + elif change == "service": + driver = _driver(tmp_path / "changed", services=[Service(name="driver", command=f"{SERVE} --verbose")]) + settings = {"theme": "dark"} if change == "build-setting" else {} + display = {"width": 1600, "height": 900} if change == "display-read-at-build" else None + + again = Env.get(_put(base, driver, computer_settings=settings, display=display).id).metadata["composed"][ + "container"]["arm64"] + + assert again["key"] != first and again["reused"] is False and len(docker.builds) == 4 + if display: + assert 'ENV AGENTENV_SETTING_RESOLUTION="1600x900"' in docker.builds[-1]["dockerfile"] + + +def test_a_pair_that_fails_is_recorded_with_its_log_and_the_rest_are_built_and_written(base, driver, docker): + docker.fail["linux/amd64"] = "#7 ERROR: process \"/bin/sh -c pip install\" did not complete successfully" + + env = Env.get(_put(base, driver).id) + + amd64 = env.metadata["composed"]["container"]["amd64"] + assert (amd64["status"], amd64["stage"]) == ("failed", "build") + assert amd64["reason"] == 'the build failed: #7 ERROR: process "/bin/sh -c pip install" did not complete successfully' + log = FileArtifact.get(amd64["log"]["id"], amd64["log"]["version"]) + assert b"did not complete successfully" in log.load() + assert env.metadata["composed"]["container"]["arm64"]["status"] == "unverified" + assert ComputerArtifact.get(env.metadata["computer"]["id"]).arch == ["arm64"] + + +def test_a_driver_file_named_outside_its_folder_fails_the_build_before_anything_is_written_or_built(base, docker, + tmp_path): + escape = FileArtifact.put_bytes("@local/~/kit/escape", description="", filename="agentenv-escape", content=b"x", + content_type="text/plain") + driver = _driver(tmp_path / "driver", extra={"../../agentenv-escape": escape}) + + env = Env.get(_put(base, driver).id) + + assert {(r["stage"], r["reason"]) for r in env.metadata["composed"]["container"].values()} == { + ("build", "ValueError: driver @local/~/kit/desk: '../../agentenv-escape' would be written outside its folder")} + assert docker.builds == [] + + +def test_a_machine_that_cant_emulate_the_other_arch_fails_that_arch_alone(base, driver, docker): + docker.emulates = set() + + env = Env.get(_put(base, driver).id) + + amd64 = env.metadata["composed"]["container"]["amd64"] + assert amd64["reason"] == "this machine can't build linux/amd64: its Docker has no emulation for it" + assert env.metadata["composed"]["container"]["arm64"]["status"] == "unverified" + assert [build["platform"] for build in docker.builds] == ["linux/arm64"] + + +def test_a_build_that_cant_run_the_other_archs_programs_says_its_emulation_is_missing(base, driver, docker): + docker.fail["linux/amd64"] = "#6 0.01 exec /bin/sh: exec format error\n#6 ERROR: process did not complete successfully" + + amd64 = Env.get(_put(base, driver).id).metadata["composed"]["container"]["amd64"] + + assert amd64["reason"].startswith("this machine can't run linux/amd64 programs: its Docker has no emulation for it:") + assert amd64["log"] + + +def test_a_base_built_from_its_context_is_built_here_for_each_arch_and_keyed_by_its_sources(driver, docker, + monkeypatch): + image = get_artifact_store().put_document(DockerImageArtifact( + id="base-image", description="", image_name="local/base-1234:abcd", build_context_object_url="file:///c.tar.gz", + dockerfile_path="Dockerfile", source_digest="sha256:" + "c" * 64)) + built_for = [] + monkeypatch.setattr(composer, "build_for", lambda image, platform: built_for.append(platform) or + f"{image.image_name}-{platform.rsplit('/', 1)[1]}") + + env = Env.get(_put(_base(image), driver).id) + again = Env.get(_put(_base(image), driver, id="@local/~/kit/again", environment_name="again").id) + + assert sorted(built_for) == ["linux/amd64", "linux/arm64"] + assert {r["reused"] for r in again.metadata["composed"]["container"].values()} == {True} + assert {build["dockerfile"].splitlines()[0] for build in docker.builds} == { + "FROM local/base-1234:abcd-amd64", "FROM local/base-1234:abcd-arm64"} + assert {r["status"] for r in env.metadata["composed"]["container"].values()} == {"unverified"} + + +def test_a_checkout_composes_its_own_protocol_into_the_drivers_venv(base, driver, docker, tmp_path, monkeypatch): + source = tmp_path / "protocol" + (source / "src" / "agentenv_protocol").mkdir(parents=True) + (source / "src" / "agentenv_protocol" / "__init__.py").write_text("") + (source / "pyproject.toml").write_text("[project]\nname = 'agentenv-framework-protocol'\n") + monkeypatch.setattr(composer, "_protocol_source", lambda: source) + + _put(base, driver) + + dockerfile = docker.builds[0]["dockerfile"] + assert "COPY protocol /tmp/agentenv-protocol" in dockerfile + assert "pip install --no-cache-dir /tmp/agentenv-protocol -r /opt/agentenv/drivers/desk/requirements.txt" in dockerfile + assert "protocol/pyproject.toml" in docker.builds[0]["files"] + + +def test_the_pairs_left_to_build_are_those_failed_or_interrupted(base, driver, docker): + docker.fail["linux/amd64"] = "boom" + env = Env.get(_put(base, driver).id) + composed = env.metadata["composed"] + composed["container"]["arm64"] = {"status": "building"} + env.metadata = {**env.metadata, "composed": composed} + + assert composer.pending(env) == [("container", "amd64"), ("container", "arm64")] + docker.fail.clear() + composer.compose(env, composer.pending(env)) + + assert {r["status"] for r in Env.get(env.id).metadata["composed"]["container"].values()} == {"unverified"} + assert composer.pending(Env.get(env.id)) == [] + + +def test_env_build_builds_what_a_put_left_and_prints_each_pair(base, driver, docker, caplog): + caplog.set_level(logging.ERROR, logger=composer.__name__) # live logging would put pytest's stdout back mid-command + docker.fail["linux/amd64"] = "boom" + env = _put(base, driver) + runner = CliRunner() + + failed = runner.invoke(cli, ["env", "build", "--id", env.id]) + docker.fail.clear() + built = runner.invoke(cli, ["env", "build", "--id", env.id]) + again = runner.invoke(cli, ["env", "build", "--id", env.id]) + + assert failed.exit_code == 1 and "container/amd64: failed (the build failed: boom)" in failed.output + assert built.exit_code == 0 and "container/amd64: unverified (localhost:5000/agentenv-computers:" in built.output + assert "container/arm64: unverified (localhost:5000" in built.output and len(docker.builds) == 2 + assert again.exit_code == 0 and len(docker.builds) == 2 + assert ComputerArtifact.get(Env.get(env.id).metadata["computer"]["id"]).arch == ["amd64", "arm64"] + + +def test_env_build_refuses_an_env_that_isnt_a_computer_env(base, driver, docker): + refused = CliRunner().invoke(cli, ["env", "build", "--id", driver.id]) + + assert refused.exit_code == 1 and "is a computer_driver env; only a computer env is built" in refused.output + + +@pytest.mark.asyncio +async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, driver, docker): + env = _put(base, driver) + + with pytest.raises(NotImplementedError, match=r"agent-env can't boot yet; its builds are \{'container/amd64': " + r"'unverified', 'container/arm64': 'unverified'\}"): + await Env.get(env.id).deploy() + + +@pytest.mark.parametrize("changes, problem", [ + ({"apps": [{"env": "mail"}]}, "apps: computer app envs aren't supported yet"), + ({"display": {"width": 0, "height": 800}}, "display is { width = , height = }, each 1 to 32767 pixels"), + ({"display": {"width": 1280}}, "display is { width = , height = }"), + ({"display": {"width": 1280, "height": 800}, "computer_settings": {"resolution": "800x600"}}, + "display and the base's resolution setting both set the screen's size; set display alone"), + ({"network": {"mode": "allow_all", "allow_hosts": ["x.org"]}}, + "network: allow_all permits everything; entries would be silently ignored"), + ({"network": {"mode": "allowlist", "ports": [80]}}, "network is { mode, allow_hosts, allow_cidrs }"), + ({"computer_settings": {"wallpaper": "x"}}, + "computer_settings.wallpaper: @local/~/kit/ubuntu-24.04 declares no setting 'wallpaper'"), + ({"computer_settings": {"timezone": 3}}, "computer_settings.timezone must be a str, like its default 'UTC', not 3"), + ({"computer_settings": ["timezone"]}, "computer_settings must be a table of the computer's setting values"), +], ids=["apps", "display-zero", "display-side", "display-and-resolution", "network", "network-key", + "settings-name", "settings-type", "settings-table"]) +def test_what_the_env_declares_wrong_is_refused_before_anything_is_stored(base, driver, docker, changes, problem): + with pytest.raises(ValueError, match="computer env '@local/~/kit/desktop'") as refused: + _put(base, driver, **changes) + + assert problem in str(refused.value) + with pytest.raises(NotFoundError): + Env.get("@local/~/kit/desktop") + assert docker.builds == [] + + +@pytest.mark.parametrize("make, problem", [ + (lambda tmp: _driver(tmp, supports=[{"os": {"type": "linux", "display": ["wayland"]}, "arch": ["amd64"]}]), + "driver: @local/~/kit/desk supports no platform @local/~/kit/ubuntu-24.04 is (linux ubuntu 24.04 x11); it " + "supports linux wayland amd64"), + (lambda tmp: _driver(tmp, supports=[{"os": {"type": "linux", "distribution": "debian", "display": ["x11"]}, + "arch": ["amd64"]}]), "supports no platform"), + (lambda tmp: _driver(tmp, install=False), "driver: @local/~/kit/desk declares no install for linux"), + (lambda tmp: _driver(tmp, services=[Service(name="driver", command=SERVE, scope="system")]), + "driver: its service 'driver' runs as root, and @local/~/kit/ubuntu-24.04's supervisord runs every program as user"), +], ids=["display", "distribution", "no-install", "system-service"]) +def test_a_driver_that_cant_compose_with_the_base_is_refused(base, docker, tmp_path, make, problem): + with pytest.raises(ValueError) as refused: + _put(base, make(tmp_path / "driver")) + + assert problem in str(refused.value) + + +def test_a_base_and_driver_with_no_arch_in_common_and_an_image_that_cant_be_got_are_refused(docker, tmp_path): + image = get_artifact_store().put_document(DockerImageArtifact(id="base-image", description="", + image_name="local/base:1")) + driver = _driver(tmp_path / "driver", supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["arm64"]}]) + + with pytest.raises(ValueError) as refused: + _put(_base(image, arch=["amd64"]), driver) + + assert ("driver: no architecture both run: @local/~/kit/ubuntu-24.04 runs amd64, and @local/~/kit/desk arm64" + in str(refused.value)) + assert ("computer: @local/~/kit/ubuntu-24.04's container image 'local/base:1' is in no registry and has no build " + "context to build it from" in str(refused.value)) + + +def test_the_platforms_this_machine_builds_are_read_from_its_builder(monkeypatch): + inspected = ("Name: desktop-linux\nDriver: docker\nNodes:\nName: desktop-linux\n" + "Platforms: linux/arm64, linux/amd64, linux/amd64/v2, linux/riscv64*\nLabels:\n") + monkeypatch.setattr(subprocess, "run", lambda *a, **k: SimpleNamespace(stdout=inspected, returncode=0)) + + assert composer._emulated_platforms() == {"linux/arm64", "linux/amd64", "linux/amd64/v2", "linux/riscv64"} + + +@pytest.mark.parametrize("direct_url, expected", [ + ('{{"url": "file://{folder}", "dir_info": {{"editable": true}}}}', "folder"), + (None, None), + ('{{"url": "https://files.pythonhosted.org/agentenv_framework_protocol-0.1.381.whl"}}', None), +], ids=["source", "index", "url"]) +def test_the_protocol_is_composed_from_its_source_only_when_installed_from_one(tmp_path, monkeypatch, direct_url, + expected): + (tmp_path / "pyproject.toml").write_text("") + text = direct_url.format(folder=tmp_path) if direct_url else None + monkeypatch.setattr(composer.importlib.metadata, "distribution", + lambda name: SimpleNamespace(read_text=lambda file: text)) + + assert composer._protocol_source() == (tmp_path if expected else None) + + +def test_the_packages_a_build_installed_are_read_from_its_image_without_running_it(monkeypatch): + listed = json.dumps([{"name": "mss", "version": "10.2.0"}, + {"name": "agentenv-framework-protocol", "version": "0.1.382"}]).encode() + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as tar: + info = tarfile.TarInfo("installed.json") + info.size = len(listed) + tar.addfile(info, io.BytesIO(listed)) + calls = [] + + def run(argv, **kwargs): + calls.append(argv[:2]) + if argv[1] == "create": + return SimpleNamespace(stdout="c0ffee\n", returncode=0) + return SimpleNamespace(stdout=archive.getvalue(), returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + + assert composer._installed("agentenv-computer:abc-arm64", "reference") == { + "mss": "10.2.0", "agentenv-framework-protocol": "0.1.382"} + assert calls == [["docker", "create"], ["docker", "cp"], ["docker", "rm"]] + + +def test_a_script_or_binary_in_the_drivers_files_stays_executable_in_the_image(base, docker, tmp_path, monkeypatch): + folder = tmp_path / "scripted" + folder.mkdir() + (folder / "driver.py").write_text("class Desk: ...\n") + (folder / "start").write_text("#!/bin/sh\nexec venv/bin/python -m agentenv_protocol.computer.serve driver.py:Desk\n") + (folder / "helper").write_bytes(b"\x7fELF\x02\x01\x01") + modes = {} + monkeypatch.setattr(composer, "build_image", lambda dockerfile, context, tag, **kw: modes.update( + {path.name: path.stat().st_mode & 0o111 for path in (context / "driver").iterdir()})) + files = FileArtifactUniverse.put_bundled("@local/~/kit/scripted__files", files={ + name: folder / name for name in ("driver.py", "start", "helper")}) + driver = ComputerDriverEnv.put( + id="@local/~/kit/scripted", module="driver.py:Desk", files=files, declaration=_driver(tmp_path / "d").declaration, + install={"linux": Install(services=[Service(name="driver", command="./start")])}, port=18765) + + _put(base, driver) + + assert modes == {"driver.py": 0, "start": 0o111, "helper": 0o111} + + +def test_a_registry_base_is_signed_in_to_before_its_image_is_built_on_and_the_store_before_the_index(base, driver, + docker, monkeypatch): + signed = [] + monkeypatch.setattr(composer, "docker_login", signed.append) + + _put(base, driver) + + assert len(signed) == 3 and len(docker.builds) == 2 + + +def test_env_build_writes_the_computer_again_when_it_isnt_over_every_pair_built(base, driver, docker, caplog): + caplog.set_level(logging.ERROR, logger=composer.__name__) + stored = Env.get(_put(base, driver).id) + stale = {**stored.metadata["computer"], "images": {"arm64": stored.metadata["computer"]["images"]["arm64"]}} + stored.update_metadata({**stored.metadata, "computer": stale}) + + assert not composer.current(Env.get(stored.id)) + rebuilt = CliRunner().invoke(cli, ["env", "build", "--id", stored.id]) + + assert rebuilt.exit_code == 0 and composer.current(Env.get(stored.id)) and len(docker.builds) == 2 + assert Env.get(stored.id).metadata["computer"]["version"] == stale["version"] + 1 + + +def test_env_build_by_an_installed_alias_composes_under_the_id_of_the_env_it_names(base, driver, docker, local_stores, + tmp_path, monkeypatch, caplog): + caplog.set_level(logging.ERROR, logger=composer.__name__) + (tmp_path / "kit").mkdir() + (tmp_path / "kit" / ALIASES_FILE).write_text('[envs]\ndesktop = ["office"]\n') + kit = InstalledBundle("kit", "acme-kit", "1.0", "acme_kit", tmp_path / "kit", None, None) + monkeypatch.setattr(env_module, "installed_aliases", lambda table: installed_aliases((kit,), table)) + env = _put(base, driver, id="@local/acme-kit/kit/desktop", compose=False) + local_stores.set_image_store(OciRegistryImageStore("registry.example")) + + built = CliRunner().invoke(cli, ["env", "build", "--id", "office"]) + + stored = Env.get(env.id) + assert built.exit_code == 0 and stored.metadata["computer"]["id"] == "@local/acme-kit/kit/desktop__computer" + images = [r["image"] for r in stored.metadata["composed"]["container"].values()] + assert all(ref.startswith("localhost:5000/") for ref in [*images, ComputerArtifact.get( + "@local/acme-kit/kit/desktop__computer").image().image_name]) + + +def test_builds_of_one_env_at_once_keep_each_others_pairs_and_a_failure_doesnt_replace_a_built_one(base, driver, + docker, + monkeypatch): + env = _put(base, driver, compose=False) + first, second, third = (Env.get(env.id) for _ in range(3)) + + composer.compose(first, [("container", "amd64")]) + composer.compose(second, [("container", "arm64")]) + monkeypatch.setattr(composer, "_served", lambda ref, store: None) + docker.fail["linux/arm64"] = "boom" + composer.compose(third, [("container", "arm64")]) + + stored = Env.get(env.id) + composed = stored.metadata["composed"]["container"] + assert {arch: r["status"] for arch, r in composed.items()} == {"amd64": "unverified", "arm64": "unverified"} + assert stored.metadata["computer"]["images"] == {arch: r["image"] for arch, r in composed.items()} diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json new file mode 100644 index 000000000..2ace631ca --- /dev/null +++ b/tst/unit/serialization/golden/env/computer.json @@ -0,0 +1,46 @@ +{ + "computer": { + "id": "ubuntu-24.04", + "version": 1 + }, + "computer_settings": { + "timezone": "Europe/Paris" + }, + "display": { + "height": 800, + "width": 1280 + }, + "driver": { + "id": "reference-driver", + "version": 4 + }, + "environment_name": "desktop", + "id": "desktop", + "metadata": { + "composed": { + "container": { + "amd64": { + "built_at": "2026-10-28T10:02:11+00:00", + "emulated": false, + "image": "localhost:5000/agentenv-computers:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "installed": { + "mss": "10.1.0" + }, + "key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reused": false, + "status": "unverified" + } + } + }, + "computer": { + "id": "desktop__computer", + "version": 1 + } + }, + "network": { + "mode": "allow_all" + }, + "service_name": "desktop", + "type": "computer", + "version": 2 +} diff --git a/tst/unit/serialization/golden/env/computer_driver.json b/tst/unit/serialization/golden/env/computer_driver.json index d46cf903c..48dae3af6 100644 --- a/tst/unit/serialization/golden/env/computer_driver.json +++ b/tst/unit/serialization/golden/env/computer_driver.json @@ -44,10 +44,24 @@ "version": 4 }, "id": "reference-driver", + "install": { + "linux": { + "files": ".", + "python": "requirements.txt", + "services": [ + { + "command": "venv/bin/python -m agentenv_protocol.computer.serve driver.py:ReferenceDriver", + "name": "driver", + "scope": "session" + } + ] + } + }, "metadata": { "owner": "example-team" }, "module": "driver.py:ReferenceDriver", + "port": 18765, "type": "computer_driver", "version": 4 } diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index f37c89081..f0cbe123c 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -38,6 +38,8 @@ from agent_env.artifact.artifacts.skill import SkillArtifact from agent_env.artifact.ref import ArtifactRef from agent_env.artifact.registry import get_artifact_registry +from agent_env.env.envs.computer.env import ComputerEnv +from agent_env.env.envs.computer.install import Install, Service from agent_env.env.envs.computer_driver import ComputerDriverEnv from agent_env.env.envs.gateway_server import GatewayEnv from agent_env.env.envs.mcp_server import MCPServerEnv @@ -156,6 +158,21 @@ def _website_env() -> WebsiteEnv: } ENV_FIXTURES = { + "computer": lambda: ComputerEnv( + id="desktop", + version=2, + environment_name="desktop", + computer=ARTIFACT_FIXTURES["computer"]().model_copy(update={"version": 1}), + driver=ENV_FIXTURES["computer_driver"](), + display={"width": 1280, "height": 800}, + network={"mode": "allow_all"}, + computer_settings={"timezone": "Europe/Paris"}, + metadata={"composed": {"container": {"amd64": { + "status": "unverified", "key": "sha256:" + "a" * 64, "reused": False, "emulated": False, + "image": "localhost:5000/agentenv-computers:" + "a" * 64 + "@sha256:" + "b" * 64, + "installed": {"mss": "10.1.0"}, "built_at": "2026-10-28T10:02:11+00:00"}}}, + "computer": {"id": "desktop__computer", "version": 1}}, + ), "computer_driver": lambda: ComputerDriverEnv( id="reference-driver", version=4, @@ -166,6 +183,9 @@ def _website_env() -> WebsiteEnv: "provides": ["urn:agentenv:computer/v1", "urn:agentenv:computer-ready/v1"], "tools": ["computer_screenshot", "computer_click"], "features": {"buttons": ["left"], "max_click_count": 1}}]}, + install={"linux": Install(python="requirements.txt", services=[Service( + name="driver", command="venv/bin/python -m agentenv_protocol.computer.serve driver.py:ReferenceDriver")])}, + port=18765, metadata={"owner": "example-team"}, ), "gateway_server": lambda: GatewayEnv( diff --git a/tst/unit/store/local_backends_composition_test.py b/tst/unit/store/local_backends_composition_test.py index db768b09b..377df8ed6 100644 --- a/tst/unit/store/local_backends_composition_test.py +++ b/tst/unit/store/local_backends_composition_test.py @@ -248,7 +248,7 @@ def test_a_docker_image_names_its_repository_and_tarball_from_the_encoded_id( local_registry = [] monkeypatch.setattr(LocalRegistryImageStore, "ensure_repository", lambda self, repository: local_registry.append(repository)) pushed = [] - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: pushed.append(ref)) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: pushed.append(ref)) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: None) monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) @@ -279,7 +279,7 @@ def test_an_local_id_outside_the_cli_is_refused_before_any_image_or_object_is_wr images = FakeImageStore() set_image_store(images) pushed = [] - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: pushed.append(ref)) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: pushed.append(ref)) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: None) monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) @@ -329,7 +329,7 @@ def interrupted(**kwargs): def test_a_docker_image_put_that_stops_before_its_document_doesnt_block_the_next(local_stores, monkeypatch): set_image_store(FakeImageStore()) - monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: None) + monkeypatch.setattr(docker_image, "push_local_image", lambda src, ref, store: None) monkeypatch.setattr(docker_image, "_image_platform", lambda ref: None) monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) put_tar = docker_image.DockerImageArtifact.put_tar diff --git a/tst/util/oci_registry.py b/tst/util/oci_registry.py index c4e5963dc..8dc684d26 100644 --- a/tst/util/oci_registry.py +++ b/tst/util/oci_registry.py @@ -31,8 +31,9 @@ def blob(self, data: bytes) -> str: 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()) + def image(self, *layers: bytes, arch: str = "amd64", labels: dict | None = None) -> str: + config = self.blob(json.dumps({"os": "linux", "architecture": arch, + **({"config": {"Labels": labels}} if labels else {})}).encode()) manifest = {"mediaType": MANIFEST, "config": {"digest": config}, "layers": [{"digest": self.blob(layer)} for layer in layers]} return self._manifest(manifest)