From fc3ccc426c99f25c1a683a28629ef3bde05afb62 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 14:26:44 -0700 Subject: [PATCH 01/23] feat(bundle): resolve installed aliases in every entry's references, not only a task's An env, an agent or an artifact naming another bundle's alias is pointed at what the alias names and pulled in, as a task's step is, with the field rewritten to its id: a computer env names its base and driver as ubuntu-24.04 and reference. Entries pulled in before the aliases are read are pointed too. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 97 +++++++++++++--------- src/agent_env/bundle/resolve.py | 33 ++++---- tst/unit/bundle/installed_entities_test.py | 36 +++++--- 3 files changed, 101 insertions(+), 65 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 84fb3f8e9..c4cd22f85 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -64,7 +64,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 @@ -275,6 +276,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) @@ -302,31 +304,24 @@ 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.""" + """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.""" 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: - 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 +329,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 @@ -729,16 +748,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 +1147,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/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): From 65afedf45d91ba96074ebda54c6a38eeb8898bd1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 14:27:55 -0700 Subject: [PATCH 02/23] feat(protocol): serve a computer driver from its file with agentenv_protocol.computer.serve Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/computer/serve.py | 38 ++++++++++ .../tests/test_computer_serve.py | 75 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/computer/serve.py create mode 100644 packages/agentenv-protocol/tests/test_computer_serve.py 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 == [] From 1431cf0c5b1aa892f6a09ce83ee7adffd1aa70f2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 14:33:42 -0700 Subject: [PATCH 03/23] feat(computer): a driver declares how a computer installs it and the port it serves on A computer_driver env.toml gains port and [install.]: the folder's files copied under the driver's prefix, a requirements file installed into a venv beside them, and the services the base's init runs. The reference driver declares a session service serving its class from a venv of its requirements.txt. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/drivers/README.md | 6 +- src/agent_env/drivers/envs/reference/env.toml | 5 ++ .../drivers/envs/reference/requirements.txt | 5 ++ src/agent_env/env/envs/computer/__init__.py | 1 + src/agent_env/env/envs/computer/install.py | 64 +++++++++++++++++++ src/agent_env/env/envs/computer_driver.py | 62 +++++++++++++----- tst/unit/bundle/computer_driver_env_test.py | 39 ++++++++++- .../golden/env/computer_driver.json | 14 ++++ .../serialization/test_golden_wire_format.py | 4 ++ 9 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 src/agent_env/drivers/envs/reference/requirements.txt create mode 100644 src/agent_env/env/envs/computer/__init__.py create mode 100644 src/agent_env/env/envs/computer/install.py 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/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..999a517ac 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,8 @@ #: 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 class ComputerDriverEnv(Env): @@ -26,6 +31,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 +39,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) <= 65535: + problems.append(f"port must be 1 to 65535, 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 +95,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/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/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..67b59d4b6 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -38,6 +38,7 @@ 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.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 @@ -166,6 +167,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( From 6caa7d9273b409fcb3e2d7f95c5141e55337ce86 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 14:54:20 -0700 Subject: [PATCH 04/23] feat(computer): the computer env type, composed at put into an image per form and arch A computer env names a base computer and a driver, with its display, network, hidden tools, settings and data paths. Gate 1 matches them before anything is stored and refuses every mismatch at once. The composer then builds each form and arch with this machine's Docker, the other arch through its emulation: the base, built here from its context when no registry has it, with the driver's files, venv and supervisord programs on top. Each image is tagged in the image store by a key of its Dockerfile and build context, so any env with the same inputs reuses it. Statuses go into the env's metadata as each pair finishes, the built arches are written as the env's computer over one index, and agent-env env build resumes what a put left. Builds end unverified; M0.7 boots and probes them. Co-Authored-By: Claude Opus 5.5 --- .../artifact/artifacts/docker_image.py | 7 +- src/agent_env/bundle/materialize.py | 2 + src/agent_env/bundle/plan.py | 3 +- src/agent_env/cli/env/__init__.py | 2 + src/agent_env/cli/env/build.py | 35 ++ src/agent_env/env/env.py | 2 + src/agent_env/env/envs/computer/composer.py | 335 +++++++++++++++++ src/agent_env/env/envs/computer/env.py | 113 ++++++ src/agent_env/env/envs/computer/gate.py | 145 ++++++++ src/agent_env/env/registry.py | 2 + .../sandbox_providers/host_images.py | 29 +- .../test_docker_image_build_context.py | 6 +- tst/unit/bundle/computer_env_folder_test.py | 78 ++++ tst/unit/bundle/register_test.py | 2 +- tst/unit/env/envs/test_computer_env.py | 348 ++++++++++++++++++ .../serialization/golden/env/computer.json | 57 +++ .../serialization/test_golden_wire_format.py | 19 + .../store/local_backends_composition_test.py | 6 +- 18 files changed, 1177 insertions(+), 14 deletions(-) create mode 100644 src/agent_env/cli/env/build.py create mode 100644 src/agent_env/env/envs/computer/composer.py create mode 100644 src/agent_env/env/envs/computer/env.py create mode 100644 src/agent_env/env/envs/computer/gate.py create mode 100644 tst/unit/bundle/computer_env_folder_test.py create mode 100644 tst/unit/env/envs/test_computer_env.py create mode 100644 tst/unit/serialization/golden/env/computer.json diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 663281ee8..8de8dbd1b 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,11 +584,12 @@ 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: +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.""" auth = image_store.auth(image_ref) if auth is not None: login = subprocess.run( diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 1382a4b7b..5539e99d4 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 and get_env_registry()[write.source.entry.type].builds_at_put and on_build: + on_build(write) return _WRITERS[write.kind](plan, write) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index c4cd22f85..ff91a0360 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 @@ -97,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,) 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..30ee025b6 --- /dev/null +++ b/src/agent_env/cli/env/build.py @@ -0,0 +1,35 @@ +"""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") + base, driver = env.base(), env.driver_env() + if (pairs := composer.pending(env, base, driver)) or "computer" not in env.metadata: + composer.compose(env, base, driver, 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/env/env.py b/src/agent_env/env/env.py index 56dedcf68..a3047b2c2 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -231,6 +231,8 @@ class Env(ABC): type: ClassVar[str] = "env" toml_refs: ClassVar[tuple[EntityRef, ...]] = () env_provider_types: ClassVar[tuple[str, ...]] = () + #: Whether writing one builds images with this machine's Docker, which a bundle run says before it writes one. + builds_at_put: ClassVar[bool] = False def __init__(self, id: str, version: Optional[int], metadata: Optional[dict[str, Any]] = None): self.id: str = id 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..4d2118d0c --- /dev/null +++ b/src/agent_env/env/envs/computer/composer.py @@ -0,0 +1,335 @@ +"""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 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 typing import TYPE_CHECKING, Any + +from agent_env.artifact.artifacts.computer import ComputerArtifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, push_local_image +from agent_env.artifact.artifacts.file import FileArtifact +from agent_env.config import get_config +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 +from agent_env.store.ids import derive_id +from agent_env.store.image_store.oci_registry_credentials import names_registry +from agent_env.store.image_store.registry_api import 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" +_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, base: ComputerArtifact, driver: ComputerDriverEnv, + pairs: list[tuple[str, str]] | None = None) -> None: + """Build each of ``pairs``, by default every form and 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.""" + pairs = pairs if pairs is not None else [(form, arch) for form in env.forms 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): (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.id}: {form}/{arch} failed at {result['stage']}: {result['reason']}") + else: + logger.info(f"{env.id}: {form}/{arch} {'reused' if result['reused'] else 'built'}: {result['image']}") + record.finished(form, arch, result) + record.computer(_computer(env, base, record.built())) + + +def pending(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv) -> 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 env.forms for arch in gate.arches(base, driver) + if composed.get(form, {}).get(arch, {}).get("status") not in ("unverified", "ready")] + + +def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, form: str, arch: str) -> dict: + """The status of ``form`` and ``arch`` once built and pushed, or reused, or the stage that failed and why.""" + 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() + store.ensure_repository(REPOSITORY) + ref = store.image_ref(REPOSITORY, key.removeprefix("sha256:")) + if (pinned := _served(ref, store)) is not None: + return {"status": "unverified", "key": key, "image": pinned, "reused": True, "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") + (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) + 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: + return _failed(env, form, arch, stage, key, "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 _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: a registry image pinned to + its digest, or one built here from the base's stored build context for that arch.""" + 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 build_for(image, f"linux/{arch}"), f"sources:{image.source_digest}" + + +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(): + path = PurePosixPath(name) + 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(artifact.load()) + (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.""" + values = env.settings.get(base.id, {}) + return {name: values[name] for name, setting in base.config.items() if setting.phase == "build" and name in values} + + +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 freeze > {prefix}/installed.txt" + (" && 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.txt", "-"], + 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: + text = archive.extractfile(archive.getmembers()[0]).read().decode() + return dict(line.split("==", 1) for line in text.splitlines() if "==" in line) + + +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.id, f"build_log_{form}_{arch}"), description=( + f"the {form}/{arch} build of {env.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: 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() + index = store.image_ref(REPOSITORY, "index-" + hashlib.sha256( + ",".join(built[arch] for arch in arches).encode()).hexdigest()) + _joined(index, [built[arch] for arch in arches]) + image = DockerImageArtifact.put_ref(derive_id(env.id, "computer_image"), description=( + f"{env.id}'s composed computer"), image_name=index) + config = {name: setting.model_dump() for name, setting in base.config.items()} + for name, value in env.settings.get(base.id, {}).items(): + config[name]["default"] = value + if env.display is not None: + config["resolution"]["default"] = f"{env.display['width']}x{env.display['height']}" + computer = ComputerArtifact.put( + id=derive_id(env.id, "computer"), description=f"{base.id} composed with {env.driver['id']} for {env.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, written by compare-and-swap as each pair starts and finishes, + one write at a time.""" + + def __init__(self, env: ComputerEnv): + self.env, self.lock = env, threading.Lock() + self.composed = copy.deepcopy(env.metadata.get("composed", {})) + + def started(self, pairs: list[tuple[str, str]]) -> None: + with self.lock: + for form, arch in pairs: + self.composed.setdefault(form, {})[arch] = {"status": "building", "started_at": _now()} + self.env.merge_metadata({"composed": copy.deepcopy(self.composed)}) + + def finished(self, form: str, arch: str, result: dict) -> None: + with self.lock: + self.composed.setdefault(form, {})[arch] = result + self.env.merge_metadata({"composed": copy.deepcopy(self.composed)}) + + 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.composed.get("container", {}).items() + if result.get("status") in ("unverified", "ready")} + + def computer(self, ref: dict | None) -> None: + if ref is not None: + with self.lock: + self.env.merge_metadata({"computer": ref}) 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..9b8ceed1a --- /dev/null +++ b/src/agent_env/env/envs/computer/env.py @@ -0,0 +1,113 @@ +"""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 and arch, each recorded in its metadata as it builds +(``composed``), and the arches built written as its computer (``computer``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self + +from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact +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" + builds_at_put: ClassVar[bool] = True + 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: dict, driver: dict, + forms: list[str], display: Optional[dict] = None, network: Optional[dict] = None, + hidden_tools: Optional[list[str]] = None, settings: Optional[dict] = None, + data_paths: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None): + super().__init__(id, version, metadata=metadata) + self.environment_name = environment_name + self.computer, self.driver = computer, driver # each {"id": ..., "version": ...} + self.forms = forms + self.display, self.network = display, network + self.hidden_tools = hidden_tools or [] + self.settings = settings or {} # setting values by layer id + self.data_paths = data_paths 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": self.computer, + "driver": self.driver, "forms": self.forms, "display": self.display, "network": self.network, + "hidden_tools": self.hidden_tools, "settings": self.settings, "data_paths": self.data_paths} + + @classmethod + def from_dict(cls, data: dict) -> ComputerEnv: + return cls(id=data["id"], version=data.get("version"), + environment_name=data["environment_name"] if "environment_name" in data else data["service_name"], + computer=data["computer"], driver=data["driver"], forms=data["forms"], display=data.get("display"), + network=data.get("network"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), + data_paths=data.get("data_paths"), metadata=data.get("metadata") or {}) + + @classmethod + def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, environment_name: str, + forms: Optional[list[str]] = None, apps: Any = (), display: Any = None, network: Any = None, + tools: Any = None, settings: Any = None, data_paths: Any = (), compose: bool = True) -> Self: + """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is + False. Refused, naming every mismatch, before anything is stored.""" + forms = list(forms) if forms is not None else [form for form in FORMS if getattr(computer.forms, form, None)] + tools = tools if tools is not None else {} + problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ + f"tools is {{ hide = [, ...] }}, not {tools!r}"] + hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] + by_layer, mismatched = gate.check(computer, driver, forms=forms, apps=list(apps), display=display, + network=network, hidden_tools=hidden, settings=settings or {}, + data_paths=list(data_paths)) + if problems := problems + mismatched: + 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={"id": computer.id, "version": computer.version}, driver={"id": driver.id, "version": driver.version}, + forms=forms, display=display, network=network, hidden_tools=hidden, settings=by_layer, + data_paths=list(data_paths))) + if compose: + composer.compose(env, computer, driver) + 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, forms=list, + apps=list, display=dict, network=dict, tools=dict, settings=dict, + data_paths=list) + 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), **fields) + except ValueError as e: + ctx.refuse([str(e)]) + + def base(self) -> ComputerArtifact: + return ComputerArtifact.get(self.computer["id"], self.computer["version"]) + + def driver_env(self) -> ComputerDriverEnv: + driver = Env.get(self.driver["id"], self.driver["version"]) + if not isinstance(driver, ComputerDriverEnv): + raise TypeError(f"{self.driver['id']} is a {driver.type} env, not a computer_driver env") + return driver + + 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..bc2b6ef70 --- /dev/null +++ b/src/agent_env/env/envs/computer/gate.py @@ -0,0 +1,145 @@ +"""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 fnmatch import fnmatchcase +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Any + +from agentenv_protocol.computer.platform import Platform, supported + +from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact +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, *, forms: list[str], apps: list, display: Any, + network: Any, hidden_tools: list[str], settings: Any, data_paths: list) -> tuple[dict, list[str]]: + """The env's settings by layer id, 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 += [f"forms: the {form} form isn't supported yet; a computer env composes {', '.join(FORMS)}" + for form in forms if form not in FORMS] + problems += _platform(base, driver) + problems += _image(base) + problems += _display(base, display, settings) + problems += _network(network) + problems += _tools(driver, hidden_tools) + problems += [f"data_paths: {path!r} isn't a path inside the session user's home" for path in data_paths + if not isinstance(path, str) or not path or PurePosixPath(path).is_absolute() + or ".." in PurePosixPath(path).parts] + by_layer, invalid = layered(base, driver, settings) + return by_layer, problems + invalid + + +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 layered(base: ComputerArtifact, driver: ComputerDriverEnv, settings: Any) -> tuple[dict, list[str]]: + """``settings``, a table of setting values by layer, keyed by the layer's id instead, and the problems with it: a + layer is named by its id or its name, the last part of its id, and only the base declares settings.""" + if not isinstance(settings, dict): + return {}, [f"settings must be a table of values by layer, not {settings!r}"] + names = {key: layer for layer in (base.id, driver.id) for key in (layer, layer.rsplit("/", 1)[-1])} + declared = {base.id: base.config, driver.id: {}} + by_layer, problems = {}, [] + for key, values in settings.items(): + if (layer := names.get(key)) is None: + problems.append(f"settings.{key}: names neither the computer, {base.id}, nor the driver, {driver.id}") + continue + if not isinstance(values, dict): + problems.append(f"settings.{key} must be a table of setting values, not {values!r}") + continue + for name, value in values.items(): + if (setting := declared[layer].get(name)) is None: + problems.append(f"settings.{key}.{name}: {layer} declares no setting {name!r}") + elif type(value) is not type(setting.default): + problems.append(f"settings.{key}.{name} must be a {type(setting.default).__name__}, like its default " + f"{setting.default!r}, not {value!r}") + else: + by_layer.setdefault(layer, {})[name] = value + return by_layer, 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, 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"] + names = {base.id, base.id.rsplit("/", 1)[-1]} + if isinstance(settings, dict) and any(isinstance(values, dict) and "resolution" in values + for key, values in settings.items() if key in names): + 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 [] + + +def _tools(driver: ComputerDriverEnv, hidden: list[str]) -> list[str]: + tools = {tool for platform in driver.declaration["platforms"] for tool in platform["tools"]} + return [f"tools.hide: {pattern!r} matches none of {driver.id}'s tools ({', '.join(sorted(tools))})" + for pattern in hidden if not isinstance(pattern, str) or not any(fnmatchcase(tool, pattern) for tool in tools)] 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..71e97767a 100644 --- a/src/agent_env/providers/sandbox_providers/host_images.py +++ b/src/agent_env/providers/sandbox_providers/host_images.py @@ -84,13 +84,30 @@ 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 build_for(image: DockerImageArtifact, platform: str) -> str: + """The tag under which this machine's Docker holds ``image`` built for ``platform``, built from its stored build + context unless one built from the same sources is held: its own name for the host's platform, else that name with + the platform's arch after it, built through Docker's emulation. Raises DockerBuildError.""" + if normalize_platform(platform) == host_platform(): + build(image) + return image.image_name + tag, built_from = f"{image.image_name}-{platform.rsplit('/', 1)[-1]}", f"{sources(image)}@{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 +120,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/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_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py new file mode 100644 index 000000000..f965278b1 --- /dev/null +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -0,0 +1,78 @@ +"""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 settings keyed by the base's, 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.bundle.materialize import materialize +from agent_env.bundle.plan import plan_bundle +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 } + +[settings."ubuntu-24.04"] +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 = [] + monkeypatch.setattr(composer, "compose", lambda env, base, driver: calls.append((env.id, base.id, driver.id))) + 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 _plan(root): + return plan_bundle(resolve_bundle(parse_bundle(root)), installed=installed_bundles) + + +def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed(tmp_path, composed): + plan = _plan(_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.settings == {UBUNTU: {"timezone": "Europe/Paris"}} and env.display == {"width": 1600, "height": 900} + assert composed == [(env.id, UBUNTU, REFERENCE)] and env.id in building + + +@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/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..c55142258 --- /dev/null +++ b/tst/unit/env/envs/test_computer_env.py @@ -0,0 +1,348 @@ +"""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 json +import logging + +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.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.store.base import NotFoundError +from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore +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"] + + +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 = 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 push(self, local, ref, store): + self.registry.tags[ref] = self.registry.image(UBUNTU_LAYER, arch=local.rsplit("-", 1)[1]) + + 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, "_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): + 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")}) + 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): + env = _put(base, driver, settings={"ubuntu-24.04": {"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 {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.settings == {base.id: {"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 freeze " + "> /opt/agentenv/drivers/desk/installed.txt", + "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): + first = _put(base, driver) + second = _put(base, driver, id="@local/~/kit/other", environment_name="other", display={"width": 640, "height": 480}, + network={"mode": "allowlist"}, tools={"hide": ["computer_type"]}) + + 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) + assert {arch: reused[arch]["image"] for arch in reused} == {arch: built[arch]["image"] for arch in built} + + +@pytest.mark.parametrize("change", ["requirements", "build-setting", "service"]) +def test_what_changes_the_image_changes_its_key_and_builds_it_again(base, driver, docker, tmp_path, change): + 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 = {"ubuntu-24.04": {"theme": "dark"}} if change == "build-setting" else {} + + again = Env.get(_put(base, driver, settings=settings).id).metadata["composed"]["container"]["arm64"] + + assert again["key"] != first and again["reused"] is False and len(docker.builds) == 4 + + +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_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_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) + + assert sorted(built_for) == ["linux/amd64", "linux/arm64"] + 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, base, driver) == [("container", "amd64"), ("container", "arm64")] + docker.fail.clear() + composer.compose(env, base, driver, composer.pending(env, base, driver)) + + assert {r["status"] for r in Env.get(env.id).metadata["composed"]["container"].values()} == {"unverified"} + assert composer.pending(Env.get(env.id), base, driver) == [] + + +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"), + ({"forms": ["disk"]}, "forms: the disk form isn't supported yet; a computer env composes container"), + ({"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}, "settings": {"ubuntu-24.04": {"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 }"), + ({"tools": {"hide": ["computer_drag*"]}}, "tools.hide: 'computer_drag*' matches none of @local/~/kit/desk's tools"), + ({"tools": {"show": ["x"]}}, "tools is { hide = [, ...] }"), + ({"data_paths": ["/etc"]}, "data_paths: '/etc' isn't a path inside the session user's home"), + ({"data_paths": ["../x"]}, "data_paths: '../x' isn't a path inside the session user's home"), + ({"settings": {"mail": {"x": 1}}}, "settings.mail: names neither the computer, @local/~/kit/ubuntu-24.04, nor the " + "driver, @local/~/kit/desk"), + ({"settings": {"ubuntu-24.04": {"wallpaper": "x"}}}, + "settings.ubuntu-24.04.wallpaper: @local/~/kit/ubuntu-24.04 declares no setting 'wallpaper'"), + ({"settings": {"ubuntu-24.04": {"timezone": 3}}}, + "settings.ubuntu-24.04.timezone must be a str, like its default 'UTC', not 3"), + ({"settings": {"desk": {"log_level": "debug"}}}, "settings.desk.log_level: @local/~/kit/desk declares no setting"), +], ids=["apps", "form", "display-zero", "display-side", "display-and-resolution", "network", "network-key", + "tools-hide", "tools-key", "data-absolute", "data-escapes", "settings-layer", "settings-name", "settings-type", + "settings-driver"]) +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)) diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json new file mode 100644 index 000000000..f3f122ad9 --- /dev/null +++ b/tst/unit/serialization/golden/env/computer.json @@ -0,0 +1,57 @@ +{ + "computer": { + "id": "ubuntu-24.04", + "version": 1 + }, + "data_paths": [ + "Documents" + ], + "display": { + "height": 800, + "width": 1280 + }, + "driver": { + "id": "reference-driver", + "version": 4 + }, + "environment_name": "desktop", + "forms": [ + "container" + ], + "hidden_tools": [ + "computer_keyboard_*" + ], + "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", + "settings": { + "ubuntu-24.04": { + "timezone": "Europe/Paris" + } + }, + "type": "computer", + "version": 2 +} diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index 67b59d4b6..1b215d191 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -38,6 +38,7 @@ 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 @@ -157,6 +158,24 @@ def _website_env() -> WebsiteEnv: } ENV_FIXTURES = { + "computer": lambda: ComputerEnv( + id="desktop", + version=2, + environment_name="desktop", + computer={"id": "ubuntu-24.04", "version": 1}, + driver={"id": "reference-driver", "version": 4}, + forms=["container"], + display={"width": 1280, "height": 800}, + network={"mode": "allow_all"}, + hidden_tools=["computer_keyboard_*"], + settings={"ubuntu-24.04": {"timezone": "Europe/Paris"}}, + data_paths=["Documents"], + 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, 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 From e96e7e14ea1063b98e728bf7d9bd152b27b92998 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:05:18 -0700 Subject: [PATCH 05/23] test(computer): compose the shipped base and reference driver and probe the image at full The integration tier composes a bundle's computer env naming ubuntu-24.04 and reference, boots this machine's arch and runs the conformance probe at full three times, and checks an env differing only in display reuses the image. The packages a build installs are read from pip list, so the protocol, when installed from a checkout's source, shows its version. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 10 +- tst/integration/computer/computer_env_test.py | 123 ++++++++++++++++++ tst/unit/env/envs/test_computer_env.py | 4 +- 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 tst/integration/computer/computer_env_test.py diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 4d2118d0c..32e86c8dc 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -196,8 +196,8 @@ def _dockerfile(source: str, layer: str, install: Install, base: ComputerArtifac 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 freeze > {prefix}/installed.txt" + (" && rm -rf /tmp/agentenv-protocol" - if protocol else "")) + 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: @@ -213,15 +213,15 @@ def _installed(local: str, layer: str) -> dict[str, str]: 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.txt", "-"], + 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: - text = archive.extractfile(archive.getmembers()[0]).read().decode() - return dict(line.split("==", 1) for line in text.splitlines() if "==" in line) + listed = json.loads(archive.extractfile(archive.getmembers()[0]).read()) + return {package["name"]: package["version"] for package in listed} def _served(ref: str, store) -> str | None: diff --git a/tst/integration/computer/computer_env_test.py b/tst/integration/computer/computer_env_test.py new file mode 100644 index 000000000..c4344dc3a --- /dev/null +++ b/tst/integration/computer/computer_env_test.py @@ -0,0 +1,123 @@ +"""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.base(), driver=composed.driver_env(), + 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 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/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index c55142258..949d229ac 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -139,8 +139,8 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ 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 freeze " - "> /opt/agentenv/drivers/desk/installed.txt", + "--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", From e5502e7042cad8e0c24fd365e9e19fbee7ec1c73 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:07:23 -0700 Subject: [PATCH 06/23] docs(computer): the computer env type, the driver's install and aliases in every entry Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 13 +++---- src/agent_env/computers/README.md | 4 +++ tst/unit/env/envs/test_computer_env.py | 50 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) 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/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/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 949d229ac..ba34f1ddf 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -3,8 +3,12 @@ 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 @@ -346,3 +350,49 @@ def test_a_base_and_driver_with_no_arch_in_common_and_an_image_that_cant_be_got_ 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"]] From 8d5d215175ad18a4aab8a531a112660592b1eddb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:10:50 -0700 Subject: [PATCH 07/23] fix(computer): keep when a pair started building in its finished record Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 32e86c8dc..2d37b6c8d 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -321,7 +321,8 @@ def started(self, pairs: list[tuple[str, str]]) -> None: def finished(self, form: str, arch: str, result: dict) -> None: with self.lock: - self.composed.setdefault(form, {})[arch] = result + started = self.composed.get(form, {}).get(arch, {}).get("started_at") + self.composed.setdefault(form, {})[arch] = {**result, "started_at": started} self.env.merge_metadata({"composed": copy.deepcopy(self.composed)}) def built(self) -> dict[str, str]: From 2eafdd70ca641ebcf0d8ae0c91910a2d8b8d0697 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:24:34 -0700 Subject: [PATCH 08/23] fix(computer): reuse before building the base, sign in to its registry, and keep what reuse and retries need - A base stored as its build context is built FROM a tag fixed by its arch, so the key is found and an image reused before the base is built. - The image store's credentials sign Docker in to a registry base before a build pulls it. - Scripts and ELF binaries in a driver's files stay executable in the image; the store keeps no file modes. - Each build labels its image with what it installed and whether it was emulated, read back from its arch's config when another env reuses it. - The env's computer records the images it was written over, and env build writes it again when they aren't every pair built. - The port limit is named. Co-Authored-By: Claude Opus 5.5 --- .../artifact/artifacts/docker_image.py | 20 ++++--- src/agent_env/cli/env/build.py | 2 +- src/agent_env/env/envs/computer/composer.py | 56 ++++++++++++++++--- src/agent_env/env/envs/computer_driver.py | 5 +- .../sandbox_providers/host_images.py | 16 +++--- .../store/image_store/image_contents.py | 17 ++++++ tst/integration/computer/computer_env_test.py | 1 + tst/unit/env/envs/test_computer_env.py | 56 ++++++++++++++++++- tst/util/oci_registry.py | 5 +- 9 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 8de8dbd1b..fd64c9d68 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -590,15 +590,7 @@ def push_context_image(id: str, context_path: str, dockerfile_path: str, 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.""" - 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}") - + docker_login(image_store.auth(image_ref)) for cmd, label in [ (["docker", "tag", local_tag, image_ref], "tag"), (["docker", "push", image_ref], "push"), @@ -609,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/cli/env/build.py b/src/agent_env/cli/env/build.py index 30ee025b6..f86b74114 100644 --- a/src/agent_env/cli/env/build.py +++ b/src/agent_env/cli/env/build.py @@ -22,7 +22,7 @@ def build(env_id: str, version: int | None): if not isinstance(env, ComputerEnv): raise click.ClickException(f"{env_id} is a {env.type} env; only a computer env is built") base, driver = env.base(), env.driver_env() - if (pairs := composer.pending(env, base, driver)) or "computer" not in env.metadata: + if (pairs := composer.pending(env, base, driver)) or not composer.current(env): composer.compose(env, base, driver, pairs) failed = False for form, by_arch in env.metadata.get("composed", {}).items(): diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 2d37b6c8d..3ef2476b8 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -24,13 +24,14 @@ from typing import TYPE_CHECKING, Any from agent_env.artifact.artifacts.computer import ComputerArtifact -from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, push_local_image +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.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 +from agent_env.providers.sandbox_providers.host_images import build_for, host_platform, platform_tag 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 @@ -46,6 +47,11 @@ #: 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") _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") @@ -71,6 +77,12 @@ def compose(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, 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, base: ComputerArtifact, driver: ComputerDriverEnv) -> 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", {}) @@ -96,16 +108,22 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, store.ensure_repository(REPOSITORY) ref = store.image_ref(REPOSITORY, key.removeprefix("sha256:")) if (pinned := _served(ref, store)) is not None: - return {"status": "unverified", "key": key, "image": pinned, "reused": True, "built_at": _now()} + 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") + _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, @@ -119,14 +137,25 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, 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: a registry image pinned to - its digest, or one built here from the base's stored build context for that arch.""" + """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 build_for(image, f"linux/{arch}"), f"sources:{image.source_digest}" + 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: @@ -140,7 +169,9 @@ def _stage_driver(context: Path, driver: ComputerDriverEnv, base: ComputerArtifa 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(artifact.load()) + target.write_bytes(data := artifact.load()) + if data.startswith(_RUN_ONLY): + target.chmod(0o755) (context / "driver").mkdir(exist_ok=True) (context / f"agentenv-{layer}.conf").write_text(_programs(layer, install, driver.port)) return layer @@ -224,6 +255,14 @@ def _installed(local: str, layer: str) -> dict[str, str]: 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: @@ -331,6 +370,7 @@ def built(self) -> dict[str, str]: if result.get("status") in ("unverified", "ready")} 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}) + self.env.merge_metadata({"computer": {**ref, "images": self.built()}}) diff --git a/src/agent_env/env/envs/computer_driver.py b/src/agent_env/env/envs/computer_driver.py index 999a517ac..b8b39f36a 100644 --- a/src/agent_env/env/envs/computer_driver.py +++ b/src/agent_env/env/envs/computer_driver.py @@ -24,6 +24,7 @@ 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): @@ -80,8 +81,8 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: 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) <= 65535: - problems.append(f"port must be 1 to 65535, not {fields['port']}") + 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)] diff --git a/src/agent_env/providers/sandbox_providers/host_images.py b/src/agent_env/providers/sandbox_providers/host_images.py index 71e97767a..e1953d384 100644 --- a/src/agent_env/providers/sandbox_providers/host_images.py +++ b/src/agent_env/providers/sandbox_providers/host_images.py @@ -89,14 +89,16 @@ def build(image: DockerImageArtifact, before: Callable[[], None] | None = None) _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: - """The tag under which this machine's Docker holds ``image`` built for ``platform``, built from its stored build - context unless one built from the same sources is held: its own name for the host's platform, else that name with - the platform's arch after it, built through Docker's emulation. Raises DockerBuildError.""" - if normalize_platform(platform) == host_platform(): - build(image) - return image.image_name - tag, built_from = f"{image.image_name}-{platform.rsplit('/', 1)[-1]}", f"{sources(image)}@{platform}" + """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: 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 index c4344dc3a..798476fe1 100644 --- a/tst/integration/computer/computer_env_test.py +++ b/tst/integration/computer/computer_env_test.py @@ -106,6 +106,7 @@ def test_an_env_that_changes_only_its_display_and_name_reuses_the_image(composed 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 diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index ba34f1ddf..952cd4bc0 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -42,7 +42,7 @@ class FakeDocker: 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 = registry, [], {}, {"linux/amd64"} + 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: @@ -51,8 +51,12 @@ def build_image(self, dockerfile, context, tag, *, platform, labels=None, build_ "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]) + 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] @@ -70,6 +74,8 @@ def docker(local_stores, cli_routing, 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) @@ -166,6 +172,8 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one 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) 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)} @pytest.mark.parametrize("change", ["requirements", "build-setting", "service"]) @@ -217,8 +225,10 @@ def test_a_base_built_from_its_context_is_built_here_for_each_arch_and_keyed_by_ 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"} @@ -396,3 +406,45 @@ def run(argv, **kwargs): 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(base, driver, docker, monkeypatch): + signed = [] + monkeypatch.setattr(composer, "docker_login", signed.append) + + _put(base, driver) + + assert len(signed) == 2 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 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) From 68508df1c2c94a53d9632e012cea6cb9c2d6eda5 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:34:00 -0700 Subject: [PATCH 09/23] fix(computer): say a build that can't run the other arch's programs has no emulation for it Docker can still list a platform after its emulation is removed, until it restarts, so the check before a build passes and the build fails running a program. That failure's log names it, and the pair now says so. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 5 ++++- tst/unit/env/envs/test_computer_env.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 3ef2476b8..76ee0f5d5 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -129,7 +129,10 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, 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: - return _failed(env, form, arch, stage, key, "the build failed", log=str(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: diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 952cd4bc0..431690690 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -215,6 +215,15 @@ def test_a_machine_that_cant_emulate_the_other_arch_fails_that_arch_alone(base, 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( From dfb451e29b173868a881ed37e3669a4ced7e031c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:49:34 -0700 Subject: [PATCH 10/23] fix(computer): push an env's composed images to its own image store, and sign in before joining them An @local env's images and index go to the local registry under namespace routing, as a docker_image's do. Docker is signed in to the store before the index is joined, so reusing every image, or an env build that only writes the computer again, can join images in a private registry. The executable mode gets a name. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 16 +++++++++------ tst/unit/env/envs/test_computer_env.py | 22 +++++++++++++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 76ee0f5d5..7642d886e 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -1,5 +1,6 @@ """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 image store by a key of everything that goes into it, so an image of that key, built for any env, is reused. +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.""" @@ -52,6 +53,7 @@ # 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 _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") @@ -104,7 +106,7 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, "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() + store = get_config().get_image_store_for(env.id) store.ensure_repository(REPOSITORY) ref = store.image_ref(REPOSITORY, key.removeprefix("sha256:")) if (pinned := _served(ref, store)) is not None: @@ -174,7 +176,7 @@ def _stage_driver(context: Path, driver: ComputerDriverEnv, base: ComputerArtifa target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data := artifact.load()) if data.startswith(_RUN_ONLY): - target.chmod(0o755) + target.chmod(_EXECUTABLE) (context / "driver").mkdir(exist_ok=True) (context / f"agentenv-{layer}.conf").write_text(_programs(layer, install, driver.port)) return layer @@ -299,14 +301,16 @@ def _failed(env: ComputerEnv, form: str, arch: str, stage: str, key: str | None, 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: 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.""" + """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() + store = get_config().get_image_store_for(env.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.id, "computer_image"), description=( f"{env.id}'s composed computer"), image_name=index) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 431690690..692e56b83 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -26,7 +26,9 @@ from agent_env.env.envs.computer.install import Install, Service from agent_env.env.envs.computer_driver import ComputerDriverEnv 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 @@ -129,7 +131,9 @@ def _put(base, driver, compose=True, **changes): "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): +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, settings={"ubuntu-24.04": {"timezone": "Europe/Paris", "theme": "dark"}}, display={"width": 1600, "height": 900}) @@ -141,6 +145,7 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ 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") @@ -164,8 +169,14 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one_another_env_built(base, driver, - docker): + 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))) first = _put(base, driver) + docker_calls.clear() second = _put(base, driver, id="@local/~/kit/other", environment_name="other", display={"width": 640, "height": 480}, network={"mode": "allowlist"}, tools={"hide": ["computer_type"]}) @@ -174,6 +185,8 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one 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"]) @@ -437,13 +450,14 @@ def test_a_script_or_binary_in_the_drivers_files_stays_executable_in_the_image(b assert modes == {"driver.py": 0, "start": 0o111, "helper": 0o111} -def test_a_registry_base_is_signed_in_to_before_its_image_is_built_on(base, driver, docker, monkeypatch): +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) == 2 and len(docker.builds) == 2 + 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): From 5387358961ff46f03fda9b949204ed981fdeb8a1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 16:11:08 -0700 Subject: [PATCH 11/23] fix(computer): compose under the env's stored id, and merge each pair into the stored record An env got through an installed alias composes into the store and under the ids of the env the alias names. Each pair's result is merged into the stored composed table, read again when another build wrote it first, so two builds of one version keep each other's pairs, and a built pair isn't replaced by another build's building or failed. data_paths is read once. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 62 +++++++++++++-------- src/agent_env/env/envs/computer/env.py | 5 +- tst/unit/env/envs/test_computer_env.py | 42 +++++++++++++- 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 7642d886e..edf96a8c4 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -28,9 +28,11 @@ 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.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 @@ -54,6 +56,9 @@ # 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") @@ -72,9 +77,9 @@ def compose(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, form, arch = futures[future] result = future.result() if result["status"] == "failed": - logger.warning(f"{env.id}: {form}/{arch} failed at {result['stage']}: {result['reason']}") + logger.warning(f"{env.stored_id}: {form}/{arch} failed at {result['stage']}: {result['reason']}") else: - logger.info(f"{env.id}: {form}/{arch} {'reused' if result['reused'] else 'built'}: {result['image']}") + 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())) @@ -89,7 +94,7 @@ def pending(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv) """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 env.forms for arch in gate.arches(base, driver) - if composed.get(form, {}).get(arch, {}).get("status") not in ("unverified", "ready")] + if composed.get(form, {}).get(arch, {}).get("status") not in _BUILT] def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, form: str, arch: str) -> dict: @@ -106,7 +111,7 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, "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.id) + 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: @@ -292,8 +297,8 @@ def _failed(env: ComputerEnv, form: str, arch: str, stage: str, key: str | None, if key is not None: result["key"] = key if log is not None: - stored = FileArtifact.put_bytes(derive_id(env.id, f"build_log_{form}_{arch}"), description=( - f"the {form}/{arch} build of {env.id} v{env.version}, which failed"), filename="build.log", + 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'}" @@ -307,20 +312,20 @@ def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str]) - if not built: return None arches = [arch for arch in base.arch if arch in built] - store = get_config().get_image_store_for(env.id) + 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.id, "computer_image"), description=( - f"{env.id}'s composed computer"), image_name=index) + 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.settings.get(base.id, {}).items(): config[name]["default"] = value if env.display is not None: config["resolution"]["default"] = f"{env.display['width']}x{env.display['height']}" computer = ComputerArtifact.put( - id=derive_id(env.id, "computer"), description=f"{base.id} composed with {env.driver['id']} for {env.id}", + 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}}) @@ -352,32 +357,43 @@ def _now() -> str: class _Record: - """The env's ``composed`` metadata, by form and arch, written by compare-and-swap as each pair starts and finishes, - one write at a time.""" + """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() - self.composed = copy.deepcopy(env.metadata.get("composed", {})) def started(self, pairs: list[tuple[str, str]]) -> None: - with self.lock: - for form, arch in pairs: - self.composed.setdefault(form, {})[arch] = {"status": "building", "started_at": _now()} - self.env.merge_metadata({"composed": copy.deepcopy(self.composed)}) + now = _now() + self._merge({pair: {"status": "building", "started_at": now} for pair in pairs}) def finished(self, form: str, arch: str, result: dict) -> None: - with self.lock: - started = self.composed.get(form, {}).get(arch, {}).get("started_at") - self.composed.setdefault(form, {})[arch] = {**result, "started_at": started} - self.env.merge_metadata({"composed": copy.deepcopy(self.composed)}) + 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.composed.get("container", {}).items() - if result.get("status") in ("unverified", "ready")} + 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 index 9b8ceed1a..ad78bfe02 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -60,20 +60,21 @@ def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" forms = list(forms) if forms is not None else [form for form in FORMS if getattr(computer.forms, form, None)] + data_paths = list(data_paths) tools = tools if tools is not None else {} problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ f"tools is {{ hide = [, ...] }}, not {tools!r}"] hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] by_layer, mismatched = gate.check(computer, driver, forms=forms, apps=list(apps), display=display, network=network, hidden_tools=hidden, settings=settings or {}, - data_paths=list(data_paths)) + data_paths=data_paths) if problems := problems + mismatched: 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={"id": computer.id, "version": computer.version}, driver={"id": driver.id, "version": driver.version}, forms=forms, display=display, network=network, hidden_tools=hidden, settings=by_layer, - data_paths=list(data_paths))) + data_paths=data_paths)) if compose: composer.compose(env, computer, driver) return env diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 692e56b83..954183939 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -21,10 +21,12 @@ 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 @@ -135,7 +137,7 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ local_stores): local_stores.set_image_store(OciRegistryImageStore("registry.example")) env = _put(base, driver, settings={"ubuntu-24.04": {"timezone": "Europe/Paris", "theme": "dark"}}, - display={"width": 1600, "height": 900}) + display={"width": 1600, "height": 900}, data_paths=(path for path in ["Documents", "Music"])) stored = Env.get(env.id) composed = stored.metadata["composed"]["container"] @@ -150,6 +152,7 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ "amd64": "passed", "arm64": "passed"} assert (computer.config["resolution"].default, computer.config["timezone"].default) == ("1600x900", "Europe/Paris") assert stored.settings == {base.id: {"timezone": "Europe/Paris", "theme": "dark"}} + assert stored.data_paths == ["Documents", "Music"] 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/", @@ -471,3 +474,40 @@ def test_env_build_writes_the_computer_again_when_it_isnt_over_every_pair_built( 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, base, driver, [("container", "amd64")]) + composer.compose(second, base, driver, [("container", "arm64")]) + monkeypatch.setattr(composer, "_served", lambda ref, store: None) + docker.fail["linux/arm64"] = "boom" + composer.compose(third, base, driver, [("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()} From 9ff01d3e47c1abf4e71d361cf33e9e620d84b6d1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 19:33:21 -0700 Subject: [PATCH 12/23] test(computer): record the composed computer test's CI time for the slow-tier shards Co-Authored-By: Claude Opus 5.5 --- tst/integration/slow_durations.json | 1 + 1 file changed, 1 insertion(+) 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, From 800193732f07a5ecc9f64f6afca45f1ceae05749 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 21:17:55 -0700 Subject: [PATCH 13/23] fix(computer): refuse a tools.hide pattern that hides one of the seven tools every computer offers Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/gate.py | 11 +++++++++-- tst/unit/env/envs/test_computer_env.py | 8 +++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py index bc2b6ef70..cf1b34350 100644 --- a/src/agent_env/env/envs/computer/gate.py +++ b/src/agent_env/env/envs/computer/gate.py @@ -7,6 +7,7 @@ from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any +from agentenv_protocol.computer.conformance.checks import MINIMUM_TOOLS from agentenv_protocol.computer.platform import Platform, supported from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact @@ -141,5 +142,11 @@ def _network(network: Any) -> list[str]: def _tools(driver: ComputerDriverEnv, hidden: list[str]) -> list[str]: tools = {tool for platform in driver.declaration["platforms"] for tool in platform["tools"]} - return [f"tools.hide: {pattern!r} matches none of {driver.id}'s tools ({', '.join(sorted(tools))})" - for pattern in hidden if not isinstance(pattern, str) or not any(fnmatchcase(tool, pattern) for tool in tools)] + problems = [] + for pattern in hidden: + if not isinstance(pattern, str) or not any(fnmatchcase(tool, pattern) for tool in tools): + problems.append(f"tools.hide: {pattern!r} matches none of {driver.id}'s tools ({', '.join(sorted(tools))})") + elif minimum := [tool for tool in MINIMUM_TOOLS if fnmatchcase(tool, pattern)]: + problems.append(f"tools.hide: {pattern!r} hides {', '.join(minimum)}, of the seven tools every computer " + "offers") + return problems diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 954183939..188c41d53 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -38,7 +38,7 @@ 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"] +TOOLS = ["computer_screenshot", "computer_click", "computer_type", "computer_pointer_list"] class FakeDocker: @@ -181,7 +181,7 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one first = _put(base, driver) docker_calls.clear() second = _put(base, driver, id="@local/~/kit/other", environment_name="other", display={"width": 640, "height": 480}, - network={"mode": "allowlist"}, tools={"hide": ["computer_type"]}) + network={"mode": "allowlist"}, tools={"hide": ["computer_pointer_list"]}) 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) @@ -333,6 +333,8 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr "network: allow_all permits everything; entries would be silently ignored"), ({"network": {"mode": "allowlist", "ports": [80]}}, "network is { mode, allow_hosts, allow_cidrs }"), ({"tools": {"hide": ["computer_drag*"]}}, "tools.hide: 'computer_drag*' matches none of @local/~/kit/desk's tools"), + ({"tools": {"hide": ["computer_c*"]}}, "tools.hide: 'computer_c*' hides computer_click, of the seven tools every " + "computer offers"), ({"tools": {"show": ["x"]}}, "tools is { hide = [, ...] }"), ({"data_paths": ["/etc"]}, "data_paths: '/etc' isn't a path inside the session user's home"), ({"data_paths": ["../x"]}, "data_paths: '../x' isn't a path inside the session user's home"), @@ -344,7 +346,7 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr "settings.ubuntu-24.04.timezone must be a str, like its default 'UTC', not 3"), ({"settings": {"desk": {"log_level": "debug"}}}, "settings.desk.log_level: @local/~/kit/desk declares no setting"), ], ids=["apps", "form", "display-zero", "display-side", "display-and-resolution", "network", "network-key", - "tools-hide", "tools-key", "data-absolute", "data-escapes", "settings-layer", "settings-name", "settings-type", + "tools-hide", "tools-minimum", "tools-key", "data-absolute", "data-escapes", "settings-layer", "settings-name", "settings-type", "settings-driver"]) 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: From dd672f978a534dfd3373b7c68cad5ff7a9d5e732 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 21:42:14 -0700 Subject: [PATCH 14/23] refactor(computer): a computer env holds its computer and driver, not dicts of their ids As an MCP server env holds its image and a multi env its children: from_dict loads them, to_dict writes the same {id, version} as before. The composer takes them from the env. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/cli/env/build.py | 5 ++-- src/agent_env/env/envs/computer/composer.py | 10 +++---- src/agent_env/env/envs/computer/env.py | 30 ++++++++----------- tst/integration/computer/computer_env_test.py | 2 +- tst/unit/bundle/computer_env_folder_test.py | 4 +-- tst/unit/env/envs/test_computer_env.py | 12 ++++---- .../serialization/test_golden_wire_format.py | 4 +-- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/agent_env/cli/env/build.py b/src/agent_env/cli/env/build.py index f86b74114..f2ac2a72b 100644 --- a/src/agent_env/cli/env/build.py +++ b/src/agent_env/cli/env/build.py @@ -21,9 +21,8 @@ def build(env_id: str, version: int | None): 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") - base, driver = env.base(), env.driver_env() - if (pairs := composer.pending(env, base, driver)) or not composer.current(env): - composer.compose(env, base, driver, pairs) + 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(): diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index edf96a8c4..5495e2037 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -64,10 +64,10 @@ _PROTOCOL_FILES = ("pyproject.toml", "README.md", "LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md") -def compose(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, - pairs: list[tuple[str, str]] | None = None) -> None: +def compose(env: ComputerEnv, pairs: list[tuple[str, str]] | None = None) -> None: """Build each of ``pairs``, by default every form and 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.""" + base, driver = env.computer, env.driver pairs = pairs if pairs is not None else [(form, arch) for form in env.forms for arch in gate.arches(base, driver)] record = _Record(env) record.started(pairs) @@ -90,10 +90,10 @@ def current(env: ComputerEnv) -> bool: return "computer" in env.metadata and env.metadata["computer"].get("images") == _Record(env).built() -def pending(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv) -> list[tuple[str, str]]: +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 env.forms for arch in gate.arches(base, driver) + return [(form, arch) for form in env.forms for arch in gate.arches(env.computer, env.driver) if composed.get(form, {}).get(arch, {}).get("status") not in _BUILT] @@ -325,7 +325,7 @@ def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str]) - if env.display is not None: config["resolution"]["default"] = f"{env.display['width']}x{env.display['height']}" computer = ComputerArtifact.put( - id=derive_id(env.stored_id, "computer"), description=f"{base.id} composed with {env.driver['id']} for {env.stored_id}", + 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}}) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index ad78bfe02..f565a5d36 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -25,13 +25,13 @@ class ComputerEnv(Env): 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: dict, driver: dict, - forms: list[str], display: Optional[dict] = None, network: Optional[dict] = None, + def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, + driver: ComputerDriverEnv, forms: list[str], display: Optional[dict] = None, network: Optional[dict] = None, hidden_tools: Optional[list[str]] = None, settings: Optional[dict] = None, data_paths: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None): super().__init__(id, version, metadata=metadata) self.environment_name = environment_name - self.computer, self.driver = computer, driver # each {"id": ..., "version": ...} + self.computer, self.driver = computer, driver self.forms = forms self.display, self.network = display, network self.hidden_tools = hidden_tools or [] @@ -41,15 +41,19 @@ def __init__(self, id: str, version: Optional[int], environment_name: str, compu 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": self.computer, - "driver": self.driver, "forms": self.forms, "display": self.display, "network": self.network, + "computer": {"id": self.computer.id, "version": self.computer.version}, + "driver": {"id": self.driver.id, "version": self.driver.version}, "forms": self.forms, "display": self.display, "network": self.network, "hidden_tools": self.hidden_tools, "settings": self.settings, "data_paths": self.data_paths} @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=data["computer"], driver=data["driver"], forms=data["forms"], display=data.get("display"), + computer=ComputerArtifact.get(data["computer"]["id"], data["computer"]["version"]), driver=driver, + forms=data["forms"], display=data.get("display"), network=data.get("network"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), data_paths=data.get("data_paths"), metadata=data.get("metadata") or {}) @@ -72,11 +76,10 @@ def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, 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={"id": computer.id, "version": computer.version}, driver={"id": driver.id, "version": driver.version}, - forms=forms, display=display, network=network, hidden_tools=hidden, settings=by_layer, + computer=computer, driver=driver, forms=forms, display=display, network=network, hidden_tools=hidden, settings=by_layer, data_paths=data_paths)) if compose: - composer.compose(env, computer, driver) + composer.compose(env) return env @classmethod @@ -98,15 +101,6 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerEnv: except ValueError as e: ctx.refuse([str(e)]) - def base(self) -> ComputerArtifact: - return ComputerArtifact.get(self.computer["id"], self.computer["version"]) - - def driver_env(self) -> ComputerDriverEnv: - driver = Env.get(self.driver["id"], self.driver["version"]) - if not isinstance(driver, ComputerDriverEnv): - raise TypeError(f"{self.driver['id']} is a {driver.type} env, not a computer_driver env") - return driver - 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()} diff --git a/tst/integration/computer/computer_env_test.py b/tst/integration/computer/computer_env_test.py index 798476fe1..473a80020 100644 --- a/tst/integration/computer/computer_env_test.py +++ b/tst/integration/computer/computer_env_test.py @@ -99,7 +99,7 @@ async def test_the_image_of_this_machines_arch_boots_as_the_session_user_and_pas 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.base(), driver=composed.driver_env(), + 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"] diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index f965278b1..0f436d429 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -33,7 +33,7 @@ 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 = [] - monkeypatch.setattr(composer, "compose", lambda env, base, driver: calls.append((env.id, base.id, driver.id))) + monkeypatch.setattr(composer, "compose", lambda env: calls.append((env.id, env.computer.id, env.driver.id))) return calls @@ -56,7 +56,7 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed 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.id, env.driver.id, env.environment_name) == (UBUNTU, REFERENCE, "desktop") assert env.settings == {UBUNTU: {"timezone": "Europe/Paris"}} and env.display == {"width": 1600, "height": 900} assert composed == [(env.id, UBUNTU, REFERENCE)] and env.id in building diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 188c41d53..4d8e326c8 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -281,12 +281,12 @@ def test_the_pairs_left_to_build_are_those_failed_or_interrupted(base, driver, d composed["container"]["arm64"] = {"status": "building"} env.metadata = {**env.metadata, "composed": composed} - assert composer.pending(env, base, driver) == [("container", "amd64"), ("container", "arm64")] + assert composer.pending(env) == [("container", "amd64"), ("container", "arm64")] docker.fail.clear() - composer.compose(env, base, driver, composer.pending(env, base, driver)) + 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), base, driver) == [] + assert composer.pending(Env.get(env.id)) == [] def test_env_build_builds_what_a_put_left_and_prints_each_pair(base, driver, docker, caplog): @@ -503,11 +503,11 @@ def test_builds_of_one_env_at_once_keep_each_others_pairs_and_a_failure_doesnt_r env = _put(base, driver, compose=False) first, second, third = (Env.get(env.id) for _ in range(3)) - composer.compose(first, base, driver, [("container", "amd64")]) - composer.compose(second, base, driver, [("container", "arm64")]) + 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, base, driver, [("container", "arm64")]) + composer.compose(third, [("container", "arm64")]) stored = Env.get(env.id) composed = stored.metadata["composed"]["container"] diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index 1b215d191..c86e3ba59 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -162,8 +162,8 @@ def _website_env() -> WebsiteEnv: id="desktop", version=2, environment_name="desktop", - computer={"id": "ubuntu-24.04", "version": 1}, - driver={"id": "reference-driver", "version": 4}, + computer=ARTIFACT_FIXTURES["computer"]().model_copy(update={"version": 1}), + driver=ENV_FIXTURES["computer_driver"](), forms=["container"], display={"width": 1280, "height": 800}, network={"mode": "allow_all"}, From 17ea4caf86eaa2b0a63204295ba3212187f56f3e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 21:57:44 -0700 Subject: [PATCH 15/23] refactor(computer): a computer env names no data paths What a computer's data/get returns is the reader's to name, not the env's: the env drops data_paths, and an env.toml naming it is refused as an unknown key. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/env.py | 23 ++++++++----------- src/agent_env/env/envs/computer/gate.py | 6 +---- tst/unit/bundle/computer_env_folder_test.py | 3 ++- tst/unit/env/envs/test_computer_env.py | 7 ++---- .../serialization/golden/env/computer.json | 3 --- .../serialization/test_golden_wire_format.py | 1 - 6 files changed, 15 insertions(+), 28 deletions(-) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index f565a5d36..852a9e63c 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -28,7 +28,7 @@ class ComputerEnv(Env): def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, driver: ComputerDriverEnv, forms: list[str], display: Optional[dict] = None, network: Optional[dict] = None, hidden_tools: Optional[list[str]] = None, settings: Optional[dict] = None, - data_paths: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None): + metadata: Optional[dict[str, Any]] = None): super().__init__(id, version, metadata=metadata) self.environment_name = environment_name self.computer, self.driver = computer, driver @@ -36,14 +36,14 @@ def __init__(self, id: str, version: Optional[int], environment_name: str, compu self.display, self.network = display, network self.hidden_tools = hidden_tools or [] self.settings = settings or {} # setting values by layer id - self.data_paths = data_paths 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}, "forms": self.forms, "display": self.display, "network": self.network, - "hidden_tools": self.hidden_tools, "settings": self.settings, "data_paths": self.data_paths} + "driver": {"id": self.driver.id, "version": self.driver.version}, "forms": self.forms, + "display": self.display, "network": self.network, "hidden_tools": self.hidden_tools, + "settings": self.settings} @classmethod def from_dict(cls, data: dict) -> ComputerEnv: @@ -55,29 +55,27 @@ def from_dict(cls, data: dict) -> ComputerEnv: computer=ComputerArtifact.get(data["computer"]["id"], data["computer"]["version"]), driver=driver, forms=data["forms"], display=data.get("display"), network=data.get("network"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), - data_paths=data.get("data_paths"), metadata=data.get("metadata") or {}) + metadata=data.get("metadata") or {}) @classmethod def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, environment_name: str, forms: Optional[list[str]] = None, apps: Any = (), display: Any = None, network: Any = None, - tools: Any = None, settings: Any = None, data_paths: Any = (), compose: bool = True) -> Self: + tools: Any = None, settings: Any = None, compose: bool = True) -> Self: """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" forms = list(forms) if forms is not None else [form for form in FORMS if getattr(computer.forms, form, None)] - data_paths = list(data_paths) tools = tools if tools is not None else {} problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ f"tools is {{ hide = [, ...] }}, not {tools!r}"] hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] by_layer, mismatched = gate.check(computer, driver, forms=forms, apps=list(apps), display=display, - network=network, hidden_tools=hidden, settings=settings or {}, - data_paths=data_paths) + network=network, hidden_tools=hidden, settings=settings or {}) if problems := problems + mismatched: 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, forms=forms, display=display, network=network, hidden_tools=hidden, settings=by_layer, - data_paths=data_paths)) + computer=computer, driver=driver, forms=forms, display=display, network=network, hidden_tools=hidden, + settings=by_layer)) if compose: composer.compose(env) return env @@ -87,8 +85,7 @@ 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, forms=list, - apps=list, display=dict, network=dict, tools=dict, settings=dict, - data_paths=list) + apps=list, display=dict, network=dict, tools=dict, 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: diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py index cf1b34350..e783ab49d 100644 --- a/src/agent_env/env/envs/computer/gate.py +++ b/src/agent_env/env/envs/computer/gate.py @@ -4,7 +4,6 @@ from __future__ import annotations from fnmatch import fnmatchcase -from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any from agentenv_protocol.computer.conformance.checks import MINIMUM_TOOLS @@ -22,7 +21,7 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, forms: list[str], apps: list, display: Any, - network: Any, hidden_tools: list[str], settings: Any, data_paths: list) -> tuple[dict, list[str]]: + network: Any, hidden_tools: list[str], settings: Any) -> tuple[dict, list[str]]: """The env's settings by layer id, and every way ``base``, ``driver`` and what the env declares don't compose.""" problems = [] if apps: @@ -34,9 +33,6 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, forms: list[str] problems += _display(base, display, settings) problems += _network(network) problems += _tools(driver, hidden_tools) - problems += [f"data_paths: {path!r} isn't a path inside the session user's home" for path in data_paths - if not isinstance(path, str) or not path or PurePosixPath(path).is_absolute() - or ".." in PurePosixPath(path).parts] by_layer, invalid = layered(base, driver, settings) return by_layer, problems + invalid diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 0f436d429..6c22fe6fb 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -65,8 +65,9 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed ('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("display =", 'data_paths = ["Documents"]\ndisplay ='), "unknown key 'data_paths'"), (DESKTOP.replace("1600", "0"), "display is { width = , height = }, each 1 to 32767 pixels"), -], ids=["no-computer", "no-driver", "unknown-key", "display"]) +], ids=["no-computer", "no-driver", "unknown-key", "data-paths", "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)) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 4d8e326c8..87eb18552 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -137,7 +137,7 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ local_stores): local_stores.set_image_store(OciRegistryImageStore("registry.example")) env = _put(base, driver, settings={"ubuntu-24.04": {"timezone": "Europe/Paris", "theme": "dark"}}, - display={"width": 1600, "height": 900}, data_paths=(path for path in ["Documents", "Music"])) + display={"width": 1600, "height": 900}) stored = Env.get(env.id) composed = stored.metadata["composed"]["container"] @@ -152,7 +152,6 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ "amd64": "passed", "arm64": "passed"} assert (computer.config["resolution"].default, computer.config["timezone"].default) == ("1600x900", "Europe/Paris") assert stored.settings == {base.id: {"timezone": "Europe/Paris", "theme": "dark"}} - assert stored.data_paths == ["Documents", "Music"] 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/", @@ -336,8 +335,6 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr ({"tools": {"hide": ["computer_c*"]}}, "tools.hide: 'computer_c*' hides computer_click, of the seven tools every " "computer offers"), ({"tools": {"show": ["x"]}}, "tools is { hide = [, ...] }"), - ({"data_paths": ["/etc"]}, "data_paths: '/etc' isn't a path inside the session user's home"), - ({"data_paths": ["../x"]}, "data_paths: '../x' isn't a path inside the session user's home"), ({"settings": {"mail": {"x": 1}}}, "settings.mail: names neither the computer, @local/~/kit/ubuntu-24.04, nor the " "driver, @local/~/kit/desk"), ({"settings": {"ubuntu-24.04": {"wallpaper": "x"}}}, @@ -346,7 +343,7 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr "settings.ubuntu-24.04.timezone must be a str, like its default 'UTC', not 3"), ({"settings": {"desk": {"log_level": "debug"}}}, "settings.desk.log_level: @local/~/kit/desk declares no setting"), ], ids=["apps", "form", "display-zero", "display-side", "display-and-resolution", "network", "network-key", - "tools-hide", "tools-minimum", "tools-key", "data-absolute", "data-escapes", "settings-layer", "settings-name", "settings-type", + "tools-hide", "tools-minimum", "tools-key", "settings-layer", "settings-name", "settings-type", "settings-driver"]) 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: diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json index f3f122ad9..bc3c8938e 100644 --- a/tst/unit/serialization/golden/env/computer.json +++ b/tst/unit/serialization/golden/env/computer.json @@ -3,9 +3,6 @@ "id": "ubuntu-24.04", "version": 1 }, - "data_paths": [ - "Documents" - ], "display": { "height": 800, "width": 1280 diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index c86e3ba59..fca59713b 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -169,7 +169,6 @@ def _website_env() -> WebsiteEnv: network={"mode": "allow_all"}, hidden_tools=["computer_keyboard_*"], settings={"ubuntu-24.04": {"timezone": "Europe/Paris"}}, - data_paths=["Documents"], metadata={"composed": {"container": {"amd64": { "status": "unverified", "key": "sha256:" + "a" * 64, "reused": False, "emulated": False, "image": "localhost:5000/agentenv-computers:" + "a" * 64 + "@sha256:" + "b" * 64, From 72bf0b7c6bc4fe39dfd13d4bd4847ea8262571ce Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 08:14:17 -0700 Subject: [PATCH 16/23] refactor(computer): a computer env builds every form its base has, naming none Its forms field could only ever be ["container"], the default, and a deploy already picks a form through env_params. The composer builds each form the base has (gate.forms); an env.toml naming forms is refused as an unknown key. An env's choice of forms comes back with a second form (M4). Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 8 +++--- src/agent_env/env/envs/computer/env.py | 26 ++++++++----------- src/agent_env/env/envs/computer/gate.py | 11 +++++--- tst/unit/bundle/computer_env_folder_test.py | 3 ++- tst/unit/env/envs/test_computer_env.py | 3 +-- .../serialization/golden/env/computer.json | 3 --- .../serialization/test_golden_wire_format.py | 1 - 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 5495e2037..dbeb03fee 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -65,10 +65,10 @@ def compose(env: ComputerEnv, pairs: list[tuple[str, str]] | None = None) -> None: - """Build each of ``pairs``, by default every form and 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.""" + """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.""" base, driver = env.computer, env.driver - pairs = pairs if pairs is not None else [(form, arch) for form in env.forms for arch in gate.arches(base, driver)] + 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: @@ -93,7 +93,7 @@ def current(env: ComputerEnv) -> bool: 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 env.forms for arch in gate.arches(env.computer, env.driver) + 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] diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 852a9e63c..2efbfca8c 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -1,12 +1,12 @@ """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 and arch, each recorded in its metadata as it builds -(``composed``), and the arches built written as its computer (``computer``).""" +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 typing import TYPE_CHECKING, Any, ClassVar, Optional, Self -from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact +from agent_env.artifact.artifacts.computer import ComputerArtifact from agent_env.entity.refs import EntityRef from agent_env.env.env import Env from agent_env.env.envs.computer import composer, gate @@ -26,13 +26,12 @@ class ComputerEnv(Env): EntityRef.env("driver", env_type=ComputerDriverEnv.type)) def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, - driver: ComputerDriverEnv, forms: list[str], display: Optional[dict] = None, network: Optional[dict] = None, + driver: ComputerDriverEnv, display: Optional[dict] = None, network: Optional[dict] = None, hidden_tools: Optional[list[str]] = None, settings: Optional[dict] = 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.forms = forms self.display, self.network = display, network self.hidden_tools = hidden_tools or [] self.settings = settings or {} # setting values by layer id @@ -41,8 +40,7 @@ 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}, "forms": self.forms, - "display": self.display, "network": self.network, "hidden_tools": self.hidden_tools, + "driver": {"id": self.driver.id, "version": self.driver.version}, "display": self.display, "network": self.network, "hidden_tools": self.hidden_tools, "settings": self.settings} @classmethod @@ -53,28 +51,26 @@ def from_dict(cls, data: dict) -> ComputerEnv: 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, - forms=data["forms"], display=data.get("display"), - network=data.get("network"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), + display=data.get("display"), network=data.get("network"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), metadata=data.get("metadata") or {}) @classmethod def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, environment_name: str, - forms: Optional[list[str]] = None, apps: Any = (), display: Any = None, network: Any = None, + apps: Any = (), display: Any = None, network: Any = None, tools: Any = None, settings: Any = None, compose: bool = True) -> Self: """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" - forms = list(forms) if forms is not None else [form for form in FORMS if getattr(computer.forms, form, None)] tools = tools if tools is not None else {} problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ f"tools is {{ hide = [, ...] }}, not {tools!r}"] hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] - by_layer, mismatched = gate.check(computer, driver, forms=forms, apps=list(apps), display=display, + by_layer, mismatched = gate.check(computer, driver, apps=list(apps), display=display, network=network, hidden_tools=hidden, settings=settings or {}) if problems := problems + mismatched: 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, forms=forms, display=display, network=network, hidden_tools=hidden, + computer=computer, driver=driver, display=display, network=network, hidden_tools=hidden, settings=by_layer)) if compose: composer.compose(env) @@ -84,8 +80,8 @@ def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, 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, forms=list, - apps=list, display=dict, network=dict, tools=dict, settings=dict) + fields, refused = ctx.accepted(data, environment_name=str, computer=object, driver=object, apps=list, + display=dict, network=dict, tools=dict, 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: diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py index e783ab49d..1c0f6644c 100644 --- a/src/agent_env/env/envs/computer/gate.py +++ b/src/agent_env/env/envs/computer/gate.py @@ -20,14 +20,12 @@ MAX_SIDE = 32767 -def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, forms: list[str], apps: list, display: Any, - network: Any, hidden_tools: list[str], settings: Any) -> tuple[dict, list[str]]: +def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, display: Any, network: Any, + hidden_tools: list[str], settings: Any) -> tuple[dict, list[str]]: """The env's settings by layer id, 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 += [f"forms: the {form} form isn't supported yet; a computer env composes {', '.join(FORMS)}" - for form in forms if form not in FORMS] problems += _platform(base, driver) problems += _image(base) problems += _display(base, display, settings) @@ -37,6 +35,11 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, forms: list[str] return by_layer, 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.""" diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 6c22fe6fb..78aaeeb46 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -66,8 +66,9 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed ('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("display =", 'data_paths = ["Documents"]\ndisplay ='), "unknown key 'data_paths'"), + (DESKTOP.replace("display =", 'forms = ["container"]\ndisplay ='), "unknown key 'forms'"), (DESKTOP.replace("1600", "0"), "display is { width = , height = }, each 1 to 32767 pixels"), -], ids=["no-computer", "no-driver", "unknown-key", "data-paths", "display"]) +], ids=["no-computer", "no-driver", "unknown-key", "data-paths", "forms", "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)) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 87eb18552..c621645c0 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -323,7 +323,6 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr @pytest.mark.parametrize("changes, problem", [ ({"apps": [{"env": "mail"}]}, "apps: computer app envs aren't supported yet"), - ({"forms": ["disk"]}, "forms: the disk form isn't supported yet; a computer env composes container"), ({"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}, "settings": {"ubuntu-24.04": {"resolution": "800x600"}}}, @@ -342,7 +341,7 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr ({"settings": {"ubuntu-24.04": {"timezone": 3}}}, "settings.ubuntu-24.04.timezone must be a str, like its default 'UTC', not 3"), ({"settings": {"desk": {"log_level": "debug"}}}, "settings.desk.log_level: @local/~/kit/desk declares no setting"), -], ids=["apps", "form", "display-zero", "display-side", "display-and-resolution", "network", "network-key", +], ids=["apps", "display-zero", "display-side", "display-and-resolution", "network", "network-key", "tools-hide", "tools-minimum", "tools-key", "settings-layer", "settings-name", "settings-type", "settings-driver"]) def test_what_the_env_declares_wrong_is_refused_before_anything_is_stored(base, driver, docker, changes, problem): diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json index bc3c8938e..006379920 100644 --- a/tst/unit/serialization/golden/env/computer.json +++ b/tst/unit/serialization/golden/env/computer.json @@ -12,9 +12,6 @@ "version": 4 }, "environment_name": "desktop", - "forms": [ - "container" - ], "hidden_tools": [ "computer_keyboard_*" ], diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index fca59713b..ec0713a7c 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -164,7 +164,6 @@ def _website_env() -> WebsiteEnv: environment_name="desktop", computer=ARTIFACT_FIXTURES["computer"]().model_copy(update={"version": 1}), driver=ENV_FIXTURES["computer_driver"](), - forms=["container"], display={"width": 1280, "height": 800}, network={"mode": "allow_all"}, hidden_tools=["computer_keyboard_*"], From 3ee56e43fd16dd8d46a8737b1254b45b81487bba Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 08:18:12 -0700 Subject: [PATCH 17/23] fix(bundle): an entity-only plan resolves installed aliases too write_installed plans through plan_entities, which skipped alias resolution, so an installed computer env naming ubuntu-24.04 and reference failed to write on a fresh store. It now resolves them as a run does. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 1 + tst/unit/bundle/computer_env_folder_test.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index ff91a0360..a8fa76d96 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -524,6 +524,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() self._check_writers() self._check_loops() return self._planned(self._select_entities(ids), [], [], None) diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 78aaeeb46..75f978fb6 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -9,7 +9,7 @@ from agent_env.bundle import BundleError, 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.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 @@ -47,8 +47,15 @@ def _plan(root): return plan_bundle(resolve_bundle(parse_bundle(root)), installed=installed_bundles) -def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed(tmp_path, composed): - plan = _plan(_bundle(tmp_path / "desk")) +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)) From 84341cd22b64bc6cdb7c9ec26d2ed2cc2ee846e0 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 08:41:16 -0700 Subject: [PATCH 18/23] refactor(computer): computer_settings, the base's settings by name, not settings by layer Only the base declares settings, so the layer key always named the env's own computer. A computer env keeps the base's values in computer_settings ([computer_settings] in an env.toml), checked against the base's declared settings; a driver's or an app's get tables of their own when they arrive. SettingValue names what a setting holds. The refusals of shapes never released go. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/artifact/artifacts/computer.py | 4 +- src/agent_env/env/envs/computer/composer.py | 4 +- src/agent_env/env/envs/computer/env.py | 23 ++++---- src/agent_env/env/envs/computer/gate.py | 58 ++++++++----------- tst/unit/bundle/computer_env_folder_test.py | 10 ++-- tst/unit/env/envs/test_computer_env.py | 24 ++++---- .../serialization/golden/env/computer.json | 8 +-- .../serialization/test_golden_wire_format.py | 2 +- 8 files changed, 59 insertions(+), 74 deletions(-) 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/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index dbeb03fee..c7a9fee6e 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -223,7 +223,7 @@ def _protocol_source() -> Path | 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.""" - values = env.settings.get(base.id, {}) + values = env.computer_settings return {name: values[name] for name, setting in base.config.items() if setting.phase == "build" and name in values} @@ -320,7 +320,7 @@ def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str]) - 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.settings.get(base.id, {}).items(): + for name, value in env.computer_settings.items(): config[name]["default"] = value if env.display is not None: config["resolution"]["default"] = f"{env.display['width']}x{env.display['height']}" diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 2efbfca8c..dd1bd7b5a 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self -from agent_env.artifact.artifacts.computer import ComputerArtifact +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 @@ -27,21 +27,21 @@ class ComputerEnv(Env): def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, driver: ComputerDriverEnv, display: Optional[dict] = None, network: Optional[dict] = None, - hidden_tools: Optional[list[str]] = None, settings: Optional[dict] = None, + hidden_tools: Optional[list[str]] = 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.hidden_tools = hidden_tools or [] - self.settings = settings or {} # setting values by layer id + 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, "hidden_tools": self.hidden_tools, - "settings": self.settings} + "computer_settings": self.computer_settings} @classmethod def from_dict(cls, data: dict) -> ComputerEnv: @@ -51,27 +51,28 @@ def from_dict(cls, data: dict) -> ComputerEnv: 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"), hidden_tools=data.get("hidden_tools"), settings=data.get("settings"), + display=data.get("display"), network=data.get("network"), hidden_tools=data.get("hidden_tools"), + 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, - tools: Any = None, settings: Any = None, compose: bool = True) -> Self: + apps: Any = (), display: Any = None, network: Any = None, tools: Any = None, + computer_settings: Any = None, compose: bool = True) -> Self: """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" tools = tools if tools is not None else {} problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ f"tools is {{ hide = [, ...] }}, not {tools!r}"] hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] - by_layer, mismatched = gate.check(computer, driver, apps=list(apps), display=display, - network=network, hidden_tools=hidden, settings=settings or {}) + values, mismatched = gate.check(computer, driver, apps=list(apps), display=display, network=network, + hidden_tools=hidden, computer_settings=computer_settings or {}) if problems := problems + mismatched: 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, hidden_tools=hidden, - settings=by_layer)) + computer_settings=values)) if compose: composer.compose(env) return env @@ -81,7 +82,7 @@ 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, tools=dict, settings=dict) + display=dict, network=dict, tools=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: diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py index 1c0f6644c..fcfbe7342 100644 --- a/src/agent_env/env/envs/computer/gate.py +++ b/src/agent_env/env/envs/computer/gate.py @@ -9,7 +9,7 @@ from agentenv_protocol.computer.conformance.checks import MINIMUM_TOOLS from agentenv_protocol.computer.platform import Platform, supported -from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact +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 @@ -21,18 +21,19 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, display: Any, network: Any, - hidden_tools: list[str], settings: Any) -> tuple[dict, list[str]]: - """The env's settings by layer id, and every way ``base``, ``driver`` and what the env declares don't compose.""" + hidden_tools: list[str], 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, settings) + problems += _display(base, display, computer_settings) problems += _network(network) problems += _tools(driver, hidden_tools) - by_layer, invalid = layered(base, driver, settings) - return by_layer, problems + invalid + values, invalid = _computer_settings(base, computer_settings) + return values, problems + invalid def forms(base: ComputerArtifact) -> list[str]: @@ -47,30 +48,21 @@ def arches(base: ComputerArtifact, driver: ComputerDriverEnv) -> list[str]: return [arch for arch in base.arch if arch in matching] -def layered(base: ComputerArtifact, driver: ComputerDriverEnv, settings: Any) -> tuple[dict, list[str]]: - """``settings``, a table of setting values by layer, keyed by the layer's id instead, and the problems with it: a - layer is named by its id or its name, the last part of its id, and only the base declares settings.""" - if not isinstance(settings, dict): - return {}, [f"settings must be a table of values by layer, not {settings!r}"] - names = {key: layer for layer in (base.id, driver.id) for key in (layer, layer.rsplit("/", 1)[-1])} - declared = {base.id: base.config, driver.id: {}} - by_layer, problems = {}, [] - for key, values in settings.items(): - if (layer := names.get(key)) is None: - problems.append(f"settings.{key}: names neither the computer, {base.id}, nor the driver, {driver.id}") - continue - if not isinstance(values, dict): - problems.append(f"settings.{key} must be a table of setting values, not {values!r}") - continue - for name, value in values.items(): - if (setting := declared[layer].get(name)) is None: - problems.append(f"settings.{key}.{name}: {layer} declares no setting {name!r}") - elif type(value) is not type(setting.default): - problems.append(f"settings.{key}.{name} must be a {type(setting.default).__name__}, like its default " - f"{setting.default!r}, not {value!r}") - else: - by_layer.setdefault(layer, {})[name] = value - return by_layer, problems +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]: @@ -112,7 +104,7 @@ def _image(base: ComputerArtifact) -> list[str]: "to build it from"] -def _display(base: ComputerArtifact, display: Any, settings: Any) -> list[str]: +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( @@ -120,9 +112,7 @@ def _display(base: ComputerArtifact, display: Any, settings: Any) -> list[str]: 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"] - names = {base.id, base.id.rsplit("/", 1)[-1]} - if isinstance(settings, dict) and any(isinstance(values, dict) and "resolution" in values - for key, values in settings.items() if key in names): + 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 [] diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 75f978fb6..587d93f3d 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -1,6 +1,6 @@ """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 settings keyed by the base's, and the run says it builds with docker. The composer is replaced by a recorder.""" +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 @@ -23,7 +23,7 @@ driver = "reference" display = { width = 1600, height = 900 } -[settings."ubuntu-24.04"] +[computer_settings] timezone = "Europe/Paris" ''' @@ -64,7 +64,7 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed 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.settings == {UBUNTU: {"timezone": "Europe/Paris"}} and env.display == {"width": 1600, "height": 900} + 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 @@ -72,10 +72,8 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed ('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("display =", 'data_paths = ["Documents"]\ndisplay ='), "unknown key 'data_paths'"), - (DESKTOP.replace("display =", 'forms = ["container"]\ndisplay ='), "unknown key 'forms'"), (DESKTOP.replace("1600", "0"), "display is { width = , height = }, each 1 to 32767 pixels"), -], ids=["no-computer", "no-driver", "unknown-key", "data-paths", "forms", "display"]) +], 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)) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index c621645c0..86326e788 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -136,7 +136,7 @@ def _put(base, driver, compose=True, **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, settings={"ubuntu-24.04": {"timezone": "Europe/Paris", "theme": "dark"}}, + env = _put(base, driver, computer_settings={"timezone": "Europe/Paris", "theme": "dark"}, display={"width": 1600, "height": 900}) stored = Env.get(env.id) @@ -151,7 +151,7 @@ def test_a_computer_env_is_composed_for_each_arch_and_written_with_the_computer_ 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.settings == {base.id: {"timezone": "Europe/Paris", "theme": "dark"}} + 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/", @@ -198,9 +198,9 @@ def test_what_changes_the_image_changes_its_key_and_builds_it_again(base, driver 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 = {"ubuntu-24.04": {"theme": "dark"}} if change == "build-setting" else {} + settings = {"theme": "dark"} if change == "build-setting" else {} - again = Env.get(_put(base, driver, settings=settings).id).metadata["composed"]["container"]["arm64"] + again = Env.get(_put(base, driver, computer_settings=settings).id).metadata["composed"]["container"]["arm64"] assert again["key"] != first and again["reused"] is False and len(docker.builds) == 4 @@ -325,7 +325,7 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr ({"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}, "settings": {"ubuntu-24.04": {"resolution": "800x600"}}}, + ({"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"), @@ -334,16 +334,12 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr ({"tools": {"hide": ["computer_c*"]}}, "tools.hide: 'computer_c*' hides computer_click, of the seven tools every " "computer offers"), ({"tools": {"show": ["x"]}}, "tools is { hide = [, ...] }"), - ({"settings": {"mail": {"x": 1}}}, "settings.mail: names neither the computer, @local/~/kit/ubuntu-24.04, nor the " - "driver, @local/~/kit/desk"), - ({"settings": {"ubuntu-24.04": {"wallpaper": "x"}}}, - "settings.ubuntu-24.04.wallpaper: @local/~/kit/ubuntu-24.04 declares no setting 'wallpaper'"), - ({"settings": {"ubuntu-24.04": {"timezone": 3}}}, - "settings.ubuntu-24.04.timezone must be a str, like its default 'UTC', not 3"), - ({"settings": {"desk": {"log_level": "debug"}}}, "settings.desk.log_level: @local/~/kit/desk declares no setting"), + ({"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", - "tools-hide", "tools-minimum", "tools-key", "settings-layer", "settings-name", "settings-type", - "settings-driver"]) + "tools-hide", "tools-minimum", "tools-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) diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json index 006379920..979fad556 100644 --- a/tst/unit/serialization/golden/env/computer.json +++ b/tst/unit/serialization/golden/env/computer.json @@ -3,6 +3,9 @@ "id": "ubuntu-24.04", "version": 1 }, + "computer_settings": { + "timezone": "Europe/Paris" + }, "display": { "height": 800, "width": 1280 @@ -41,11 +44,6 @@ "mode": "allow_all" }, "service_name": "desktop", - "settings": { - "ubuntu-24.04": { - "timezone": "Europe/Paris" - } - }, "type": "computer", "version": 2 } diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index ec0713a7c..f06bbf3e8 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -167,7 +167,7 @@ def _website_env() -> WebsiteEnv: display={"width": 1280, "height": 800}, network={"mode": "allow_all"}, hidden_tools=["computer_keyboard_*"], - settings={"ubuntu-24.04": {"timezone": "Europe/Paris"}}, + 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, From 4442bb98e74d99f4f62593653e8e74623428ea5a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 08:49:00 -0700 Subject: [PATCH 19/23] refactor(computer): a computer env hides no tools of its own [tools] hide duplicated modify_env_tool_access, which disables tools on a computer env through its driver's disable-tool/v1 per role and per task, as on any env. ComputerEnv loses hidden_tools, and gate 1 its tools check. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/env.py | 32 +++++++------------ src/agent_env/env/envs/computer/gate.py | 17 +--------- tst/unit/env/envs/test_computer_env.py | 8 ++--- .../serialization/golden/env/computer.json | 3 -- .../serialization/test_golden_wire_format.py | 1 - 5 files changed, 15 insertions(+), 46 deletions(-) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index dd1bd7b5a..59a8b83f6 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -27,21 +27,19 @@ class ComputerEnv(Env): def __init__(self, id: str, version: Optional[int], environment_name: str, computer: ComputerArtifact, driver: ComputerDriverEnv, display: Optional[dict] = None, network: Optional[dict] = None, - hidden_tools: Optional[list[str]] = None, computer_settings: Optional[dict[str, SettingValue]] = None, - metadata: Optional[dict[str, Any]] = 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.hidden_tools = hidden_tools or [] 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, "hidden_tools": self.hidden_tools, - "computer_settings": self.computer_settings} + "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: @@ -51,28 +49,22 @@ def from_dict(cls, data: dict) -> ComputerEnv: 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"), hidden_tools=data.get("hidden_tools"), - computer_settings=data.get("computer_settings"), - metadata=data.get("metadata") or {}) + 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, tools: Any = None, - computer_settings: Any = None, compose: bool = True) -> Self: + apps: Any = (), display: Any = None, network: Any = None, computer_settings: Any = None, + compose: bool = True) -> Self: """Write a computer env of ``computer`` and ``driver`` once gate 1 passes, then compose it unless ``compose`` is False. Refused, naming every mismatch, before anything is stored.""" - tools = tools if tools is not None else {} - problems = [] if isinstance(tools, dict) and tools.keys() <= {"hide"} else [ - f"tools is {{ hide = [, ...] }}, not {tools!r}"] - hidden = list(tools.get("hide", [])) if isinstance(tools, dict) else [] - values, mismatched = gate.check(computer, driver, apps=list(apps), display=display, network=network, - hidden_tools=hidden, computer_settings=computer_settings or {}) - if problems := problems + mismatched: + 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, hidden_tools=hidden, - computer_settings=values)) + computer=computer, driver=driver, display=display, network=network, computer_settings=values)) if compose: composer.compose(env) return env @@ -82,7 +74,7 @@ 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, tools=dict, computer_settings=dict) + 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: diff --git a/src/agent_env/env/envs/computer/gate.py b/src/agent_env/env/envs/computer/gate.py index fcfbe7342..a1334e837 100644 --- a/src/agent_env/env/envs/computer/gate.py +++ b/src/agent_env/env/envs/computer/gate.py @@ -3,10 +3,8 @@ from __future__ import annotations -from fnmatch import fnmatchcase from typing import TYPE_CHECKING, Any -from agentenv_protocol.computer.conformance.checks import MINIMUM_TOOLS from agentenv_protocol.computer.platform import Platform, supported from agent_env.artifact.artifacts.computer import FORMS, ComputerArtifact, SettingValue @@ -21,7 +19,7 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, display: Any, network: Any, - hidden_tools: list[str], computer_settings: Any) -> tuple[dict[str, SettingValue], list[str]]: + 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 = [] @@ -31,7 +29,6 @@ def check(base: ComputerArtifact, driver: ComputerDriverEnv, *, apps: list, disp problems += _image(base) problems += _display(base, display, computer_settings) problems += _network(network) - problems += _tools(driver, hidden_tools) values, invalid = _computer_settings(base, computer_settings) return values, problems + invalid @@ -127,15 +124,3 @@ def _network(network: Any) -> list[str]: except (TypeError, ValueError) as e: return [f"network: {e}"] return [] - - -def _tools(driver: ComputerDriverEnv, hidden: list[str]) -> list[str]: - tools = {tool for platform in driver.declaration["platforms"] for tool in platform["tools"]} - problems = [] - for pattern in hidden: - if not isinstance(pattern, str) or not any(fnmatchcase(tool, pattern) for tool in tools): - problems.append(f"tools.hide: {pattern!r} matches none of {driver.id}'s tools ({', '.join(sorted(tools))})") - elif minimum := [tool for tool in MINIMUM_TOOLS if fnmatchcase(tool, pattern)]: - problems.append(f"tools.hide: {pattern!r} hides {', '.join(minimum)}, of the seven tools every computer " - "offers") - return problems diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 86326e788..b7fa277aa 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -180,7 +180,7 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one first = _put(base, driver) docker_calls.clear() second = _put(base, driver, id="@local/~/kit/other", environment_name="other", display={"width": 640, "height": 480}, - network={"mode": "allowlist"}, tools={"hide": ["computer_pointer_list"]}) + network={"mode": "allowlist"}) 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) @@ -330,16 +330,12 @@ async def test_a_computer_env_doesnt_deploy_yet_and_says_how_far_it_got(base, dr ({"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 }"), - ({"tools": {"hide": ["computer_drag*"]}}, "tools.hide: 'computer_drag*' matches none of @local/~/kit/desk's tools"), - ({"tools": {"hide": ["computer_c*"]}}, "tools.hide: 'computer_c*' hides computer_click, of the seven tools every " - "computer offers"), - ({"tools": {"show": ["x"]}}, "tools is { hide = [, ...] }"), ({"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", - "tools-hide", "tools-minimum", "tools-key", "settings-name", "settings-type", "settings-table"]) + "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) diff --git a/tst/unit/serialization/golden/env/computer.json b/tst/unit/serialization/golden/env/computer.json index 979fad556..2ace631ca 100644 --- a/tst/unit/serialization/golden/env/computer.json +++ b/tst/unit/serialization/golden/env/computer.json @@ -15,9 +15,6 @@ "version": 4 }, "environment_name": "desktop", - "hidden_tools": [ - "computer_keyboard_*" - ], "id": "desktop", "metadata": { "composed": { diff --git a/tst/unit/serialization/test_golden_wire_format.py b/tst/unit/serialization/test_golden_wire_format.py index f06bbf3e8..f0cbe123c 100644 --- a/tst/unit/serialization/test_golden_wire_format.py +++ b/tst/unit/serialization/test_golden_wire_format.py @@ -166,7 +166,6 @@ def _website_env() -> WebsiteEnv: driver=ENV_FIXTURES["computer_driver"](), display={"width": 1280, "height": 800}, network={"mode": "allow_all"}, - hidden_tools=["computer_keyboard_*"], computer_settings={"timezone": "Europe/Paris"}, metadata={"composed": {"container": {"amd64": { "status": "unverified", "key": "sha256:" + "a" * 64, "reused": False, "emulated": False, From 694532bf57acd4f02f250aa146e4c99cdf52044a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 08:55:35 -0700 Subject: [PATCH 20/23] refactor(bundle): an env's write says it builds only when it does, not by a flag on Env Env.builds_at_put said before every computer env's put that it was building, though a rerun reuses every image. The run's build callback now reaches the env's from_toml on the AuthoringContext (on_build), and the composer calls it once, just before it first builds an image, as image entries already do. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/authoring.py | 4 ++- src/agent_env/bundle/materialize.py | 9 ++++--- src/agent_env/env/env.py | 2 -- src/agent_env/env/envs/computer/composer.py | 29 +++++++++++++++++---- src/agent_env/env/envs/computer/env.py | 11 ++++---- tst/unit/bundle/computer_env_folder_test.py | 7 ++++- tst/unit/env/envs/test_computer_env.py | 7 ++--- 7 files changed, 48 insertions(+), 21 deletions(-) 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 5539e99d4..f85afab91 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -156,8 +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 and get_env_registry()[write.source.entry.type].builds_at_put and on_build: - on_build(write) + if write.kind is BundleKind.ENV: + return _write_env(plan, write, _before(on_build, write)) return _WRITERS[write.kind](plan, write) @@ -283,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/env/env.py b/src/agent_env/env/env.py index a3047b2c2..56dedcf68 100644 --- a/src/agent_env/env/env.py +++ b/src/agent_env/env/env.py @@ -231,8 +231,6 @@ class Env(ABC): type: ClassVar[str] = "env" toml_refs: ClassVar[tuple[EntityRef, ...]] = () env_provider_types: ClassVar[tuple[str, ...]] = () - #: Whether writing one builds images with this machine's Docker, which a bundle run says before it writes one. - builds_at_put: ClassVar[bool] = False def __init__(self, id: str, version: Optional[int], metadata: Optional[dict[str, Any]] = None): self.id: str = id diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index c7a9fee6e..a6ca301d3 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -22,6 +22,7 @@ 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 @@ -64,15 +65,18 @@ _PROTOCOL_FILES = ("pyproject.toml", "README.md", "LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md") -def compose(env: ComputerEnv, pairs: list[tuple[str, str]] | None = None) -> None: +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.""" + 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): (form, arch) for form, arch in pairs} + 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() @@ -97,8 +101,10 @@ def pending(env: ComputerEnv) -> list[tuple[str, str]]: if composed.get(form, {}).get(arch, {}).get("status") not in _BUILT] -def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, form: str, arch: str) -> dict: - """The status of ``form`` and ``arch`` once built and pushed, or reused, or the stage that failed and why.""" +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: @@ -124,6 +130,7 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, 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}" @@ -146,6 +153,18 @@ def _built(env: ComputerEnv, base: ComputerArtifact, driver: ComputerDriverEnv, 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 diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 59a8b83f6..a38ae5a53 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -4,6 +4,7 @@ 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 @@ -21,7 +22,6 @@ 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" - builds_at_put: ClassVar[bool] = True toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("computer", artifact_type="computer"), EntityRef.env("driver", env_type=ComputerDriverEnv.type)) @@ -55,9 +55,10 @@ def from_dict(cls, data: dict) -> ComputerEnv: @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) -> Self: + 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. Refused, naming every mismatch, before anything is stored.""" + 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: @@ -66,7 +67,7 @@ def put(cls, *, id: str, computer: ComputerArtifact, driver: ComputerDriverEnv, id=id, version=None, environment_name=environment_name, computer=computer, driver=driver, display=display, network=network, computer_settings=values)) if compose: - composer.compose(env) + composer.compose(env, on_build=on_build) return env @classmethod @@ -83,7 +84,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> ComputerEnv: ComputerDriverEnv) try: return cls.put(id=ctx.id, computer=base, driver=driver, - environment_name=fields.pop("environment_name", ctx.name), **fields) + environment_name=fields.pop("environment_name", ctx.name), on_build=ctx.on_build, **fields) except ValueError as e: ctx.refuse([str(e)]) diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 587d93f3d..8250bb100 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -33,7 +33,12 @@ 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 = [] - monkeypatch.setattr(composer, "compose", lambda env: calls.append((env.id, env.computer.id, env.driver.id))) + + 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 diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index b7fa277aa..d06e289bd 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -177,13 +177,14 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one 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))) - first = _put(base, driver) + 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"}) + 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) + 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)} From 3ef998fef6a3f822fd5e551ea8bea43120f4c499 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 09:53:07 -0700 Subject: [PATCH 21/23] fix(bundle): an entity-only plan leaves the bundle's tasks and evals out of its alias pass Writing an env or artifact alone resolved aliases for every task too, so a default agent naming a refused alias blocked a write no task runs for. Tasks and evals are left as they are when nothing runs. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/bundle/plan.py | 9 +++++--- tst/unit/bundle/computer_env_folder_test.py | 24 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index a8fa76d96..fd6d09a1a 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -304,12 +304,13 @@ 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: + 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.""" + ``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() @@ -320,6 +321,8 @@ def _resolve_aliases(self) -> None: self.nodes[key], self.edges[key] = pointed, _needs(pointed) default = _UNREAD for index, entry in enumerate(self.entries): + if not runs and entry.entry.kind in (BundleKind.TASK, BundleKind.EVAL): + continue 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): @@ -524,7 +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() + self._resolve_aliases(runs=False) self._check_writers() self._check_loops() return self._planned(self._select_entities(ids), [], [], None) diff --git a/tst/unit/bundle/computer_env_folder_test.py b/tst/unit/bundle/computer_env_folder_test.py index 8250bb100..4f3c38cc3 100644 --- a/tst/unit/bundle/computer_env_folder_test.py +++ b/tst/unit/bundle/computer_env_folder_test.py @@ -8,6 +8,7 @@ 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 @@ -48,6 +49,14 @@ def _bundle(root, toml=DESKTOP): "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) @@ -73,6 +82,21 @@ def test_a_computer_env_naming_shipped_aliases_is_written_over_them_and_composed 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"'), From 572960c857a92925b4976701091ffeec4d070c42 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 10:17:08 -0700 Subject: [PATCH 22/23] fix(computer): an env's display reaches the image of a base that reads its resolution at build The display only set the composed computer's default, so a base whose resolution is a build-phase setting built its default size while the record said the display's. The display is now the resolution's value among the build-phase settings too, so it's in the image and its key. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 11 ++++++++--- tst/unit/env/envs/test_computer_env.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index a6ca301d3..981edafc7 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -241,11 +241,16 @@ def _protocol_source() -> Path | 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.""" - values = env.computer_settings + """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.""" @@ -342,7 +347,7 @@ def _computer(env: ComputerEnv, base: ComputerArtifact, built: dict[str, str]) - for name, value in env.computer_settings.items(): config[name]["default"] = value if env.display is not None: - config["resolution"]["default"] = f"{env.display['width']}x{env.display['height']}" + 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, diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index d06e289bd..728b3245d 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -192,18 +192,24 @@ def test_an_env_that_differs_only_in_what_doesnt_change_the_image_reuses_the_one assert docker_calls == [("login", auth), ("join", index)] -@pytest.mark.parametrize("change", ["requirements", "build-setting", "service"]) +@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).id).metadata["composed"]["container"]["arm64"] + 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): From 98e139a20034f14358b2787caf8b94925bc3f5a9 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 28 Oct 2026 11:05:54 -0700 Subject: [PATCH 23/23] fix(computer): refuse a driver file named outside its folder before writing the build context A driver's files come from a stored file universe, and FileArtifactUniverse.put takes any name, so a name such as ../../x reached a write outside the composer's temporary build folder. Each name now passes entity.export.checked_path first; one that's absolute or climbs out fails the pair at build, naming it. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/composer.py | 6 +++++- tst/unit/env/envs/test_computer_env.py | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/agent_env/env/envs/computer/composer.py b/src/agent_env/env/envs/computer/composer.py index 981edafc7..52e0ba33e 100644 --- a/src/agent_env/env/envs/computer/composer.py +++ b/src/agent_env/env/envs/computer/composer.py @@ -29,6 +29,7 @@ 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 @@ -194,7 +195,10 @@ def _stage_driver(context: Path, driver: ComputerDriverEnv, base: ComputerArtifa 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(): - path = PurePosixPath(name) + 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) diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 728b3245d..f3716c3e9 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -112,12 +112,14 @@ def _base(image, **changes): "init": "supervisord", "input_channel": "env"}}, **changes}) -def _driver(folder, requirements="mss>=9,<11\n", supports=None, services=None, install=True): +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")] @@ -226,6 +228,19 @@ def test_a_pair_that_fails_is_recorded_with_its_log_and_the_rest_are_built_and_w 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()